diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index e0d1a53e..cfc4b0aa 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -12,17 +12,23 @@ import "encoding/json" // AnthropicRequest is the request body for POST /v1/messages. type AnthropicRequest struct { - Model string `json:"model"` - MaxTokens int `json:"max_tokens"` - System json.RawMessage `json:"system,omitempty"` // string or []AnthropicContentBlock - Messages []AnthropicMessage `json:"messages"` - Tools []AnthropicTool `json:"tools,omitempty"` - Stream bool `json:"stream,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - StopSeqs []string `json:"stop_sequences,omitempty"` - Thinking *AnthropicThinking `json:"thinking,omitempty"` - ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System json.RawMessage `json:"system,omitempty"` // string or []AnthropicContentBlock + Messages []AnthropicMessage `json:"messages"` + Tools []AnthropicTool `json:"tools,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + StopSeqs []string `json:"stop_sequences,omitempty"` + Thinking *AnthropicThinking `json:"thinking,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + // Metadata 会被原样透传给上游。OAuth/Claude-Code 路径依赖 metadata.user_id + // 参与上游的"是否为官方 Claude Code 请求"判定;如果经由本结构体重新序列化 + // 时丢弃该字段,网关侧后续的 metadata 重写(ensureClaudeOAuthMetadataUserID/ + // RewriteUserIDWithMasking) 在 body 里拿不到起点,就无法重建一个合法的 + // user_id,进而导致请求被归类为第三方 app。 + Metadata json.RawMessage `json:"metadata,omitempty"` OutputConfig *AnthropicOutputConfig `json:"output_config,omitempty"` } diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 37b38f76..8248d26c 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -85,15 +85,16 @@ func (s *GatewayService) ForwardAsChatCompletions( return nil, fmt.Errorf("marshal anthropic request: %w", err) } - // 6. Apply Claude Code mimicry for OAuth accounts - isClaudeCode := false // CC API is never Claude Code + // 6. Apply Claude Code mimicry for OAuth accounts. + // Chat Completions 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号 + // 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入), + // 否则会被 Anthropic 判为第三方应用并扣 extra usage。 + // 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。 + isClaudeCode := false shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { - if !strings.Contains(strings.ToLower(mappedModel), "haiku") && - !systemIncludesClaudeCodePrompt(anthropicReq.System) { - anthropicBody = injectClaudeCodePrompt(anthropicBody, anthropicReq.System) - } + anthropicBody = s.applyClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel) } // 7. Enforce cache_control block limit diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 2c917112..1ecad7d3 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -82,15 +82,16 @@ func (s *GatewayService) ForwardAsResponses( return nil, fmt.Errorf("marshal anthropic request: %w", err) } - // 6. Apply Claude Code mimicry for OAuth accounts (non-Claude-Code endpoints) - isClaudeCode := false // Responses API is never Claude Code + // 6. Apply Claude Code mimicry for OAuth accounts (non-Claude-Code endpoints). + // OpenAI Responses 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号 + // 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入), + // 否则会被 Anthropic 判为第三方应用并扣 extra usage。 + // 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。 + isClaudeCode := false shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { - if !strings.Contains(strings.ToLower(mappedModel), "haiku") && - !systemIncludesClaudeCodePrompt(anthropicReq.System) { - anthropicBody = injectClaudeCodePrompt(anthropicBody, anthropicReq.System) - } + anthropicBody = s.applyClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel) } // 7. Enforce cache_control block limit diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 5a91d0de..0c6f5001 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1128,6 +1128,117 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion) } +// applyClaudeCodeOAuthMimicryToBody 将"非 Claude Code 客户端 + Claude OAuth 账号" +// 路径上原本只在 /v1/messages 里做的完整伪装应用到任意 body 上。 +// +// 这是 /v1/messages 主路径上 rewriteSystemForNonClaudeCode + +// normalizeClaudeOAuthRequestBody 流程的通用版,供 OpenAI 协议兼容层 +// (ForwardAsChatCompletions / ForwardAsResponses) 复用。 +// +// 未抽离之前,OpenAI 协议兼容层仅做 injectClaudeCodePrompt(前置追加), +// 而仓内 /v1/messages 路径自己的注释明确说过"仅前置追加无法通过 Anthropic +// 第三方检测";那条注释就是本函数存在的根因。 +// +// 参数: +// - ctx / c:用于读取指纹和 gateway settings;c 可为 nil(如 count_tokens)。 +// - account:必须是 OAuth 账号,且调用方已判断不是 Claude Code 客户端。 +// - body:已经 marshal 成 Anthropic /v1/messages 格式的请求体。 +// - systemRaw:body 中原始 system 字段(用于判断是否需要 rewrite)。 +// - model:最终会发给上游的模型 ID(用于 haiku 旁路 + metadata 版本选择)。 +// +// 返回:改写后的 body。即使中间任何一步失败,也会退化成原 body(不会 panic)。 +func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + systemRaw any, + model string, +) []byte { + if account == nil || !account.IsOAuth() || len(body) == 0 { + return body + } + + systemRewritten := false + if !strings.Contains(strings.ToLower(model), "haiku") && + !systemIncludesClaudeCodePrompt(systemRaw) { + body = rewriteSystemForNonClaudeCode(body, systemRaw) + systemRewritten = true + } + + normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} + + if s.identityService != nil && c != nil && c.Request != nil { + if fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header); err == nil && fp != nil { + mimicMPT := false + if s.settingService != nil { + _, mimicMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) + } + if !mimicMPT { + if uid := s.buildOAuthMetadataUserIDFromBody(ctx, account, fp, body); uid != "" { + normalizeOpts.injectMetadata = true + normalizeOpts.metadataUserID = uid + } + } + } + } + + body, _ = normalizeClaudeOAuthRequestBody(body, model, normalizeOpts) + return body +} + +// buildOAuthMetadataUserIDFromBody 是 buildOAuthMetadataUserID 的变体, +// 适用于调用方手上没有 ParsedRequest 的场景(如 OpenAI 协议兼容层)。 +// +// 与 buildOAuthMetadataUserID 的唯一区别: +// - session hash 从 body 本体按同样规则重算,而不是读取 ParsedRequest 缓存值。 +// - 如果 body 里已经存在 metadata.user_id,则返回空(由 ensureClaudeOAuthMetadataUserID +// 自行决定是否覆盖)。 +func (s *GatewayService) buildOAuthMetadataUserIDFromBody( + ctx context.Context, + account *Account, + fp *Fingerprint, + body []byte, +) string { + _ = ctx + if account == nil { + return "" + } + if existing := gjson.GetBytes(body, "metadata.user_id").String(); existing != "" { + return "" + } + + userID := strings.TrimSpace(account.GetClaudeUserID()) + if userID == "" && fp != nil { + userID = fp.ClientID + } + if userID == "" { + userID = generateClientID() + } + + sessionID := uuid.NewString() + if hash := hashBodyForSessionSeed(body); hash != "" { + sessionID = generateSessionUUID(fmt.Sprintf("%d::%s", account.ID, hash)) + } + + var uaVersion string + if fp != nil { + uaVersion = ExtractCLIVersion(fp.UserAgent) + } + accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid")) + return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion) +} + +// hashBodyForSessionSeed 为 sessionID 提供一个稳定但仅对本次请求特征化的种子。 +// 复用 SHA-256 + 截断,与 generateSessionUUID 的输入格式对齐。 +func hashBodyForSessionSeed(body []byte) string { + if len(body) == 0 { + return "" + } + sum := sha256.Sum256(body) + return fmt.Sprintf("%x", sum[:16]) +} + // GenerateSessionUUID creates a deterministic UUID4 from a seed string. func GenerateSessionUUID(seed string) string { return generateSessionUUID(seed) @@ -6099,6 +6210,11 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { if isStream { setHeaderRaw(req.Header, "x-stainless-helper-method", "stream") } + // Real Claude CLI 每个请求都会生成一个新的 UUID 放在 x-client-request-id。 + // 上游会以此作为会话/请求指纹的一部分,缺失或重复都可能触发第三方判定。 + if getHeaderRaw(req.Header, "x-client-request-id") == "" { + setHeaderRaw(req.Header, "x-client-request-id", uuid.NewString()) + } } func truncateForLog(b []byte, maxBytes int) string {