diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index 443486ab..f3a22c1d 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -9,6 +9,11 @@ import ( ) func TestIsClaudeCodeClient(t *testing.T) { + // 合法的 legacy 格式 metadata.user_id(64位 hex + account uuid + session uuid) + legacyUserID := "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account_550e8400-e29b-41d4-a716-446655440000_session_123e4567-e89b-12d3-a456-426614174000" + // 合法的 JSON 格式 metadata.user_id(2.1.78+ 版本) + jsonUserID := `{"device_id":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","account_uuid":"550e8400-e29b-41d4-a716-446655440000","session_id":"123e4567-e89b-12d3-a456-426614174000"}` + tests := []struct { name string userAgent string @@ -16,15 +21,21 @@ func TestIsClaudeCodeClient(t *testing.T) { want bool }{ { - name: "Claude Code client", + name: "Claude Code client with legacy user_id", userAgent: "claude-cli/1.0.62 (darwin; arm64)", - metadataUserID: "session_123e4567-e89b-12d3-a456-426614174000", + metadataUserID: legacyUserID, want: true, }, { - name: "Claude Code without version suffix", - userAgent: "claude-cli/2.0.0", - metadataUserID: "session_abc", + name: "Claude Code client with JSON user_id", + userAgent: "claude-cli/2.1.92 (external, cli)", + metadataUserID: jsonUserID, + want: true, + }, + { + name: "Claude Code case insensitive UA", + userAgent: "Claude-CLI/2.0.0", + metadataUserID: legacyUserID, want: true, }, { @@ -34,21 +45,33 @@ func TestIsClaudeCodeClient(t *testing.T) { want: false, }, { - name: "Different user agent", + name: "Claude CLI UA with invalid user_id format", + userAgent: "claude-cli/2.0.0", + metadataUserID: "fake-user-id-12345", + want: false, + }, + { + name: "Different user agent with valid user_id", userAgent: "curl/7.68.0", - metadataUserID: "user123", + metadataUserID: legacyUserID, want: false, }, { name: "Empty user agent", userAgent: "", - metadataUserID: "user123", + metadataUserID: legacyUserID, want: false, }, { name: "Similar but not Claude CLI", userAgent: "claude-api/1.0.0", - metadataUserID: "user123", + metadataUserID: legacyUserID, + want: false, + }, + { + name: "Opencode spoofing UA with arbitrary user_id", + userAgent: "claude-cli/2.1.92", + metadataUserID: "session_abc", want: false, }, } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index ffd66fc7..6be19ba6 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -329,7 +329,7 @@ func isClaudeCodeCredentialScopeError(msg string) bool { // Some upstream APIs return non-standard "data:" without space (should be "data: "). var ( sseDataRe = regexp.MustCompile(`^data:\s*`) - claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`) + claudeCliUserAgentRe = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`) // claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表 // 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等 @@ -3709,13 +3709,19 @@ func sleepWithContext(ctx context.Context, d time.Duration) error { } } -// isClaudeCodeClient 判断请求是否来自 Claude Code 客户端 -// 简化判断:User-Agent 匹配 + metadata.user_id 存在 +// isClaudeCodeClient 判断请求是否来自真正的 Claude Code 客户端。 +// 判定条件: +// 1. User-Agent 匹配 claude-cli/X.Y.Z(大小写不敏感) +// 2. metadata.user_id 符合 Claude Code 格式(legacy 或 JSON 格式) +// +// 只检查 metadata.user_id 非空不够严格:第三方工具(opencode 等)可能伪造 UA +// 并附带任意 metadata.user_id 字符串,从而绕过 mimicry。必须通过 ParseMetadataUserID +// 验证格式才能确认是真正的 Claude Code 客户端。 func isClaudeCodeClient(userAgent string, metadataUserID string) bool { - if metadataUserID == "" { + if !claudeCliUserAgentRe.MatchString(userAgent) { return false } - return claudeCliUserAgentRe.MatchString(userAgent) + return ParseMetadataUserID(metadataUserID) != nil } // normalizeSystemParam 将 json.RawMessage 类型的 system 参数转为标准 Go 类型(string / []any / nil), @@ -4144,12 +4150,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A }) } - // OAuth 账号无条件走完整 mimicry,与 Parrot 对齐。 - // 不再检查 isClaudeCodeRequest —— 即使客户端自称 Claude Code(opencode 等 - // 第三方工具会伪装 UA / X-App / system prompt),它的伪装往往不完整(缺 billing - // block / 工具名混淆 / cache 策略等),被 Anthropic 判为 third-party。 - // 无条件覆盖不会对真正的 Claude Code 造成问题,因为我们的伪装更完整。 - shouldMimicClaudeCode := account.IsOAuth() + // Claude Code 客户端判定:UA 匹配 claude-cli/* 且携带 metadata.user_id。 + // 真正的 Claude Code 客户端自带完整的 system prompt、cache_control 断点和 header, + // 不需要代理做任何 body 级别的 mimicry;强行替换反而会破坏客户端的缓存策略 + // (长 system prompt 被替换为 ~45 tokens 的短 prompt,低于 Anthropic 1024 token + // 最低缓存门槛,导致系统级缓存失效)。 + // + // 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。 + isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) + shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { // 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code @@ -8387,7 +8396,8 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, // Pre-filter: strip empty text blocks to prevent upstream 400. body = StripEmptyTextBlocks(body) - shouldMimicClaudeCode := account.IsOAuth() + isClaudeCodeCT := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) + shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCodeCT if shouldMimicClaudeCode { normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}