From 4045378cb4334967682a1cbe739469167408bfd4 Mon Sep 17 00:00:00 2001 From: pzy <2360718056@qq.com> Date: Thu, 2 Apr 2026 15:55:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A2=9E=E5=BC=BA=20Claude=20=E5=8F=8D?= =?UTF-8?q?=E4=BB=A3=E6=A3=80=E6=B5=8B=E5=AF=B9=E6=8A=97=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 Claude Code v2.1.88 源码分析,修复多个可被 Anthropic 检测的差距: - 实现消息指纹算法(SHA256 盐值 + 字符索引),替代随机 buildHash - billing header cc_version 从设备 profile 动态取版本号,不再硬编码 - billing header cc_entrypoint 从客户端 UA 解析,支持 cli/vscode/local-agent - billing header 新增 cc_workload 支持(通过 X-CPA-Claude-Workload 头传入) - 新增 X-Claude-Code-Session-Id 头(每 apiKey 缓存 UUID,TTL=1h) - 新增 x-client-request-id 头(仅 api.anthropic.com,每请求 UUID) - 补全 4 个缺失的 beta flags(structured-outputs/fast-mode/redact-thinking/token-efficient-tools) - OAuth scope 对齐 Claude Code 2.1.88(移除 org:create_api_key,添加 sessions/mcp/file_upload) - Anthropic-Dangerous-Direct-Browser-Access 仅在 API key 模式发送 - 响应头网关指纹清洗(剥离 litellm/helicone/portkey/cloudflare/kong/braintrust 前缀头) --- internal/auth/claude/anthropic_auth.go | 2 +- internal/runtime/executor/claude_executor.go | 116 +++++++++++++++--- .../executor/helps/claude_device_profile.go | 10 ++ .../executor/helps/session_id_cache.go | 92 ++++++++++++++ sdk/api/handlers/header_filter.go | 25 ++++ 5 files changed, 227 insertions(+), 18 deletions(-) create mode 100644 internal/runtime/executor/helps/session_id_cache.go diff --git a/internal/auth/claude/anthropic_auth.go b/internal/auth/claude/anthropic_auth.go index 2853e418..12bb53ac 100644 --- a/internal/auth/claude/anthropic_auth.go +++ b/internal/auth/claude/anthropic_auth.go @@ -88,7 +88,7 @@ func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string "client_id": {ClientID}, "response_type": {"code"}, "redirect_uri": {RedirectURI}, - "scope": {"org:create_api_key user:profile user:inference"}, + "scope": {"user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"}, "code_challenge": {pkceCodes.CodeChallenge}, "code_challenge_method": {"S256"}, "state": {state}, diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index f5e7e409..fcdf14e9 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -6,7 +6,6 @@ import ( "compress/flate" "compress/gzip" "context" - "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" @@ -18,6 +17,7 @@ import ( "time" "github.com/andybalholm/brotli" + "github.com/google/uuid" "github.com/klauspost/compress/zstd" claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -813,7 +813,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, deviceProfile = helps.ResolveClaudeDeviceProfile(auth, apiKey, ginHeaders, cfg) } - baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05" + baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28" if val := strings.TrimSpace(ginHeaders.Get("Anthropic-Beta")); val != "" { baseBetas = val if !strings.Contains(val, "oauth") { @@ -851,13 +851,22 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, r.Header.Set("Anthropic-Beta", baseBetas) misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Version", "2023-06-01") - misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true") + // Only set browser access header for API key mode; real Claude Code CLI does not send it. + if useAPIKey { + misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true") + } misc.EnsureHeader(r.Header, ginHeaders, "X-App", "cli") // Values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28). misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Retry-Count", "0") misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime", "node") misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Lang", "js") misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Timeout", hdrDefault(hd.Timeout, "600")) + // Session ID: stable per auth/apiKey, matches Claude Code's X-Claude-Code-Session-Id header. + misc.EnsureHeader(r.Header, ginHeaders, "X-Claude-Code-Session-Id", helps.CachedSessionID(apiKey)) + // Per-request UUID, matches Claude Code's x-client-request-id for first-party API. + if isAnthropicBase { + misc.EnsureHeader(r.Header, ginHeaders, "x-client-request-id", uuid.New().String()) + } r.Header.Set("Connection", "keep-alive") if stream { r.Header.Set("Accept", "text/event-stream") @@ -907,7 +916,7 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { } func checkSystemInstructions(payload []byte) []byte { - return checkSystemInstructionsWithSigningMode(payload, false, false) + return checkSystemInstructionsWithSigningMode(payload, false, false, "2.1.63", "", "") } func isClaudeOAuthToken(apiKey string) bool { @@ -1102,6 +1111,38 @@ func getClientUserAgent(ctx context.Context) string { return "" } +// parseEntrypointFromUA extracts the entrypoint from a Claude Code User-Agent. +// Format: "claude-cli/x.y.z (external, cli)" → "cli" +// Format: "claude-cli/x.y.z (external, vscode)" → "vscode" +// Returns "cli" if parsing fails or UA is not Claude Code. +func parseEntrypointFromUA(userAgent string) string { + // Find content inside parentheses + start := strings.Index(userAgent, "(") + end := strings.LastIndex(userAgent, ")") + if start < 0 || end <= start { + return "cli" + } + inner := userAgent[start+1 : end] + // Split by comma, take the second part (entrypoint is at index 1, after USER_TYPE) + // Format: "(USER_TYPE, ENTRYPOINT[, extra...])" + parts := strings.Split(inner, ",") + if len(parts) >= 2 { + ep := strings.TrimSpace(parts[1]) + if ep != "" { + return ep + } + } + return "cli" +} + +// getWorkloadFromContext extracts workload identifier from the gin request headers. +func getWorkloadFromContext(ctx context.Context) string { + if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { + return strings.TrimSpace(ginCtx.GetHeader("X-CPA-Claude-Workload")) + } + return "" +} + // getCloakConfigFromAuth extracts cloak configuration from auth attributes. // Returns (cloakMode, strictMode, sensitiveWords, cacheUserID). func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string, bool) { @@ -1152,28 +1193,51 @@ func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte { return payload } +// fingerprintSalt is the salt used by Claude Code to compute the 3-char build fingerprint. +const fingerprintSalt = "59cf53e54c78" + +// computeFingerprint computes the 3-char build fingerprint that Claude Code embeds in cc_version. +// Algorithm: SHA256(salt + messageText[4] + messageText[7] + messageText[20] + version)[:3] +func computeFingerprint(messageText, version string) string { + indices := [3]int{4, 7, 20} + var chars [3]byte + for i, idx := range indices { + if idx < len(messageText) { + chars[i] = messageText[idx] + } else { + chars[i] = '0' + } + } + input := fingerprintSalt + string(chars[:]) + version + h := sha256.Sum256([]byte(input)) + return hex.EncodeToString(h[:])[:3] +} + // generateBillingHeader creates the x-anthropic-billing-header text block that // real Claude Code prepends to every system prompt array. -// Format: x-anthropic-billing-header: cc_version=.; cc_entrypoint=cli; cch=; -func generateBillingHeader(payload []byte, experimentalCCHSigning bool) string { - // Build hash: 3-char hex, matches the pattern seen in real requests (e.g. "a43") - buildBytes := make([]byte, 2) - _, _ = rand.Read(buildBytes) - buildHash := hex.EncodeToString(buildBytes)[:3] +// Format: x-anthropic-billing-header: cc_version=.; cc_entrypoint=; cch=; [cc_workload=;] +func generateBillingHeader(payload []byte, experimentalCCHSigning bool, version, messageText, entrypoint, workload string) string { + if entrypoint == "" { + entrypoint = "cli" + } + buildHash := computeFingerprint(messageText, version) + workloadPart := "" + if workload != "" { + workloadPart = fmt.Sprintf(" cc_workload=%s;", workload) + } if experimentalCCHSigning { - return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=00000;", buildHash) + return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=%s; cch=00000;%s", version, buildHash, entrypoint, workloadPart) } // Generate a deterministic cch hash from the payload content (system + messages + tools). - // Real Claude Code uses a 5-char hex hash that varies per request. h := sha256.Sum256(payload) cch := hex.EncodeToString(h[:])[:5] - return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch) + return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=%s; cch=%s;%s", version, buildHash, entrypoint, cch, workloadPart) } func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { - return checkSystemInstructionsWithSigningMode(payload, strictMode, false) + return checkSystemInstructionsWithSigningMode(payload, strictMode, false, "2.1.63", "", "") } // checkSystemInstructionsWithSigningMode injects Claude Code-style system blocks: @@ -1181,10 +1245,25 @@ func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { // system[0]: billing header (no cache_control) // system[1]: agent identifier (no cache_control) // system[2..]: user system messages (cache_control added when missing) -func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, experimentalCCHSigning bool) []byte { +func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, experimentalCCHSigning bool, version, entrypoint, workload string) []byte { system := gjson.GetBytes(payload, "system") - billingText := generateBillingHeader(payload, experimentalCCHSigning) + // Extract original message text for fingerprint computation (before billing injection). + // Use the first system text block's content as the fingerprint source. + messageText := "" + if system.IsArray() { + system.ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "text" { + messageText = part.Get("text").String() + return false + } + return true + }) + } else if system.Type == gjson.String { + messageText = system.String() + } + + billingText := generateBillingHeader(payload, experimentalCCHSigning, version, messageText, entrypoint, workload) billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText) // No cache_control on the agent block. It is a cloaking artifact with zero cache // value (the last system block is what actually triggers caching of all system content). @@ -1273,7 +1352,10 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A // Skip system instructions for claude-3-5-haiku models if !strings.HasPrefix(model, "claude-3-5-haiku") { - payload = checkSystemInstructionsWithSigningMode(payload, strictMode, useExperimentalCCHSigning) + billingVersion := helps.DefaultClaudeVersion(cfg) + entrypoint := parseEntrypointFromUA(clientUserAgent) + workload := getWorkloadFromContext(ctx) + payload = checkSystemInstructionsWithSigningMode(payload, strictMode, useExperimentalCCHSigning, billingVersion, entrypoint, workload) } // Inject fake user ID diff --git a/internal/runtime/executor/helps/claude_device_profile.go b/internal/runtime/executor/helps/claude_device_profile.go index f7b9c1f2..154901b5 100644 --- a/internal/runtime/executor/helps/claude_device_profile.go +++ b/internal/runtime/executor/helps/claude_device_profile.go @@ -358,6 +358,16 @@ func ApplyClaudeDeviceProfileHeaders(r *http.Request, profile ClaudeDeviceProfil r.Header.Set("X-Stainless-Arch", profile.Arch) } +// DefaultClaudeVersion returns the version string (e.g. "2.1.63") from the +// current baseline device profile. It extracts the version from the User-Agent. +func DefaultClaudeVersion(cfg *config.Config) string { + profile := defaultClaudeDeviceProfile(cfg) + if version, ok := parseClaudeCLIVersion(profile.UserAgent); ok { + return strconv.Itoa(version.major) + "." + strconv.Itoa(version.minor) + "." + strconv.Itoa(version.patch) + } + return "2.1.63" +} + func ApplyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg *config.Config) { if r == nil { return diff --git a/internal/runtime/executor/helps/session_id_cache.go b/internal/runtime/executor/helps/session_id_cache.go new file mode 100644 index 00000000..6c89f001 --- /dev/null +++ b/internal/runtime/executor/helps/session_id_cache.go @@ -0,0 +1,92 @@ +package helps + +import ( + "crypto/sha256" + "encoding/hex" + "sync" + "time" + + "github.com/google/uuid" +) + +type sessionIDCacheEntry struct { + value string + expire time.Time +} + +var ( + sessionIDCache = make(map[string]sessionIDCacheEntry) + sessionIDCacheMu sync.RWMutex + sessionIDCacheCleanupOnce sync.Once +) + +const ( + sessionIDTTL = time.Hour + sessionIDCacheCleanupPeriod = 15 * time.Minute +) + +func startSessionIDCacheCleanup() { + go func() { + ticker := time.NewTicker(sessionIDCacheCleanupPeriod) + defer ticker.Stop() + for range ticker.C { + purgeExpiredSessionIDs() + } + }() +} + +func purgeExpiredSessionIDs() { + now := time.Now() + sessionIDCacheMu.Lock() + for key, entry := range sessionIDCache { + if !entry.expire.After(now) { + delete(sessionIDCache, key) + } + } + sessionIDCacheMu.Unlock() +} + +func sessionIDCacheKey(apiKey string) string { + sum := sha256.Sum256([]byte(apiKey)) + return hex.EncodeToString(sum[:]) +} + +// CachedSessionID returns a stable session UUID per apiKey, refreshing the TTL on each access. +func CachedSessionID(apiKey string) string { + if apiKey == "" { + return uuid.New().String() + } + + sessionIDCacheCleanupOnce.Do(startSessionIDCacheCleanup) + + key := sessionIDCacheKey(apiKey) + now := time.Now() + + sessionIDCacheMu.RLock() + entry, ok := sessionIDCache[key] + valid := ok && entry.value != "" && entry.expire.After(now) + sessionIDCacheMu.RUnlock() + if valid { + sessionIDCacheMu.Lock() + entry = sessionIDCache[key] + if entry.value != "" && entry.expire.After(now) { + entry.expire = now.Add(sessionIDTTL) + sessionIDCache[key] = entry + sessionIDCacheMu.Unlock() + return entry.value + } + sessionIDCacheMu.Unlock() + } + + newID := uuid.New().String() + + sessionIDCacheMu.Lock() + entry, ok = sessionIDCache[key] + if !ok || entry.value == "" || !entry.expire.After(now) { + entry.value = newID + } + entry.expire = now.Add(sessionIDTTL) + sessionIDCache[key] = entry + sessionIDCacheMu.Unlock() + return entry.value +} diff --git a/sdk/api/handlers/header_filter.go b/sdk/api/handlers/header_filter.go index 135223a7..73626d38 100644 --- a/sdk/api/handlers/header_filter.go +++ b/sdk/api/handlers/header_filter.go @@ -5,6 +5,18 @@ import ( "strings" ) +// gatewayHeaderPrefixes lists header name prefixes injected by known AI gateway +// proxies. Claude Code's client-side telemetry detects these and reports the +// gateway type, so we strip them from upstream responses to avoid detection. +var gatewayHeaderPrefixes = []string{ + "x-litellm-", + "helicone-", + "x-portkey-", + "cf-aig-", + "x-kong-", + "x-bt-", +} + // hopByHopHeaders lists RFC 7230 Section 6.1 hop-by-hop headers that MUST NOT // be forwarded by proxies, plus security-sensitive headers that should not leak. var hopByHopHeaders = map[string]struct{}{ @@ -40,6 +52,19 @@ func FilterUpstreamHeaders(src http.Header) http.Header { if _, scoped := connectionScoped[canonicalKey]; scoped { continue } + // Strip headers injected by known AI gateway proxies to avoid + // Claude Code client-side gateway detection. + lowerKey := strings.ToLower(key) + gatewayMatch := false + for _, prefix := range gatewayHeaderPrefixes { + if strings.HasPrefix(lowerKey, prefix) { + gatewayMatch = true + break + } + } + if gatewayMatch { + continue + } dst[key] = values } if len(dst) == 0 {