chore(claude): bump CLI fingerprint to 2.1.88 and accept claude-code/ UA

- Centralize Claude CLI fingerprint constants (UA, x-stainless-*) in
  pkg/claude with BuildCLI/CodeUserAgent helpers
- Reuse constants in DefaultHeaders, identity_service defaults, and
  antigravity identity defaults to keep all callers in sync
- Extend ClaudeCodeValidator to accept both claude-cli/ and claude-code/
  UA prefixes (transport/helper requests use the latter)
- Update related tests to cover the new UA prefix and version
This commit is contained in:
win 2026-04-28 22:35:24 +08:00
parent 6620b56b5a
commit d6df41feaa
10 changed files with 139 additions and 37 deletions

View File

@ -1,8 +1,24 @@
// Package claude provides constants and helpers for Claude API integration. // Package claude provides constants and helpers for Claude API integration.
package claude package claude
import "strings"
// Claude Code 客户端相关常量 // 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 常量 // Beta header 常量
const ( const (
BetaOAuth = "oauth-2025-04-20" BetaOAuth = "oauth-2025-04-20"
@ -52,19 +68,45 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking
var DefaultHeaders = map[string]string{ var DefaultHeaders = map[string]string{
// Keep these in sync with recent Claude CLI traffic to reduce the chance // 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. // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
"User-Agent": "claude-cli/2.1.84 (external, cli)", "User-Agent": DefaultCLIUserAgent,
"X-Stainless-Lang": "js", "X-Stainless-Lang": DefaultStainlessLang,
"X-Stainless-Package-Version": "0.74.0", "X-Stainless-Package-Version": DefaultStainlessPackageVersion,
"X-Stainless-OS": "MacOS", "X-Stainless-OS": DefaultStainlessOS,
"X-Stainless-Arch": "arm64", "X-Stainless-Arch": DefaultStainlessArch,
"X-Stainless-Runtime": "node", "X-Stainless-Runtime": DefaultStainlessRuntime,
"X-Stainless-Runtime-Version": "v24.3.0", "X-Stainless-Runtime-Version": DefaultStainlessRuntimeVersion,
"X-Stainless-Retry-Count": "0", "X-Stainless-Retry-Count": "0",
"X-Stainless-Timeout": "600", "X-Stainless-Timeout": "600",
"X-App": "cli", "X-App": "cli",
"Anthropic-Dangerous-Direct-Browser-Access": "true", "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 用配置覆盖默认指纹值(每个实例可设不同值) // ApplyFingerprintOverrides 用配置覆盖默认指纹值(每个实例可设不同值)
// cliVersion: Claude CLI 版本(如 "2.1.81" // cliVersion: Claude CLI 版本(如 "2.1.81"
// pkgVersion: SDK 版本(如 "0.80.0" // pkgVersion: SDK 版本(如 "0.80.0"
@ -73,7 +115,7 @@ var DefaultHeaders = map[string]string{
// arch: 架构(如 "arm64" // arch: 架构(如 "arm64"
func ApplyFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) { func ApplyFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) {
if cliVersion != "" { if cliVersion != "" {
DefaultHeaders["User-Agent"] = "claude-cli/" + cliVersion + " (external, cli)" DefaultHeaders["User-Agent"] = BuildCLIUserAgent(cliVersion, "", "")
} }
if pkgVersion != "" { if pkgVersion != "" {
DefaultHeaders["X-Stainless-Package-Version"] = pkgVersion DefaultHeaders["X-Stainless-Package-Version"] = pkgVersion

View File

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
@ -15,8 +16,8 @@ import (
const defaultClaudeUsageURL = "https://api.anthropic.com/api/oauth/usage" const defaultClaudeUsageURL = "https://api.anthropic.com/api/oauth/usage"
// 默认 User-Agent用户抓包的请求一致 // 默认 User-Agent Claude Code 2.1.88 的 helper/transport 请求保持一致。
const defaultUsageUserAgent = "claude-code/2.1.7" const defaultUsageUserAgent = claude.DefaultCodeUserAgent
type claudeUsageService struct { type claudeUsageService struct {
usageURL string usageURL string

View File

@ -40,6 +40,7 @@ func TestValidate_ClaudeCLIUserAgent(t *testing.T) {
want bool want bool
}{ }{
{"标准版本号", "claude-cli/1.0.0", true}, {"标准版本号", "claude-cli/1.0.0", true},
{"官方 transport UA", "claude-code/2.1.88", true},
{"多位版本号", "claude-cli/12.34.56", true}, {"多位版本号", "claude-cli/12.34.56", true},
{"大写开头", "Claude-CLI/1.0.0", true}, {"大写开头", "Claude-CLI/1.0.0", true},
{"非 claude-cli", "curl/7.64.1", false}, {"非 claude-cli", "curl/7.64.1", false},
@ -90,6 +91,19 @@ func TestValidate_MessagesPath_FullValid(t *testing.T) {
require.True(t, result, "完整有效请求应通过") 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) { func TestValidate_MessagesPath_MissingHeaders(t *testing.T) {
v := newTestValidator() v := newTestValidator()
body := validClaudeCodeBody() body := validClaudeCodeBody()

View File

@ -15,11 +15,13 @@ import (
type ClaudeCodeValidator struct{} type ClaudeCodeValidator struct{}
var ( var (
// User-Agent 匹配: claude-cli/x.x.x (仅支持官方 CLI大小写不敏感) // User-Agent 匹配: 官方 Claude Code 目前存在两类产品前缀:
claudeCodeUAPattern = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`) // 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 一致) // System prompt 相似度阈值(默认 0.5,和 claude-relay-service 一致)
systemPromptThreshold = 0.5 systemPromptThreshold = 0.5
@ -55,7 +57,7 @@ func NewClaudeCodeValidator() *ClaudeCodeValidator {
// Validate 验证请求是否来自 Claude Code CLI // Validate 验证请求是否来自 Claude Code CLI
// 采用与 claude-relay-service 完全一致的验证策略: // 采用与 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 2: 对于非 messages 路径,只要 UA 匹配就通过
// Step 3: 检查 max_tokens=1 + haiku 探测请求绕过UA 已验证) // Step 3: 检查 max_tokens=1 + haiku 探测请求绕过UA 已验证)
// Step 4: 对于 messages 路径,进行严格验证: // Step 4: 对于 messages 路径,进行严格验证:

View File

@ -64,6 +64,7 @@ func TestExtractVersion(t *testing.T) {
want string want string
}{ }{
{"claude-cli/2.1.22 (darwin; arm64)", "2.1.22"}, {"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/1.0.0", "1.0.0"},
{"Claude-CLI/3.10.5 (linux; x86_64)", "3.10.5"}, // 大小写不敏感 {"Claude-CLI/3.10.5 (linux; x86_64)", "3.10.5"}, // 大小写不敏感
{"curl/8.0.0", ""}, // 非 Claude CLI {"curl/8.0.0", ""}, // 非 Claude CLI

View File

@ -689,6 +689,41 @@ func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *t
require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta") 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) { func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)

View File

@ -21,6 +21,12 @@ func TestIsClaudeCodeClient(t *testing.T) {
metadataUserID: "session_123e4567-e89b-12d3-a456-426614174000", metadataUserID: "session_123e4567-e89b-12d3-a456-426614174000",
want: true, 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", name: "Claude Code without version suffix",
userAgent: "claude-cli/2.0.0", userAgent: "claude-cli/2.0.0",

View File

@ -328,8 +328,8 @@ func isClaudeCodeCredentialScopeError(msg string) bool {
// sseDataRe matches SSE data lines with optional whitespace after colon. // sseDataRe matches SSE data lines with optional whitespace after colon.
// Some upstream APIs return non-standard "data:" without space (should be "data: "). // Some upstream APIs return non-standard "data:" without space (should be "data: ").
var ( var (
sseDataRe = regexp.MustCompile(`^data:\s*`) sseDataRe = regexp.MustCompile(`^data:\s*`)
claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`) claudeCodeUserAgentRe = regexp.MustCompile(`^claude-(?:cli|code)/\d+\.\d+\.\d+`)
// claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表 // claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
// 支持多种变体标准版、Agent SDK 版、Explore Agent 版、Compact 版等 // 支持多种变体标准版、Agent SDK 版、Explore Agent 版、Compact 版等
@ -3739,7 +3739,7 @@ func isClaudeCodeClient(userAgent string, metadataUserID string) bool {
if metadataUserID == "" { if metadataUserID == "" {
return false return false
} }
return claudeCliUserAgentRe.MatchString(userAgent) return claudeCodeUserAgentRe.MatchString(userAgent)
} }
func isClaudeCodeRequest(ctx context.Context, c *gin.Context, parsed *ParsedRequest) bool { 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 覆盖 // Claude Code 主 API 客户端会始终发送 X-Claude-Code-Session-Id。
if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" { // 对于 mimic / 转发场景,只要 body 中 metadata.user_id 可解析,就主动注入并同步该头。
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
if parsed := ParseMetadataUserID(uid); parsed != nil { if parsed := ParseMetadataUserID(uid); parsed != nil {
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) 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 覆盖 // Claude Code 主 API 客户端会始终发送 X-Claude-Code-Session-Id。
if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" { // 对于 mimic / 转发场景,只要 body 中 metadata.user_id 可解析,就主动注入并同步该头。
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
if parsed := ParseMetadataUserID(uid); parsed != nil { if parsed := ParseMetadataUserID(uid); parsed != nil {
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
}
} }
} }

View File

@ -13,6 +13,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
@ -26,13 +27,13 @@ var (
// 默认指纹值(当客户端未提供时使用) // 默认指纹值(当客户端未提供时使用)
var defaultFingerprint = Fingerprint{ var defaultFingerprint = Fingerprint{
UserAgent: "claude-cli/2.1.84 (external, cli)", UserAgent: claude.DefaultCLIUserAgent,
StainlessLang: "js", StainlessLang: claude.DefaultStainlessLang,
StainlessPackageVersion: "0.74.0", StainlessPackageVersion: claude.DefaultStainlessPackageVersion,
StainlessOS: "MacOS", StainlessOS: claude.DefaultStainlessOS,
StainlessArch: "arm64", StainlessArch: claude.DefaultStainlessArch,
StainlessRuntime: "node", StainlessRuntime: claude.DefaultStainlessRuntime,
StainlessRuntimeVersion: "v24.3.0", StainlessRuntimeVersion: claude.DefaultStainlessRuntimeVersion,
} }
// Fingerprint represents account fingerprint data // Fingerprint represents account fingerprint data

View File

@ -1,5 +1,7 @@
package service package service
import "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
// ============================================================== // ==============================================================
// antigravity — identity_service 扩展 // antigravity — identity_service 扩展
// //
@ -15,7 +17,7 @@ package service
// 允许不同部署实例设置不同的 CLI/SDK 版本号,避免所有实例指纹相同 // 允许不同部署实例设置不同的 CLI/SDK 版本号,避免所有实例指纹相同
func ApplyDefaultFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) { func ApplyDefaultFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) {
if cliVersion != "" { if cliVersion != "" {
defaultFingerprint.UserAgent = "claude-cli/" + cliVersion + " (external, cli)" defaultFingerprint.UserAgent = claude.BuildCLIUserAgent(cliVersion, "", "")
} }
if pkgVersion != "" { if pkgVersion != "" {
defaultFingerprint.StainlessPackageVersion = pkgVersion defaultFingerprint.StainlessPackageVersion = pkgVersion