diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index c9c015bb..95f44630 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -1,8 +1,24 @@ // Package claude provides constants and helpers for Claude API integration. package claude +import "strings" + // Claude Code 客户端相关常量 +const ( + DefaultCLIProductVersion = "2.1.88" + DefaultUserType = "external" + DefaultEntrypoint = "cli" + DefaultStainlessLang = "js" + DefaultStainlessPackageVersion = "0.74.0" + DefaultStainlessOS = "MacOS" + DefaultStainlessArch = "arm64" + DefaultStainlessRuntime = "node" + DefaultStainlessRuntimeVersion = "v24.3.0" + DefaultCLIUserAgent = "claude-cli/" + DefaultCLIProductVersion + " (" + DefaultUserType + ", " + DefaultEntrypoint + ")" + DefaultCodeUserAgent = "claude-code/" + DefaultCLIProductVersion +) + // Beta header 常量 const ( BetaOAuth = "oauth-2025-04-20" @@ -52,19 +68,45 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking var DefaultHeaders = map[string]string{ // Keep these in sync with recent Claude CLI traffic to reduce the chance // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. - "User-Agent": "claude-cli/2.1.84 (external, cli)", - "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.74.0", - "X-Stainless-OS": "MacOS", - "X-Stainless-Arch": "arm64", - "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", + "User-Agent": DefaultCLIUserAgent, + "X-Stainless-Lang": DefaultStainlessLang, + "X-Stainless-Package-Version": DefaultStainlessPackageVersion, + "X-Stainless-OS": DefaultStainlessOS, + "X-Stainless-Arch": DefaultStainlessArch, + "X-Stainless-Runtime": DefaultStainlessRuntime, + "X-Stainless-Runtime-Version": DefaultStainlessRuntimeVersion, "X-Stainless-Retry-Count": "0", "X-Stainless-Timeout": "600", "X-App": "cli", "Anthropic-Dangerous-Direct-Browser-Access": "true", } +// BuildCLIUserAgent returns the current Claude Code API client user-agent. +func BuildCLIUserAgent(version, userType, entrypoint string) string { + version = strings.TrimSpace(version) + if version == "" { + version = DefaultCLIProductVersion + } + userType = strings.TrimSpace(userType) + if userType == "" { + userType = DefaultUserType + } + entrypoint = strings.TrimSpace(entrypoint) + if entrypoint == "" { + entrypoint = DefaultEntrypoint + } + return "claude-cli/" + version + " (" + userType + ", " + entrypoint + ")" +} + +// BuildCodeUserAgent returns the current Claude Code transport/helper user-agent. +func BuildCodeUserAgent(version string) string { + version = strings.TrimSpace(version) + if version == "" { + version = DefaultCLIProductVersion + } + return "claude-code/" + version +} + // ApplyFingerprintOverrides 用配置覆盖默认指纹值(每个实例可设不同值) // cliVersion: Claude CLI 版本(如 "2.1.81") // pkgVersion: SDK 版本(如 "0.80.0") @@ -73,7 +115,7 @@ var DefaultHeaders = map[string]string{ // arch: 架构(如 "arm64") func ApplyFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) { if cliVersion != "" { - DefaultHeaders["User-Agent"] = "claude-cli/" + cliVersion + " (external, cli)" + DefaultHeaders["User-Agent"] = BuildCLIUserAgent(cliVersion, "", "") } if pkgVersion != "" { DefaultHeaders["X-Stainless-Package-Version"] = pkgVersion diff --git a/backend/internal/repository/claude_usage_service.go b/backend/internal/repository/claude_usage_service.go index b44adde2..15329507 100644 --- a/backend/internal/repository/claude_usage_service.go +++ b/backend/internal/repository/claude_usage_service.go @@ -8,6 +8,7 @@ import ( "net/http" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" "github.com/Wei-Shaw/sub2api/internal/service" @@ -15,8 +16,8 @@ import ( const defaultClaudeUsageURL = "https://api.anthropic.com/api/oauth/usage" -// 默认 User-Agent,与用户抓包的请求一致 -const defaultUsageUserAgent = "claude-code/2.1.7" +// 默认 User-Agent,与 Claude Code 2.1.88 的 helper/transport 请求保持一致。 +const defaultUsageUserAgent = claude.DefaultCodeUserAgent type claudeUsageService struct { usageURL string diff --git a/backend/internal/service/claude_code_detection_test.go b/backend/internal/service/claude_code_detection_test.go index ff7ad7f4..463aa60d 100644 --- a/backend/internal/service/claude_code_detection_test.go +++ b/backend/internal/service/claude_code_detection_test.go @@ -40,6 +40,7 @@ func TestValidate_ClaudeCLIUserAgent(t *testing.T) { want bool }{ {"标准版本号", "claude-cli/1.0.0", true}, + {"官方 transport UA", "claude-code/2.1.88", true}, {"多位版本号", "claude-cli/12.34.56", true}, {"大写开头", "Claude-CLI/1.0.0", true}, {"非 claude-cli", "curl/7.64.1", false}, @@ -90,6 +91,19 @@ func TestValidate_MessagesPath_FullValid(t *testing.T) { require.True(t, result, "完整有效请求应通过") } +func TestValidate_MessagesPath_FullValid_ClaudeCodeUA(t *testing.T) { + v := newTestValidator() + + req := httptest.NewRequest("POST", "/v1/messages", nil) + req.Header.Set("User-Agent", "claude-code/2.1.88") + req.Header.Set("X-App", "claude-code") + req.Header.Set("anthropic-beta", "max-tokens-3-5-sonnet-2024-07-15") + req.Header.Set("anthropic-version", "2023-06-01") + + result := v.Validate(req, validClaudeCodeBody()) + require.True(t, result, "官方 transport/helper UA 也应通过") +} + func TestValidate_MessagesPath_MissingHeaders(t *testing.T) { v := newTestValidator() body := validClaudeCodeBody() diff --git a/backend/internal/service/claude_code_validator.go b/backend/internal/service/claude_code_validator.go index 4e8ced67..a40ccb95 100644 --- a/backend/internal/service/claude_code_validator.go +++ b/backend/internal/service/claude_code_validator.go @@ -15,11 +15,13 @@ import ( type ClaudeCodeValidator struct{} var ( - // User-Agent 匹配: claude-cli/x.x.x (仅支持官方 CLI,大小写不敏感) - claudeCodeUAPattern = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`) + // User-Agent 匹配: 官方 Claude Code 目前存在两类产品前缀: + // 1. 主 Anthropic API 客户端: claude-cli/x.y.z (...) + // 2. transport / helper 请求: claude-code/x.y.z + claudeCodeUAPattern = regexp.MustCompile(`(?i)^claude-(?:cli|code)/\d+\.\d+\.\d+`) // 带捕获组的版本提取正则 - claudeCodeUAVersionPattern = regexp.MustCompile(`(?i)^claude-cli/(\d+\.\d+\.\d+)`) + claudeCodeUAVersionPattern = regexp.MustCompile(`(?i)^claude-(?:cli|code)/(\d+\.\d+\.\d+)`) // System prompt 相似度阈值(默认 0.5,和 claude-relay-service 一致) systemPromptThreshold = 0.5 @@ -55,7 +57,7 @@ func NewClaudeCodeValidator() *ClaudeCodeValidator { // Validate 验证请求是否来自 Claude Code CLI // 采用与 claude-relay-service 完全一致的验证策略: // -// Step 1: User-Agent 检查 (必需) - 必须是 claude-cli/x.x.x +// Step 1: User-Agent 检查 (必需) - 必须是官方 claude-cli/ 或 claude-code/ 前缀 // Step 2: 对于非 messages 路径,只要 UA 匹配就通过 // Step 3: 检查 max_tokens=1 + haiku 探测请求绕过(UA 已验证) // Step 4: 对于 messages 路径,进行严格验证: diff --git a/backend/internal/service/claude_code_validator_test.go b/backend/internal/service/claude_code_validator_test.go index f87c56e8..fd7e26da 100644 --- a/backend/internal/service/claude_code_validator_test.go +++ b/backend/internal/service/claude_code_validator_test.go @@ -64,6 +64,7 @@ func TestExtractVersion(t *testing.T) { want string }{ {"claude-cli/2.1.22 (darwin; arm64)", "2.1.22"}, + {"claude-code/2.1.88", "2.1.88"}, {"claude-cli/1.0.0", "1.0.0"}, {"Claude-CLI/3.10.5 (linux; x86_64)", "3.10.5"}, // 大小写不敏感 {"curl/8.0.0", ""}, // 非 Claude CLI diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 6e19db32..9b962455 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -689,6 +689,41 @@ func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *t require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta") } +func TestGatewayService_AnthropicOAuth_InjectsClaudeCodeSessionHeaderFromMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + sessionID := "12345678-1234-1234-1234-123456789abc" + body, err := json.Marshal(map[string]any{ + "model": "claude-3-7-sonnet-20250219", + "metadata": map[string]any{ + "user_id": FormatMetadataUserID( + "d61f76d0730d2b920763648949bad5c79742155c27037fc77ac3f9805cb90169", + "", + sessionID, + claude.DefaultCLIProductVersion, + ), + }, + }) + require.NoError(t, err) + + svc := &GatewayService{ + cfg: &config.Config{ + Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, + }, + } + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + } + + req, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "oauth-token", "oauth", "claude-3-7-sonnet-20250219", false, false) + require.NoError(t, err) + require.Equal(t, sessionID, getHeaderRaw(req.Header, "X-Claude-Code-Session-Id")) +} + func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index 356536b0..e52a1a81 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -21,6 +21,12 @@ func TestIsClaudeCodeClient(t *testing.T) { metadataUserID: "session_123e4567-e89b-12d3-a456-426614174000", want: true, }, + { + name: "Claude Code helper client", + userAgent: "claude-code/2.1.88", + metadataUserID: "session_123e4567-e89b-12d3-a456-426614174000", + want: true, + }, { name: "Claude Code without version suffix", userAgent: "claude-cli/2.0.0", diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index b54f463b..107e5086 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -328,8 +328,8 @@ func isClaudeCodeCredentialScopeError(msg string) bool { // sseDataRe matches SSE data lines with optional whitespace after colon. // 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+`) + sseDataRe = regexp.MustCompile(`^data:\s*`) + claudeCodeUserAgentRe = regexp.MustCompile(`^claude-(?:cli|code)/\d+\.\d+\.\d+`) // claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表 // 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等 @@ -3739,7 +3739,7 @@ func isClaudeCodeClient(userAgent string, metadataUserID string) bool { if metadataUserID == "" { return false } - return claudeCliUserAgentRe.MatchString(userAgent) + return claudeCodeUserAgentRe.MatchString(userAgent) } func isClaudeCodeRequest(ctx context.Context, c *gin.Context, parsed *ParsedRequest) bool { @@ -5758,12 +5758,11 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } } - // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖 - if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" { - if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { - if parsed := ParseMetadataUserID(uid); parsed != nil { - setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) - } + // Claude Code 主 API 客户端会始终发送 X-Claude-Code-Session-Id。 + // 对于 mimic / 转发场景,只要 body 中 metadata.user_id 可解析,就主动注入并同步该头。 + if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { + if parsed := ParseMetadataUserID(uid); parsed != nil { + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) } } @@ -8486,12 +8485,11 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } } - // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖 - if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" { - if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { - if parsed := ParseMetadataUserID(uid); parsed != nil { - setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) - } + // Claude Code 主 API 客户端会始终发送 X-Claude-Code-Session-Id。 + // 对于 mimic / 转发场景,只要 body 中 metadata.user_id 可解析,就主动注入并同步该头。 + if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { + if parsed := ParseMetadataUserID(uid); parsed != nil { + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) } } diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index c6a260a8..43351f73 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -26,13 +27,13 @@ var ( // 默认指纹值(当客户端未提供时使用) var defaultFingerprint = Fingerprint{ - UserAgent: "claude-cli/2.1.84 (external, cli)", - StainlessLang: "js", - StainlessPackageVersion: "0.74.0", - StainlessOS: "MacOS", - StainlessArch: "arm64", - StainlessRuntime: "node", - StainlessRuntimeVersion: "v24.3.0", + UserAgent: claude.DefaultCLIUserAgent, + StainlessLang: claude.DefaultStainlessLang, + StainlessPackageVersion: claude.DefaultStainlessPackageVersion, + StainlessOS: claude.DefaultStainlessOS, + StainlessArch: claude.DefaultStainlessArch, + StainlessRuntime: claude.DefaultStainlessRuntime, + StainlessRuntimeVersion: claude.DefaultStainlessRuntimeVersion, } // Fingerprint represents account fingerprint data diff --git a/backend/internal/service/identity_service_antigravity.go b/backend/internal/service/identity_service_antigravity.go index e725a7fb..8416aff1 100644 --- a/backend/internal/service/identity_service_antigravity.go +++ b/backend/internal/service/identity_service_antigravity.go @@ -1,5 +1,7 @@ package service +import "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + // ============================================================== // antigravity — identity_service 扩展 // @@ -15,7 +17,7 @@ package service // 允许不同部署实例设置不同的 CLI/SDK 版本号,避免所有实例指纹相同 func ApplyDefaultFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) { if cliVersion != "" { - defaultFingerprint.UserAgent = "claude-cli/" + cliVersion + " (external, cli)" + defaultFingerprint.UserAgent = claude.BuildCLIUserAgent(cliVersion, "", "") } if pkgVersion != "" { defaultFingerprint.StainlessPackageVersion = pkgVersion