diff --git a/config.example.yaml b/config.example.yaml --- a/config.example.yaml +++ b/config.example.yaml @@ -461,8 +461,8 @@ # - "*-thinking" # wildcard matching suffix (e.g. claude-opus-4-5-thinking) # - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022) # rebuild-mid-system-message: false # optional: default is false; when true, move messages with role "system" into the top-level Claude system field -# cloak: # optional: request cloaking for non-Claude-Code clients -# mode: "auto" # "auto" (default): cloak only when client is not Claude Code +# cloak: # optional: explicitly enable request cloaking for non-Claude-Code clients +# mode: "auto" # "auto" (default inside this block): cloak only when client is not Claude Code # # "always": cloak every unconfirmed client; confirmed native Claude Code still passes through # # "never": never apply cloaking # # This "cloak" block applies to this claude-api-key entry only. For Claude OAuth @@ -477,8 +477,56 @@ # - "proxy" # cache-user-id: true # optional: default is false; set true to reuse cached user_id per API key instead of generating a random one each request # # Every custom tool on a cloaked OAuth request automatically uses a caller-stable opaque mcp____ alias. +# +# # fingerprint-profile (optional, top-level on this claude-api-key entry; not a cloak sub-field): +# # OAuth and API-key fingerprints are different contracts. +# # - Real Claude OAuth stays on the strict Claude Code CLI wire fingerprint. +# # - API keys (official Anthropic, custom gateways, Kimi) stay loose and +# # caller-owned unless this field is set. +# # +# # Default (omit / empty): keep the caller request fingerprint and headers. +# # Official api.anthropic.com API keys do not add extra CLI betas/identity unless +# # this field is set. Custom gateways and delegated providers are the same. +# # +# # Controls request fingerprint only on /v1/messages (and related Claude executor paths). +# # Auth scheme stays API key (x-api-key on api.anthropic.com; Bearer on custom base-url). +# # Does NOT enable OAuth refresh, profile fetch, or OAuth-cancellation semantics. +# # +# # Values: +# # omit / empty = caller-owned API-key fingerprint (respects caller) +# # "claude-code-cli" = same Messages fingerprint as Claude Code OAuth CLI, +# # including official Anthropic API keys: OAuth Anthropic-Beta +# # set, CCH signing, stable CLI metadata.user_id / session_id / +# # device identity. API keys seed identity from the key; +# # delegated OAuth providers use stable auth ID instead of +# # rotating access tokens. "oauth-cli" is a legacy alias. +# # +# # count_tokens keeps the native model/messages/tools shape for every origin, including +# # Kimi opt-in. It does not send billing/CCH, currentDate, metadata, or diagnostics. +# # +# # CCH warning: the billing block includes a per-request cch hash. api.anthropic.com +# # strips that block (0 tokens, no cache impact). Gateways that keep it as prompt text +# # will miss cache on every Messages request. Kimi strips the block by default, but an +# # explicit fingerprint-profile opt-in keeps and signs it on Messages. +# # +# # Example (official Anthropic or a custom Messages gateway): +# # - api-key: "your-key" +# # # base-url: "https://gateway.example" # omit for api.anthropic.com +# # fingerprint-profile: "claude-code-cli" +# # cloak: +# # mode: "always" # recommended when upstream rejects non-CLI clients +# # +# # Delegated Anthropic Messages OAuth files (Kimi, etc.) use the same field in the +# # auth JSON. Refresh keeps it. Example: +# # { +# # "type": "kimi", +# # "access_token": "...", +# # "refresh_token": "...", +# # "fingerprint-profile": "claude-code-cli" +# # } +# # fingerprint-profile: "claude-code-cli" # optional: default is empty (caller-owned); uncomment to opt into Claude Code CLI Messages fingerprinting # experimental-cch-signing: false # deprecated compatibility field; CCH is generated automatically -# # all Claude OAuth requests sign, including custom gateways; direct Anthropic/Vertex paths also sign +# # for real Claude OAuth and explicit claude-code-cli profiles; Vertex keeps provider-native signing # Anthropic-Beta is assembled per request rather than sent as a fixed list, matching # Claude Code 2.1.220: context-1m sits right after claude-code, mid-conversation-system diff --git a/internal/config/config_normalization.go b/internal/config/config_normalization.go --- a/internal/config/config_normalization.go +++ b/internal/config/config_normalization.go @@ -174,6 +174,7 @@ entry.Prefix = normalizeModelPrefix(entry.Prefix) entry.Headers = NormalizeHeaders(entry.Headers) entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels) + entry.FingerprintProfile = strings.TrimSpace(entry.FingerprintProfile) } } diff --git a/internal/config/config_types.go b/internal/config/config_types.go --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -309,6 +309,7 @@ // Cloaking disguises API requests to appear as originating from the official Claude Code CLI. type CloakConfig struct { // Mode controls cloaking behavior: "auto" (default), "always", or "never". + // Supplying this CloakConfig explicitly enables cloaking for an unprofiled API key. // - "auto": cloak unless strong request signals identify a verified native entrypoint // - "always": cloak every unconfirmed client; confirmed native Claude Code remains passthrough // - "never": never apply cloaking @@ -377,6 +378,19 @@ // Cloak configures request cloaking for non-Claude-Code clients. Cloak *CloakConfig `yaml:"cloak,omitempty" json:"cloak,omitempty"` + + // FingerprintProfile selects the Claude Code request fingerprint for this + // credential on Anthropic Messages. Empty/default keeps the caller request + // fingerprint and headers, including first-party api.anthropic.com API keys. + // "claude-code-cli" opts official Anthropic API keys, custom gateways, and + // delegated providers such as Kimi into the Claude Code OAuth CLI Messages + // shape (OAuth betas, CCH signing, stable CLI identity) without treating the + // credential as a real OAuth token for refresh/profile/runtime semantics. + // CCH is a per-request hash; api.anthropic.com strips it, but gateways that + // treat the billing block as prompt text will miss cache. Kimi strips the + // attribution by default and keeps/signs it on Messages only after an explicit + // profile opt-in. count_tokens keeps the native model/messages/tools shape. + FingerprintProfile string `yaml:"fingerprint-profile,omitempty" json:"fingerprint-profile,omitempty"` // ExperimentalCCHSigning is retained for configuration compatibility. // CCH signing is automatic for Claude OAuth and supported direct upstreams. diff --git a/internal/util/claude_attribution.go b/internal/util/claude_attribution.go --- a/internal/util/claude_attribution.go +++ b/internal/util/claude_attribution.go @@ -3,6 +3,9 @@ import ( "strings" "unicode" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) const claudeCodeAttributionSystemPrefix = "x-anthropic-billing-header:" @@ -12,4 +15,55 @@ func IsClaudeCodeAttributionSystemText(text string) bool { text = strings.TrimLeftFunc(text, unicode.IsSpace) return strings.HasPrefix(text, claudeCodeAttributionSystemPrefix) +} + +// StripClaudeCodeAttributionSystem removes Claude Code billing/CCH attribution +// blocks from a Messages body. Other system content is kept. Providers such as +// Kimi and Antigravity may treat this block as prompt text, so callers use this +// helper when the active policy has not explicitly opted into a full CLI profile. +func StripClaudeCodeAttributionSystem(payload []byte) []byte { + system := gjson.GetBytes(payload, "system") + if !system.Exists() { + return payload + } + if system.Type == gjson.String { + if !IsClaudeCodeAttributionSystemText(system.String()) { + return payload + } + updated, errDelete := sjson.DeleteBytes(payload, "system") + if errDelete != nil { + return payload + } + return updated + } + if !system.IsArray() { + return payload + } + kept := make([]string, 0, len(system.Array())) + removed := false + system.ForEach(func(_, block gjson.Result) bool { + if block.Get("type").String() == "text" && IsClaudeCodeAttributionSystemText(block.Get("text").String()) { + removed = true + return true + } + if block.Raw != "" { + kept = append(kept, block.Raw) + } + return true + }) + if !removed { + return payload + } + if len(kept) == 0 { + updated, errDelete := sjson.DeleteBytes(payload, "system") + if errDelete != nil { + return payload + } + return updated + } + updated, errSet := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(kept, ",")+"]")) + if errSet != nil { + return payload + } + return updated } diff --git a/internal/util/claude_attribution_test.go b/internal/util/claude_attribution_test.go --- a/internal/util/claude_attribution_test.go +++ b/internal/util/claude_attribution_test.go @@ -1,6 +1,11 @@ package util -import "testing" +import ( + "strings" + "testing" + + "github.com/tidwall/gjson" +) func TestIsClaudeCodeAttributionSystemText(t *testing.T) { tests := []struct { @@ -34,6 +39,55 @@ t.Run(tt.name, func(t *testing.T) { if got := IsClaudeCodeAttributionSystemText(tt.text); got != tt.want { t.Fatalf("IsClaudeCodeAttributionSystemText(%q) = %v, want %v", tt.text, got, tt.want) + } + }) + } +} + +func TestStripClaudeCodeAttributionSystem(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantSystem string + wantPresent bool + }{ + { + name: "string attribution deleted", + body: `{"system":"x-anthropic-billing-header: cc_version=2.1.220; cch=abcde;","messages":[]}`, + }, + { + name: "string regular prompt kept", + body: `{"system":"You are helpful.","messages":[]}`, + wantSystem: `"You are helpful."`, + wantPresent: true, + }, + { + name: "array drops billing keeps identity", + body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220; cch=abcde;"},{"type":"text","text":"You are Claude Code"}],"messages":[]}`, + wantSystem: `[{"type":"text","text":"You are Claude Code"}]`, + wantPresent: true, + }, + { + name: "array only billing deleted", + body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220; cch=abcde;"}],"messages":[]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := StripClaudeCodeAttributionSystem([]byte(tt.body)) + system := gjson.GetBytes(got, "system") + if system.Exists() != tt.wantPresent { + t.Fatalf("system exists = %v, want %v: %s", system.Exists(), tt.wantPresent, got) + } + if tt.wantPresent && system.Raw != tt.wantSystem { + t.Fatalf("system = %s, want %s", system.Raw, tt.wantSystem) + } + if strings.Contains(string(got), "cch=") { + t.Fatalf("stripped body still contains cch=: %s", got) } }) } diff --git a/internal/runtime/executor/claude_executor_beta_policy_test.go b/internal/runtime/executor/claude_executor_beta_policy_test.go --- a/internal/runtime/executor/claude_executor_beta_policy_test.go +++ b/internal/runtime/executor/claude_executor_beta_policy_test.go @@ -77,18 +77,16 @@ } } -// Betas lifted out of the body must obey the same policy as header-supplied ones. -// Anthropic rejects an unknown beta outright, so letting the body bypass the gate -// turned a caller-controlled field into a guaranteed 400. -func TestApplyClaudeHeaders_UnknownBodyBetaDroppedOnAnthropic(t *testing.T) { +// Default API-key mode preserves body-lifted betas just like header betas. +func TestApplyClaudeHeaders_UnknownBodyBetaPreservedOnAnthropic(t *testing.T) { auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-body-beta"}} req := newClaudeHeaderTestRequest(t, nil) if err := applyClaudeHeaders(req, auth, "key-body-beta", false, []string{"unknown-body-probe-2099-01-01"}, []byte(`{"model":"claude-opus-5"}`), nil, nil, false); err != nil { t.Fatalf("applyClaudeHeaders() error = %v", err) } - if got := req.Header.Get("Anthropic-Beta"); strings.Contains(got, "unknown-body-probe-2099-01-01") { - t.Fatalf("Anthropic-Beta = %q, want the unknown body beta dropped", got) + if got := req.Header.Get("Anthropic-Beta"); got != "unknown-body-probe-2099-01-01" { + t.Fatalf("Anthropic-Beta = %q, want the caller body beta preserved", got) } } @@ -99,10 +97,8 @@ []byte(`{"model":"claude-opus-5"}`), nil, nil, false); err != nil { t.Fatalf("applyClaudeHeaders() error = %v", err) } - got := req.Header.Get("Anthropic-Beta") - parts := strings.Split(got, ",") - if len(parts) < 2 || parts[1] != claudeContext1MBeta { - t.Fatalf("Anthropic-Beta = %q, want %s honored at its captured position", got, claudeContext1MBeta) + if got := req.Header.Get("Anthropic-Beta"); got != claudeContext1MBeta { + t.Fatalf("Anthropic-Beta = %q, want caller body beta %s", got, claudeContext1MBeta) } } diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -930,9 +930,10 @@ } type claudeWirePolicy struct { - OAuth bool - ConfirmedClaudeCode bool - Cloak bool + OAuth bool // real OAuth token runtime identity + ProfileClaudeCodeCLI bool // request fingerprint looks like Claude Code CLI + ConfirmedClaudeCode bool + Cloak bool } type claudeCloakSettings struct { @@ -941,7 +942,7 @@ cacheUserID bool } -func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey string, confirmedClaudeCode bool) (claudeWirePolicy, claudeCloakSettings) { +func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey string, confirmedClaudeCode bool, origin string) (claudeWirePolicy, claudeCloakSettings) { cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth) attrMode, attrStrict, attrWords, attrCache := getCloakConfigFromAuth(auth) @@ -972,10 +973,16 @@ } } + if strings.TrimSpace(origin) == "" { + origin = fingerprintOriginFromAuth(auth) + } + fp := resolveClaudeFingerprintPolicyForOrigin(cfg, auth, apiKey, origin) + cloakConfigured := cloakCfg != nil || attrMode != "" || attrStrict || len(attrWords) > 0 || attrCache policy := claudeWirePolicy{ - OAuth: isClaudeOAuthToken(apiKey), - ConfirmedClaudeCode: confirmedClaudeCode, - Cloak: !confirmedClaudeCode, + OAuth: fp.AuthIsOAuthToken, + ProfileClaudeCodeCLI: fp.ProfileClaudeCodeCLI, + ConfirmedClaudeCode: confirmedClaudeCode, + Cloak: (fp.ProfileClaudeCodeCLI || cloakConfigured) && !confirmedClaudeCode, } if confirmedClaudeCode { // Native Claude Code is always a passthrough client. An operator-level @@ -989,6 +996,10 @@ policy.Cloak = true case "never": policy.Cloak = false + default: + // Auto applies the CLI cloak only to real Claude OAuth credentials, + // explicit fingerprint-profile opt-ins, or credentials with explicit cloak + // settings. Other API keys and delegated providers keep the caller shape. } return policy, settings } @@ -1004,7 +1015,7 @@ confirmedClaudeCode bool, cchSigning bool, ) ([]byte, bool, error) { - policy, settings := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode) + policy, settings := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode, "") if !policy.Cloak { return payload, false, nil } @@ -1020,9 +1031,10 @@ workload := getWorkloadFromContext(ctx) payload = checkSystemInstructionsWithSigningModeAt(payload, settings.strictMode, cchSigning, billingVersion, "cli", workload, claudeCodeCurrentTime(cfg, auth)) - // OAuth metadata is rewritten after credential selection and all remaining - // body mutations. Non-OAuth cloaking keeps the legacy generated identity. - if !policy.OAuth { + // Claude-Code-CLI fingerprint identity (real OAuth or fingerprint-profile=claude-code-cli) + // is applied later through the shared ApplyClaudeCredentialMetadata path. + // Other non-OAuth cloaking keeps the legacy per-request fake user_id. + if !policy.ProfileClaudeCodeCLI { var errFakeUserID error payload, errFakeUserID = injectFakeUserID(ctx, payload, apiKey, settings.cacheUserID) if errFakeUserID != nil { diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -28,8 +28,10 @@ baseURL = "https://api.anthropic.com" } url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL) - oauthToken := isClaudeOAuthToken(apiKey) - cchSigning := claudeCCHSigningEnabled(apiKey, claudeCCHUpstreamAnthropic, url) + fp := resolveClaudeFingerprintPolicyForOrigin(e.cfg, auth, apiKey, url) + // Real Claude OAuth and explicit fingerprint-profile opt-ins sign CCH. + // Default API-key and delegated-provider requests preserve the caller body. + cchSigning := claudeCCHSigningEnabled(apiKey, claudeCCHUpstreamAnthropic, fp.ProfileClaudeCodeCLI) reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth) defer reporter.TrackFailure(ctx, &err) @@ -56,7 +58,7 @@ incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, false, e.cfg) confirmedClaudeCode := claudeCodeDetection.Confirmed claudeSessionID := "" - if oauthToken { + if fp.ProfileClaudeCodeCLI { claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) } originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, upstreamStream, helps.APIKeyModelIsCompat(req)) @@ -97,7 +99,7 @@ } if contextManagementState.eligible { body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body) - if oauthToken { + if fp.InjectDiagnostics { body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID) } } @@ -139,7 +141,8 @@ // opt-in to 5m, so a cloaked caller's bare {"type":"ephemeral"} is upgraded too. // Only a ttl the caller wrote out explicitly survives, because // upgradeClaudeCacheControlTTL skips any block that already has one. - if cpaOwnsCacheControl && claudeCredentialUsesOAuth(auth, apiKey) { + // claude-code-cli fingerprint profiles emit extended-cache-ttl and must use the same 1h pool. + if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI { body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) } @@ -161,13 +164,26 @@ bodyForTranslation := body bodyForUpstream := body var oauthToolNamesReverseMap map[string]string - if oauthToken && cloaked { + if fp.MCPAlias && cloaked { mcpAliases := resolveClaudeMCPAliasOptions(ctx) bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, mcpAliases) } bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req)) - if oauthToken { - bodyForUpstream, _, err = helps.ApplyClaudeCredentialMetadata(bodyForUpstream, auth, claudeSessionID) + if fp.ApplyCLIIdentity { + // ApplyCLIIdentity and ProfileClaudeCodeCLI are the same predicate, so + // claudeSessionID was already resolved above by ClaudeAgentSessionUUIDForRequest, + // which always returns a UUID. Do not add a second session source here: a + // per-apiKey cached ID would silently break agent-conversation continuity. + identitySeed := apiKey + if isKimiMessagesUpstream(auth, url) { + identitySeed = helps.ClaudeCLIAuthIdentitySeed(auth) + } + var identityAuth *cliproxyauth.Auth + identityAuth, err = helps.PrepareClaudeCLIFingerprintAuth(auth, identitySeed, fp.SynthesizeIdentity) + if err != nil { + return resp, fmt.Errorf("ensure Claude CLI fingerprint identity: %w", err) + } + bodyForUpstream, _, err = helps.ApplyClaudeCredentialMetadata(bodyForUpstream, identityAuth, claudeSessionID) if err != nil { return resp, fmt.Errorf("apply Claude credential metadata: %w", err) } @@ -182,6 +198,7 @@ return resp, fmt.Errorf("finalize Claude CCH: %w", err) } } + bodyForUpstream = stripDefaultKimiClaudeCodeAttribution(auth, url, fp.ProfileClaudeCodeCLI, bodyForUpstream) // Runs on the finished body: payload rules can rewrite model and messages // long after translation, so an earlier check would not describe the request // that is about to be sent. diff --git a/internal/runtime/executor/claude_executor_native_helper_test.go b/internal/runtime/executor/claude_executor_native_helper_test.go --- a/internal/runtime/executor/claude_executor_native_helper_test.go +++ b/internal/runtime/executor/claude_executor_native_helper_test.go @@ -67,14 +67,16 @@ } } -func TestApplyClaudeHeadersPreservesAsyncOnlyForConfirmedNative(t *testing.T) { +func TestApplyClaudeHeadersPreservesCallerAsyncWithoutFingerprintOptIn(t *testing.T) { for _, test := range []struct { name string confirmed bool + profile bool wantAsync string }{ {name: "confirmed native", confirmed: true, wantAsync: "async"}, - {name: "unconfirmed caller", confirmed: false}, + {name: "unconfirmed caller default", wantAsync: "async"}, + {name: "unconfirmed caller profile", profile: true}, } { t.Run(test.name, func(t *testing.T) { request, errRequest := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", nil) @@ -83,6 +85,9 @@ } incoming := http.Header{"X-Stainless-Async": {"async"}} auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "test-api-key"}} + if test.profile { + auth.Attributes["fingerprint_profile"] = "claude-code-cli" + } if errHeaders := applyClaudeHeaders( request, auth, diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -19,6 +19,7 @@ "github.com/google/uuid" "github.com/klauspost/compress/zstd" claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" + "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" @@ -613,6 +614,26 @@ return isClaudeOAuthToken(apiKey) || !hasAPIKeyAttr } +func copyClaudeCallerFingerprintHeaders(dst, src http.Header) { + if dst == nil || src == nil { + return + } + for name, values := range src { + lowerName := strings.ToLower(strings.TrimSpace(name)) + if lowerName != "accept" && lowerName != "accept-encoding" && lowerName != "user-agent" && + lowerName != "x-app" && lowerName != "x-client-request-id" && + !strings.HasPrefix(lowerName, "anthropic-") && + !strings.HasPrefix(lowerName, "x-stainless-") && + !strings.HasPrefix(lowerName, "x-claude-code-") { + continue + } + dst.Del(name) + for _, value := range values { + dst.Add(name, value) + } + } +} + func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, body []byte, cfg *config.Config, incomingHeaders http.Header, confirmedClaudeCode bool, sessionIDs ...string) error { return applyClaudeHeadersWithNativeProfile( r, @@ -657,8 +678,20 @@ hd = cfg.ClaudeHeaderDefaults } - oauthToken := claudeCredentialUsesOAuth(auth, apiKey) - useAPIKey := !oauthToken + // Authentication and wire fingerprint are separate authorities. File-backed + // delegated providers still use Bearer auth, but only real Claude OAuth and + // explicit fingerprint-profile opt-ins receive the CLI wire profile. + credentialUsesBearer := claudeCredentialUsesOAuth(auth, apiKey) + useAPIKey := !credentialUsesBearer + origin := "" + if r.URL != nil { + origin = r.URL.String() + } + fp := resolveClaudeFingerprintPolicyForOrigin(cfg, auth, apiKey, origin) + wirePolicy, _ := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode, origin) + applyCLIFingerprint := fp.ProfileClaudeCodeCLI || wirePolicy.Cloak + preserveCallerFingerprint := !applyCLIFingerprint && !confirmedClaudeCode + useOAuthBetas := fp.UseOAuthBetas isAnthropicBase := isAnthropicUpstreamURL(r.URL) if isAnthropicBase && useAPIKey { r.Header.Del("Authorization") @@ -685,15 +718,18 @@ incomingBetas := strings.TrimSpace(strings.Join(incomingHeaders.Values("Anthropic-Beta"), ",")) countTokens := r.URL != nil && strings.HasSuffix(r.URL.Path, "/count_tokens") - baseBetas := claudeCodeCLIBetas(body, claudeRequestedBetas(incomingBetas, extraBetas), oauthToken) - if countTokens { - baseBetas = claudeCountTokensBetasForCredential(oauthToken) + baseBetas := incomingBetas + if !preserveCallerFingerprint { + baseBetas = claudeCodeCLIBetas(body, claudeRequestedBetas(incomingBetas, extraBetas), useOAuthBetas) + if countTokens { + baseBetas = claudeCountTokensBetasForCredential(useOAuthBetas) + } } if confirmedClaudeCode && incomingBetas != "" { baseBetas = incomingBetas // Measured Haiku helper requests already carry the exact credential // beta profile and intentionally omit extended-cache-ttl. - if oauthToken && !helperProfile { + if useOAuthBetas && !helperProfile { if countTokens { baseBetas = withClaudeCountTokensOAuthBeta(baseBetas) } else { @@ -712,31 +748,93 @@ if beta == "" || existingSet[beta] { return } - baseBetas += "," + beta + if strings.TrimSpace(baseBetas) == "" { + baseBetas = beta + } else { + baseBetas += "," + beta + } existingSet[beta] = true } - // On direct Anthropic an unconfirmed caller's own betas are dropped: appending - // them to the official baseline produces a combination real Claude Code never - // sends, which defeats the identity the rest of this path reconstructs. Other - // Anthropic-compatible upstreams (Kimi, custom gateways) run no such check, so - // caller betas stay functional there. This matches the CCH signing gate, which - // is likewise limited to api.anthropic.com. - if !confirmedClaudeCode && incomingBetas != "" && !isAnthropicBase { - for _, beta := range strings.Split(incomingBetas, ",") { - appendBeta(beta) + if preserveCallerFingerprint { + // Caller-owned mode preserves both header and body-lifted betas verbatim. + // The explicit speed=fast request still needs its protocol beta. + if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "speed").String()), "fast") { + appendBeta(claudeFastModeBeta) } - } - // Betas lifted out of the body follow the same policy as header-supplied ones. - // Known betas already reached the assembled baseline through the requested map, - // which places them at their captured positions; anything left over is unknown - // to Claude Code and Anthropic rejects it outright. Forwarding those verbatim - // here was letting the body bypass the gate the header path enforces. - if !isAnthropicBase { for _, beta := range extraBetas { appendBeta(beta) } + } else { + // On direct Anthropic an unconfirmed CLI-profile caller's own betas are + // dropped: appending them to the measured baseline produces a shape real + // Claude Code never sends. Custom gateways keep caller extensions. + if !confirmedClaudeCode && incomingBetas != "" && !isAnthropicBase { + for _, beta := range strings.Split(incomingBetas, ",") { + appendBeta(beta) + } + } + if !isAnthropicBase { + for _, beta := range extraBetas { + appendBeta(beta) + } + } } - r.Header.Set("Anthropic-Beta", baseBetas) + applyBetaHeader := func() { + if strings.TrimSpace(baseBetas) == "" { + r.Header.Del("Anthropic-Beta") + return + } + r.Header.Set("Anthropic-Beta", baseBetas) + } + applyBetaHeader() + + if preserveCallerFingerprint { + defaultAccept := "application/json" + defaultAcceptEncoding := "gzip, deflate, br, zstd" + if stream && !isAnthropicBase { + defaultAccept = "text/event-stream" + defaultAcceptEncoding = "identity" + } + copyClaudeCallerFingerprintHeaders(r.Header, incomingHeaders) + misc.EnsureHeader(r.Header, incomingHeaders, "Anthropic-Version", "2023-06-01") + misc.EnsureHeader(r.Header, incomingHeaders, "Accept", defaultAccept) + misc.EnsureHeader(r.Header, incomingHeaders, "Accept-Encoding", defaultAcceptEncoding) + // Caller-owned mode forwards the caller's own User-Agent, but a caller that + // sent none must not fall through to Go's transport default + // ("Go-http-client/1.1"), which upstreams read as a bot signature. Identify + // as CPA instead: honest about the hop, and not a fabricated client. + misc.EnsureHeader(r.Header, incomingHeaders, "User-Agent", "CLIProxyAPI/"+buildinfo.Version) + applyBetaHeader() + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(r, attrs) + // Scope the custom-header escape hatch exactly like the CLI path below, which + // claws overrides back on api.anthropic.com (an operator Anthropic-Beta reaches + // a first-party API that rejects unknown values) and on any streaming request + // (an Accept override silently disables event negotiation), while letting a + // non-streaming third-party gateway keep them. Restoring here means restoring + // the caller's own choice, not CPA's default: this mode is caller-owned. + restoreCallerTransport := func() { + resetHeader := func(name, fallback string) { + if value := strings.TrimSpace(incomingHeaders.Get(name)); value != "" { + r.Header.Set(name, value) + return + } + r.Header.Set(name, fallback) + } + resetHeader("Accept", defaultAccept) + resetHeader("Accept-Encoding", defaultAcceptEncoding) + } + if isAnthropicBase { + applyBetaHeader() + restoreCallerTransport() + } else if stream { + restoreCallerTransport() + } + return nil + } identityHeader := func(name, fallback string) { if confirmedClaudeCode { diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -30,13 +30,13 @@ baseURL = "https://api.anthropic.com" } url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL) - oauthToken := isClaudeOAuthToken(apiKey) + fp := resolveClaudeFingerprintPolicyForOrigin(e.cfg, auth, apiKey, url) defer func() { - if cancelErr := newClaudeOAuthCancellationError(ctx, oauthToken, err); cancelErr != nil { + if cancelErr := newClaudeOAuthCancellationError(ctx, fp.OAuthCancellation, err); cancelErr != nil { err = cancelErr } }() - cchSigning := claudeCCHSigningEnabled(apiKey, claudeCCHUpstreamAnthropic, url) + cchSigning := claudeCCHSigningEnabled(apiKey, claudeCCHUpstreamAnthropic, fp.ProfileClaudeCodeCLI) reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth) defer reporter.TrackFailure(ctx, &err) @@ -60,7 +60,7 @@ incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, false, e.cfg) confirmedClaudeCode := claudeCodeDetection.Confirmed claudeSessionID := "" - if oauthToken { + if fp.ProfileClaudeCodeCLI { claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) } originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true, helps.APIKeyModelIsCompat(req)) @@ -101,7 +101,7 @@ } if contextManagementState.eligible { body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body) - if oauthToken { + if fp.InjectDiagnostics { body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID) } } @@ -141,7 +141,8 @@ // opt-in to 5m, so a cloaked caller's bare {"type":"ephemeral"} is upgraded too. // Only a ttl the caller wrote out explicitly survives, because // upgradeClaudeCacheControlTTL skips any block that already has one. - if cpaOwnsCacheControl && claudeCredentialUsesOAuth(auth, apiKey) { + // claude-code-cli fingerprint profiles emit extended-cache-ttl and must use the same 1h pool. + if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI { body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) } @@ -154,13 +155,26 @@ bodyForTranslation := body bodyForUpstream := body var oauthToolNamesReverseMap map[string]string - if oauthToken && cloaked { + if fp.MCPAlias && cloaked { mcpAliases := resolveClaudeMCPAliasOptions(ctx) bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, mcpAliases) } bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req)) - if oauthToken { - bodyForUpstream, _, err = helps.ApplyClaudeCredentialMetadata(bodyForUpstream, auth, claudeSessionID) + if fp.ApplyCLIIdentity { + // ApplyCLIIdentity and ProfileClaudeCodeCLI are the same predicate, so + // claudeSessionID was already resolved above by ClaudeAgentSessionUUIDForRequest, + // which always returns a UUID. Do not add a second session source here: a + // per-apiKey cached ID would silently break agent-conversation continuity. + identitySeed := apiKey + if isKimiMessagesUpstream(auth, url) { + identitySeed = helps.ClaudeCLIAuthIdentitySeed(auth) + } + var identityAuth *cliproxyauth.Auth + identityAuth, err = helps.PrepareClaudeCLIFingerprintAuth(auth, identitySeed, fp.SynthesizeIdentity) + if err != nil { + return nil, fmt.Errorf("ensure Claude CLI fingerprint identity: %w", err) + } + bodyForUpstream, _, err = helps.ApplyClaudeCredentialMetadata(bodyForUpstream, identityAuth, claudeSessionID) if err != nil { return nil, fmt.Errorf("apply Claude credential metadata: %w", err) } @@ -175,6 +189,7 @@ return nil, fmt.Errorf("finalize Claude CCH: %w", err) } } + bodyForUpstream = stripDefaultKimiClaudeCodeAttribution(auth, url, fp.ProfileClaudeCodeCLI, bodyForUpstream) // Runs on the finished body: payload rules can rewrite model and messages // long after translation, so an earlier check would not describe the request // that is about to be sent. @@ -272,7 +287,7 @@ } }() emitCancellation := func(cause error) bool { - cancelErr := newClaudeOAuthCancellationError(ctx, oauthToken, cause) + cancelErr := newClaudeOAuthCancellationError(ctx, fp.OAuthCancellation, cause) if cancelErr == nil { return false } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -108,7 +108,7 @@ }, } - auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-fast-mode-beta"}} + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-fast-mode-beta", "cloak_mode": "always"}} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { extraBetas, body := extractAndRemoveBetas([]byte(tt.body)) @@ -193,6 +193,7 @@ ID: "auth-baseline", Attributes: map[string]string{ "api_key": "key-baseline", + "cloak_mode": "always", "header:User-Agent": "evil-client/9.9", "header:X-Stainless-Os": "Linux", "header:X-Stainless-Arch": "x64", @@ -233,7 +234,8 @@ auth := &cliproxyauth.Auth{ ID: "auth-upgrade", Attributes: map[string]string{ - "api_key": "key-upgrade", + "api_key": "key-upgrade", + "cloak_mode": "always", }, } @@ -347,7 +349,8 @@ auth := &cliproxyauth.Auth{ ID: "auth-baseline-reload", Attributes: map[string]string{ - "api_key": "key-baseline-reload", + "api_key": "key-baseline-reload", + "cloak_mode": "always", }, } @@ -389,7 +392,8 @@ auth := &cliproxyauth.Auth{ ID: "auth-custom-baseline-learning", Attributes: map[string]string{ - "api_key": "key-custom-baseline-learning", + "api_key": "key-custom-baseline-learning", + "cloak_mode": "always", }, } @@ -547,7 +551,8 @@ auth := &cliproxyauth.Auth{ ID: "auth-third-party-then-official", Attributes: map[string]string{ - "api_key": "key-third-party-then-official", + "api_key": "key-third-party-then-official", + "cloak_mode": "always", }, } @@ -589,7 +594,8 @@ auth := &cliproxyauth.Auth{ ID: "auth-disable-stability", Attributes: map[string]string{ - "api_key": "key-disable-stability", + "api_key": "key-disable-stability", + "cloak_mode": "always", }, } @@ -673,7 +679,8 @@ auth := &cliproxyauth.Auth{ ID: "auth-legacy-runtime-os-arch", Attributes: map[string]string{ - "api_key": "key-legacy-runtime-os-arch", + "api_key": "key-legacy-runtime-os-arch", + "cloak_mode": "always", }, } @@ -700,7 +707,8 @@ auth := &cliproxyauth.Auth{ ID: "auth-unset-runtime-os-arch", Attributes: map[string]string{ - "api_key": "key-unset-runtime-os-arch", + "api_key": "key-unset-runtime-os-arch", + "cloak_mode": "always", }, } @@ -745,8 +753,9 @@ executor := NewClaudeExecutor(&config.Config{}) auth := &cliproxyauth.Auth{Attributes: map[string]string{ - "api_key": "key-sdk-fingerprint", - "base_url": server.URL, + "api_key": "key-sdk-fingerprint", + "base_url": server.URL, + "cloak_mode": "always", }} payload := []byte(`{"model":"claude-opus-4-6","messages":[{"role":"user","content":[{"type":"text","text":"x"}]}]}`) @@ -1020,8 +1029,9 @@ payload := []byte(`{"model":"claude-opus-5","system":"spoofed-system","messages":[{"role":"user","content":"x"}]}`) executor := NewClaudeExecutor(&config.Config{}) auth := &cliproxyauth.Auth{Attributes: map[string]string{ - "api_key": "key-spoofed-client", - "base_url": server.URL, + "api_key": "key-spoofed-client", + "base_url": server.URL, + "cloak_mode": "always", }} _, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ Model: "claude-opus-5", @@ -1068,8 +1078,9 @@ payload := []byte(`{"model":"claude-opus-4-6","system":"agent-sdk-system","messages":[{"role":"user","content":"x"}],"metadata":{"user_id":"agent-sdk-user"}}`) executor := NewClaudeExecutor(&config.Config{}) auth := &cliproxyauth.Auth{Attributes: map[string]string{ - "api_key": "key-agent-sdk-client", - "base_url": server.URL, + "api_key": "key-agent-sdk-client", + "base_url": server.URL, + "cloak_mode": "always", }} _, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ Model: "claude-opus-4-6", @@ -2676,7 +2687,7 @@ t.Logf("✓ End-to-end test passed: Same user_id (%s) was used for both models", userIDs[0]) } -func TestClaudeExecutor_GeneratesNewUserIDByDefault(t *testing.T) { +func TestClaudeExecutor_DefaultDoesNotInjectUserID(t *testing.T) { var userIDs []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) @@ -2708,14 +2719,8 @@ if len(userIDs) != 2 { t.Fatalf("expected 2 requests, got %d", len(userIDs)) } - if userIDs[0] == "" || userIDs[1] == "" { - t.Fatal("expected user_id to be populated") - } - if userIDs[0] == userIDs[1] { - t.Fatalf("expected user_id to change when caching is not enabled, got identical values %q", userIDs[0]) - } - if !helps.IsValidUserID(userIDs[0]) || !helps.IsValidUserID(userIDs[1]) { - t.Fatalf("user_ids should be valid, got %q and %q", userIDs[0], userIDs[1]) + if userIDs[0] != "" || userIDs[1] != "" { + t.Fatalf("default API-key requests must preserve caller metadata without injecting user_id, got %q and %q", userIDs[0], userIDs[1]) } } @@ -4245,7 +4250,7 @@ } } -func TestClaudeExecutor_CustomBaseURLOmitsCCHByDefault(t *testing.T) { +func TestClaudeExecutor_CustomBaseURLPreservesBodyByDefault(t *testing.T) { var seenBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) @@ -4273,12 +4278,8 @@ t.Fatal("expected request body to be captured") } - billingHeader := gjson.GetBytes(seenBody, "system.0.text").String() - if !strings.HasPrefix(billingHeader, "x-anthropic-billing-header:") { - t.Fatalf("system.0.text = %q, want billing header", billingHeader) - } - if strings.Contains(billingHeader, "cch=") { - t.Fatalf("custom BaseURL must not include CCH, got %q", billingHeader) + if strings.Contains(string(seenBody), "x-anthropic-billing-header:") || strings.Contains(string(seenBody), "cch=") { + t.Fatalf("default custom BaseURL request must not inject billing/CCH: %s", seenBody) } } @@ -4316,13 +4317,11 @@ if len(seenBody) == 0 { t.Fatal("expected request body to be captured") } - if got := gjson.GetBytes(seenBody, "messages.0.content.1.text").String(); got != messageText { + if got := gjson.GetBytes(seenBody, "messages.0.content.0.text").String(); got != messageText { t.Fatalf("message text = %q, want %q", got, messageText) } - assertClaudeCodeCurrentDateBlock(t, gjson.GetBytes(seenBody, "messages.0.content.0")) - - if billing := gjson.GetBytes(seenBody, "system.0.text").String(); strings.Contains(billing, "cch=") { - t.Fatalf("custom BaseURL billing header must not contain CCH: %q", billing) + if strings.Contains(string(seenBody), "x-anthropic-billing-header:") { + t.Fatalf("default custom BaseURL request must not inject a billing header: %s", seenBody) } } @@ -4484,7 +4483,7 @@ for _, test := range tests { t.Run(test.name, func(t *testing.T) { auth := &cliproxyauth.Auth{Metadata: map[string]any{"cloak_mode": test.mode}} - policy, _ := resolveClaudeWirePolicy(&config.Config{}, auth, "sk-ant-oat-test", test.confirmed) + policy, _ := resolveClaudeWirePolicy(&config.Config{}, auth, "sk-ant-oat-test", test.confirmed, "") if !policy.OAuth { t.Fatal("resolveClaudeWirePolicy() OAuth = false, want true") } @@ -5591,21 +5590,18 @@ } } -func TestApplyClaudeHeaders_CallerBetasScopedByUpstream(t *testing.T) { +func TestApplyClaudeHeaders_DefaultPreservesCallerBetas(t *testing.T) { incoming := http.Header{"Anthropic-Beta": []string{"caller-only-beta"}} auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-caller-betas"}} body := []byte(`{"model":"claude-opus-4-6"}`) - // Direct Anthropic must not echo a beta real Claude Code never sends. + // Default API-key mode preserves caller betas on direct Anthropic. directReq := newClaudeHeaderTestRequest(t, incoming) if errApply := applyClaudeHeaders(directReq, auth, "key-caller-betas", false, nil, body, nil, incoming, false); errApply != nil { t.Fatalf("applyClaudeHeaders() error = %v", errApply) } - if got := directReq.Header.Get("Anthropic-Beta"); strings.Contains(got, "caller-only-beta") { - t.Fatalf("Anthropic-Beta = %q, want caller beta dropped on api.anthropic.com", got) - } - if got, want := directReq.Header.Get("Anthropic-Beta"), claudeCodeCLIBetas(body, nil, false); got != want { - t.Fatalf("Anthropic-Beta = %q, want exactly the CLI baseline %q", got, want) + if got := directReq.Header.Get("Anthropic-Beta"); got != "caller-only-beta" { + t.Fatalf("Anthropic-Beta = %q, want caller beta on api.anthropic.com", got) } // Other Anthropic-compatible upstreams keep caller betas functional. @@ -6086,7 +6082,7 @@ }) ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) executor := NewClaudeExecutor(cfg) - auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-payload-rule"}} + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-payload-rule", "cloak_mode": "always"}} request := cliproxyexecutor.Request{Model: "claude-opus-5", Payload: payload} options := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude} @@ -6184,7 +6180,7 @@ func TestApplyCloakingRejectsNonTextCallerSystemBlock(t *testing.T) { cfg := &config.Config{} - auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-123"}} + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-123", "cloak_mode": "always"}} payload := []byte(`{"model":"claude-opus-5","system":[{"type":"text","text":"S1"},{"type":"input_image"}],"messages":[{"role":"user","content":[{"type":"text","text":"U1"}]}]}`) out, cloaked, errCloaking := applyCloaking(context.Background(), cfg, auth, payload, "key-123", false, true) @@ -6290,8 +6286,9 @@ auth := &cliproxyauth.Auth{ ID: "cache-ttl-pairing", Attributes: map[string]string{ - "api_key": test.apiKey, - "base_url": server.URL, + "api_key": test.apiKey, + "base_url": server.URL, + "cloak_mode": "always", }, Metadata: claudeOAuthTestMetadata(), } diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -10,6 +10,7 @@ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" @@ -125,7 +126,7 @@ baseURL = "https://api.anthropic.com" } url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL) - oauthToken := isClaudeOAuthToken(apiKey) + fp := resolveClaudeFingerprintPolicyForOrigin(e.cfg, auth, apiKey, url) from := opts.SourceFormat responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) @@ -137,7 +138,7 @@ incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, true, e.cfg) confirmedClaudeCode := claudeCodeDetection.Confirmed claudeSessionID := "" - if oauthToken { + if fp.ProfileClaudeCodeCLI { claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) } // Use streaming translation to preserve function calling, except for claude. @@ -154,38 +155,22 @@ } directAnthropic := isAnthropicUpstreamBase(baseURL) - var cloaked bool - if directAnthropic { - // Claude Code's count_tokens carries only model, messages and tools, so the - // full Messages cloaking must not run here. Apply the parts that still have - // to hold: relocate the caller's system prompt into messages so its tokens - // stay counted, and obfuscate sensitive words exactly like the Messages path. - policy, settings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) - cloaked = policy.Cloak - if cloaked { - if !settings.strictMode { - if errSystem := validateClaudeCallerSystemBlocks(gjson.GetBytes(body, "system")); errSystem != nil { - return cliproxyexecutor.Response{}, errSystem - } - } - body = relocateClaudeSystemPromptForCountTokens(body, settings.strictMode) - if len(settings.sensitiveWords) > 0 { - body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords)) + // Claude Code's count_tokens carries only model, messages and tools, so the + // full Messages cloaking must not run here for any origin. Apply the parts + // that still have to hold: relocate the caller's system prompt into messages + // so its tokens stay counted, and obfuscate sensitive words exactly like the + // Messages path. Kimi opt-in uses the same contract. + policy, settings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode, url) + cloaked := policy.Cloak + if cloaked { + if !settings.strictMode { + if errSystem := validateClaudeCallerSystemBlocks(gjson.GetBytes(body, "system")); errSystem != nil { + return cliproxyexecutor.Response{}, errSystem } } - } else { - var errCloaking error - body, cloaked, errCloaking = applyCloaking( - ctx, - e.cfg, - auth, - body, - apiKey, - confirmedClaudeCode, - false, - ) - if errCloaking != nil { - return cliproxyexecutor.Response{}, errCloaking + body = relocateClaudeSystemPromptForCountTokens(body, settings.strictMode) + if len(settings.sensitiveWords) > 0 { + body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords)) } } @@ -198,18 +183,33 @@ extraBetas, body = extractAndRemoveBetas(body) // Claude Code 2.1.220's beta.messages.countTokens() always appends this beta. extraBetas = append(extraBetas, claudeTokenCountingBeta) - if oauthToken && cloaked { + if fp.MCPAlias && cloaked { mcpAliases := resolveClaudeMCPAliasOptions(ctx) body, _ = prepareClaudeOAuthToolNamesForUpstream(body, mcpAliases) } body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req)) - // Claude Code never sends metadata on count_tokens, and Anthropic rejects the - // field outright there ("metadata: Extra inputs are not permitted"). The - // Messages path still carries the credential identity; this endpoint must not. - if directAnthropic { + // Two different reasons converge on the same deletions, and they must stay + // separable. + // + // api.anthropic.com rejects these fields on count_tokens outright ("metadata: + // Extra inputs are not permitted"), so they have to go for every credential + // that lands there, opted in or not. That is upstream compatibility, not + // fingerprinting. + // + // Elsewhere (Kimi, delegated Anthropic Messages providers) the caller owns its + // body by default: a caller that deliberately sends context_management expects + // the token count to reflect it, so CPA must not silently rewrite the request. + // Only an explicit claude-code-cli profile aligns the shape, and then it aligns + // to the measured one: Claude Code 2.1.220 count_tokens carries exactly model, + // messages and tools, never a system block. + alignCLICountTokensShape := fp.ProfileClaudeCodeCLI + if directAnthropic || alignCLICountTokensShape { body, _ = sjson.DeleteBytes(body, "metadata") body, _ = sjson.DeleteBytes(body, "context_management") body, _ = sjson.DeleteBytes(body, "diagnostics") + } + if alignCLICountTokensShape { + body = util.StripClaudeCodeAttributionSystem(body) } // Runs on the finished body: payload rules can rewrite model and messages // long after translation, so an earlier check would not describe the request diff --git a/internal/runtime/executor/claude_fingerprint_policy.go b/internal/runtime/executor/claude_fingerprint_policy.go new file mode 100644 --- /dev/null +++ b/internal/runtime/executor/claude_fingerprint_policy.go @@ -0,0 +1,112 @@ +package executor + +import ( + "strings" + + claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" +) + +const ( + claudeFingerprintProfileDefault = "" + claudeFingerprintProfileClaudeCodeCLI = "claude-code-cli" + claudeFingerprintProfileOAuthCLI = "oauth-cli" // legacy compatibility alias + claudeFingerprintProfileAttr = "fingerprint_profile" +) + +// claudeFingerprintPolicy is a single switch-driven view of Claude fingerprint +// behavior for Anthropic Messages. The heavy algorithms stay shared: +// - betas: claudeCodeCLIBetas(..., useOAuthBetas) +// - CCH: claudeCCHSigningEnabled / finalizeAnthropicMessagesBodyCCH +// - identity: EnsureClaudeCLIFingerprintIdentity + ApplyClaudeCredentialMetadata +// +// Goal: Anthropic Messages API keys, custom gateways, and delegated providers +// (such as Kimi) can opt into the Claude Code OAuth CLI request fingerprint via +// fingerprint-profile=claude-code-cli, without OAuth control-plane semantics. +// Real Claude OAuth tokens always keep the strict CLI fingerprint. First-party +// api.anthropic.com API keys stay caller-owned by default and only take the CLI +// Messages fingerprint when this field is set. MCP aliases and diagnostics are +// wire fingerprint behavior; refresh, profile and cancellation stay gated on +// AuthIsOAuthToken. +type claudeFingerprintPolicy struct { + AuthIsOAuthToken bool + ProfileClaudeCodeCLI bool + UseOAuthBetas bool + ApplyCLIIdentity bool + SynthesizeIdentity bool + MCPAlias bool + InjectDiagnostics bool + OAuthCancellation bool +} + +func normalizeClaudeFingerprintProfile(raw string) string { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + switch trimmed { + case claudeFingerprintProfileClaudeCodeCLI, claudeFingerprintProfileOAuthCLI: + return claudeFingerprintProfileClaudeCodeCLI + case "": + return claudeFingerprintProfileDefault + default: + log.Warnf("unrecognized claude fingerprint-profile %q (supported: %q); falling back to default", raw, claudeFingerprintProfileClaudeCodeCLI) + return claudeFingerprintProfileDefault + } +} + +func claudeFingerprintProfileFromAuth(auth *cliproxyauth.Auth) string { + if auth == nil { + return claudeFingerprintProfileDefault + } + if auth.Attributes != nil { + if raw, ok := auth.Attributes[claudeFingerprintProfileAttr]; ok && strings.TrimSpace(raw) != "" { + return normalizeClaudeFingerprintProfile(raw) + } + } + for _, key := range []string{claudeFingerprintProfileAttr, "fingerprint-profile"} { + raw := claudeauth.ReadMetadataString(&auth.Metadata, key) + if strings.TrimSpace(raw) != "" { + return normalizeClaudeFingerprintProfile(raw) + } + } + return claudeFingerprintProfileDefault +} + +func claudeFingerprintProfileFromConfig(cfg *config.Config, auth *cliproxyauth.Auth) string { + if profile := claudeFingerprintProfileFromAuth(auth); profile != claudeFingerprintProfileDefault { + return profile + } + entry := resolveClaudeKeyConfig(cfg, auth) + if entry == nil { + return claudeFingerprintProfileDefault + } + return normalizeClaudeFingerprintProfile(entry.FingerprintProfile) +} + +func fingerprintOriginFromAuth(auth *cliproxyauth.Auth) string { + _, baseURL := claudeCreds(auth) + return baseURL +} + +func resolveClaudeFingerprintPolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey string) claudeFingerprintPolicy { + return resolveClaudeFingerprintPolicyForOrigin(cfg, auth, apiKey, fingerprintOriginFromAuth(auth)) +} + +func resolveClaudeFingerprintPolicyForOrigin(cfg *config.Config, auth *cliproxyauth.Auth, apiKey, _ string) claudeFingerprintPolicy { + // Keep actual Claude OAuth lifecycle authority separate from the broader + // request fingerprint policy used by API keys and delegated providers. + authIsOAuth := isClaudeOAuthToken(apiKey) + profile := claudeFingerprintProfileFromConfig(cfg, auth) + profileClaudeCodeCLI := authIsOAuth || profile == claudeFingerprintProfileClaudeCodeCLI + + return claudeFingerprintPolicy{ + AuthIsOAuthToken: authIsOAuth, + ProfileClaudeCodeCLI: profileClaudeCodeCLI, + UseOAuthBetas: profileClaudeCodeCLI, + ApplyCLIIdentity: profileClaudeCodeCLI, + SynthesizeIdentity: profileClaudeCodeCLI && !authIsOAuth, + MCPAlias: profileClaudeCodeCLI, + InjectDiagnostics: profileClaudeCodeCLI, + OAuthCancellation: authIsOAuth, + } +} diff --git a/internal/runtime/executor/claude_fingerprint_policy_test.go b/internal/runtime/executor/claude_fingerprint_policy_test.go new file mode 100644 --- /dev/null +++ b/internal/runtime/executor/claude_fingerprint_policy_test.go @@ -0,0 +1,1170 @@ +package executor + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestResolveClaudeFingerprintPolicy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + provider string + apiKey string + attrs map[string]string + metadata map[string]any + cfg *config.Config + wantAuthOAuth bool + wantProfileOAuth bool + wantSynthesize bool + wantMCP bool + wantDiagnostics bool + wantCancellation bool + }{ + { + name: "real oauth token", + apiKey: "sk-ant-oat-real", + attrs: map[string]string{"api_key": "sk-ant-oat-real"}, + wantAuthOAuth: true, + wantProfileOAuth: true, + wantMCP: true, + wantDiagnostics: true, + wantCancellation: true, + }, + { + name: "api key default", + apiKey: "key-default", + attrs: map[string]string{"api_key": "key-default"}, + wantAuthOAuth: false, + wantProfileOAuth: false, + }, + { + name: "official anthropic api key opts in via claude-code-cli attribute", + apiKey: "key-attr", + attrs: map[string]string{"api_key": "key-attr", "fingerprint_profile": "claude-code-cli"}, + wantProfileOAuth: true, + wantSynthesize: true, + wantMCP: true, + wantDiagnostics: true, + }, + { + name: "official anthropic api key opts in via oauth-cli alias", + apiKey: "key-attr-legacy", + attrs: map[string]string{"api_key": "key-attr-legacy", "fingerprint_profile": "oauth-cli"}, + wantProfileOAuth: true, + wantSynthesize: true, + wantMCP: true, + wantDiagnostics: true, + }, + { + name: "official anthropic explicit 443 api key opts in via profile", + apiKey: "key-official-443", + attrs: map[string]string{"api_key": "key-official-443", "base_url": "https://api.anthropic.com:443", "fingerprint_profile": "claude-code-cli"}, + wantProfileOAuth: true, + wantSynthesize: true, + wantMCP: true, + wantDiagnostics: true, + }, + { + name: "api key claude-code-cli attribute on gateway", + apiKey: "key-attr-gateway", + attrs: map[string]string{ + "api_key": "key-attr-gateway", + "base_url": "https://gateway.example", + "fingerprint_profile": "claude-code-cli", + }, + wantProfileOAuth: true, + wantSynthesize: true, + wantMCP: true, + wantDiagnostics: true, + }, + { + name: "api key claude-code-cli config entry", + apiKey: "key-config", + attrs: map[string]string{"api_key": "key-config", "base_url": "https://gateway.example"}, + cfg: &config.Config{ClaudeKey: []config.ClaudeKey{{ + APIKey: "key-config", + BaseURL: "https://gateway.example", + FingerprintProfile: "claude-code-cli", + }}}, + wantProfileOAuth: true, + wantSynthesize: true, + wantMCP: true, + wantDiagnostics: true, + }, + { + name: "official anthropic api key opts in via metadata profile", + apiKey: "key-metadata", + attrs: map[string]string{"api_key": "key-metadata"}, + metadata: map[string]any{"fingerprint_profile": "claude-code-cli"}, + wantProfileOAuth: true, + wantSynthesize: true, + wantMCP: true, + wantDiagnostics: true, + }, + { + name: "kimi default token has no fingerprint", + provider: "kimi", + apiKey: "kimi-access-token", + metadata: map[string]any{"access_token": "kimi-access-token"}, + }, + { + name: "kimi with claude-code-cli profile opts in", + provider: "kimi", + apiKey: "kimi-access-token", + metadata: map[string]any{ + "access_token": "kimi-access-token", + "fingerprint_profile": "claude-code-cli", + }, + wantProfileOAuth: true, + wantSynthesize: true, + wantMCP: true, + wantDiagnostics: true, + }, + { + name: "kimi oauth json hyphenated fingerprint-profile opts in", + provider: "kimi", + apiKey: "kimi-access-token", + metadata: map[string]any{ + "access_token": "kimi-access-token", + "fingerprint-profile": "claude-code-cli", + }, + wantProfileOAuth: true, + wantSynthesize: true, + wantMCP: true, + wantDiagnostics: true, + }, + { + name: "unknown profile ignored", + apiKey: "key-unknown", + attrs: map[string]string{"api_key": "key-unknown", "fingerprint_profile": "not-a-profile"}, + wantAuthOAuth: false, + wantProfileOAuth: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + auth := &cliproxyauth.Auth{ + Provider: tt.provider, + Attributes: tt.attrs, + Metadata: tt.metadata, + } + fp := resolveClaudeFingerprintPolicy(tt.cfg, auth, tt.apiKey) + if fp.AuthIsOAuthToken != tt.wantAuthOAuth { + t.Fatalf("AuthIsOAuthToken = %v, want %v", fp.AuthIsOAuthToken, tt.wantAuthOAuth) + } + if fp.ProfileClaudeCodeCLI != tt.wantProfileOAuth { + t.Fatalf("ProfileClaudeCodeCLI = %v, want %v", fp.ProfileClaudeCodeCLI, tt.wantProfileOAuth) + } + if fp.UseOAuthBetas != tt.wantProfileOAuth || fp.ApplyCLIIdentity != tt.wantProfileOAuth { + t.Fatalf("UseOAuthBetas/ApplyCLIIdentity = %v/%v, want %v", fp.UseOAuthBetas, fp.ApplyCLIIdentity, tt.wantProfileOAuth) + } + if fp.SynthesizeIdentity != tt.wantSynthesize { + t.Fatalf("SynthesizeIdentity = %v, want %v", fp.SynthesizeIdentity, tt.wantSynthesize) + } + if fp.MCPAlias != tt.wantMCP { + t.Fatalf("MCPAlias = %v, want %v", fp.MCPAlias, tt.wantMCP) + } + if fp.InjectDiagnostics != tt.wantDiagnostics { + t.Fatalf("InjectDiagnostics = %v, want %v", fp.InjectDiagnostics, tt.wantDiagnostics) + } + if fp.OAuthCancellation != tt.wantCancellation { + t.Fatalf("OAuthCancellation = %v, want %v", fp.OAuthCancellation, tt.wantCancellation) + } + }) + } +} + +func TestClaudeFingerprintProfileFromAuthConcurrentMetadata(t *testing.T) { + auth := &cliproxyauth.Auth{Metadata: map[string]any{ + claudeFingerprintProfileAttr: "claude-code-cli", + }} + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for range 1_000 { + if got := claudeFingerprintProfileFromAuth(auth); got != claudeFingerprintProfileClaudeCodeCLI { + t.Errorf("claudeFingerprintProfileFromAuth() = %q, want %q", got, claudeFingerprintProfileClaudeCodeCLI) + return + } + } + }() + go func() { + defer wg.Done() + for range 1_000 { + claudeauth.StoreMetadataString( + &auth.Metadata, + "account_uuid", + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + ) + } + }() + wg.Wait() +} + +func TestApplyClaudeHeaders_ClaudeCodeCLIProfileUsesOAuthBetasWithoutPretendingToken(t *testing.T) { + t.Parallel() + + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-third-party", + "base_url": "https://gateway.example", + "fingerprint_profile": "claude-code-cli", + }} + req, errReq := http.NewRequest(http.MethodPost, "https://gateway.example/v1/messages?beta=true", nil) + if errReq != nil { + t.Fatalf("NewRequest() error = %v", errReq) + } + if errHeaders := applyClaudeHeaders(req, auth, "key-third-party", false, nil, []byte(`{"model":"claude-sonnet-5"}`), &config.Config{}, nil, false, "11111111-2222-4333-8444-555555555555"); errHeaders != nil { + t.Fatalf("applyClaudeHeaders() error = %v", errHeaders) + } + if got := req.Header.Get("Authorization"); got != "Bearer key-third-party" { + t.Fatalf("Authorization = %q, want API key bearer", got) + } + if got := req.Header.Get("x-api-key"); got != "" { + t.Fatalf("x-api-key = %q, want empty on third-party gateway", got) + } + betas := req.Header.Get("Anthropic-Beta") + if !strings.Contains(betas, "oauth-2025-04-20") { + t.Fatalf("Anthropic-Beta = %q, want oauth beta", betas) + } + if !strings.Contains(betas, "extended-cache-ttl-2025-04-11") { + t.Fatalf("Anthropic-Beta = %q, want extended-cache-ttl", betas) + } + if !strings.Contains(betas, "fallback-credit-2026-06-01") { + t.Fatalf("Anthropic-Beta = %q, want fallback-credit", betas) + } +} + +func TestApplyClaudeHeaders_OfficialAPIKeyDefaultRespectsClient(t *testing.T) { + t.Parallel() + + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-official", + }} + req, errReq := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", nil) + if errReq != nil { + t.Fatalf("NewRequest() error = %v", errReq) + } + incoming := http.Header{} + incoming.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14") + if errHeaders := applyClaudeHeaders(req, auth, "key-official", false, nil, []byte(`{"model":"claude-sonnet-5"}`), &config.Config{}, incoming, false); errHeaders != nil { + t.Fatalf("applyClaudeHeaders() error = %v", errHeaders) + } + if got := req.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want empty on official Anthropic API key", got) + } + if got := req.Header.Get("x-api-key"); got != "key-official" { + t.Fatalf("x-api-key = %q, want API key", got) + } + betas := req.Header.Get("Anthropic-Beta") + if strings.Contains(betas, "oauth-2025-04-20") { + t.Fatalf("Anthropic-Beta = %q, default official API key must not add oauth beta", betas) + } + if strings.Contains(betas, "fallback-credit-2026-06-01") { + t.Fatalf("Anthropic-Beta = %q, default official API key must not add fallback-credit", betas) + } + if !strings.Contains(betas, "interleaved-thinking-2025-05-14") { + t.Fatalf("Anthropic-Beta = %q, want caller interleaved-thinking beta", betas) + } +} + +func TestApplyClaudeHeaders_OfficialAPIKeyClaudeCodeCLIProfileUsesOAuthBetas(t *testing.T) { + t.Parallel() + + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-official-fp", + "fingerprint_profile": "claude-code-cli", + }} + req, errReq := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", nil) + if errReq != nil { + t.Fatalf("NewRequest() error = %v", errReq) + } + if errHeaders := applyClaudeHeaders(req, auth, "key-official-fp", false, nil, []byte(`{"model":"claude-sonnet-5"}`), &config.Config{}, nil, false, "11111111-2222-4333-8444-555555555555"); errHeaders != nil { + t.Fatalf("applyClaudeHeaders() error = %v", errHeaders) + } + if got := req.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want empty on official Anthropic API key", got) + } + if got := req.Header.Get("x-api-key"); got != "key-official-fp" { + t.Fatalf("x-api-key = %q, want API key", got) + } + betas := req.Header.Get("Anthropic-Beta") + if !strings.Contains(betas, "oauth-2025-04-20") { + t.Fatalf("Anthropic-Beta = %q, want oauth beta after fingerprint-profile opt-in", betas) + } + if !strings.Contains(betas, "extended-cache-ttl-2025-04-11") { + t.Fatalf("Anthropic-Beta = %q, want extended-cache-ttl after fingerprint-profile opt-in", betas) + } +} + +func TestClaudeExecutor_ClaudeCodeCLIFingerprintOnThirdPartyGateway(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + upstreamToolName := gjson.GetBytes(seenBody, "tools.0.name").String() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte( + `{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"` + + upstreamToolName + + `","input":{}}],"stop_reason":"tool_use","usage":{"input_tokens":1,"output_tokens":1}}`, + )) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "key-claude-code-cli-fp", + BaseURL: server.URL, + FingerprintProfile: "claude-code-cli", + Cloak: &config.CloakConfig{Mode: "always"}, + }}, + } + executor := NewClaudeExecutor(cfg) + auth := &cliproxyauth.Auth{ + ID: "claude-code-cli-api-key", + Attributes: map[string]string{ + "api_key": "key-claude-code-cli-fp", + "base_url": server.URL, + "fingerprint_profile": "claude-code-cli", + }, + } + payload := []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":[{"type":"text","text":"What can you do?"}]}],"tools":[{"name":"read_file","description":"Read a file","input_schema":{"type":"object"}}]}`) + + response, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + + if got := seenHeaders.Get("Authorization"); got != "Bearer key-claude-code-cli-fp" { + t.Fatalf("Authorization = %q, want API key bearer", got) + } + wantBetas := claudeCodeCLIBetas(payload, nil, true) + if got := seenHeaders.Get("Anthropic-Beta"); got != wantBetas { + t.Fatalf("Anthropic-Beta = %q, want %q", got, wantBetas) + } + + billing := gjson.GetBytes(seenBody, "system.0.text").String() + if !strings.HasPrefix(billing, "x-anthropic-billing-header:") { + t.Fatalf("system.0.text = %q, want billing header", billing) + } + if !strings.Contains(billing, "cch=") { + t.Fatalf("billing = %q, want signed cch", billing) + } + if strings.Contains(billing, "cch=00000") { + t.Fatalf("billing = %q, want non-zero cch signature", billing) + } + if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != claudeCodeCLIIdentity { + t.Fatalf("system.1.text = %q, want CLI identity", got) + } + + userID := gjson.GetBytes(seenBody, "metadata.user_id").String() + if !helps.IsValidUserID(userID) { + t.Fatalf("metadata.user_id = %q, want valid", userID) + } + if got := gjson.Get(userID, "account_uuid").String(); got == "" { + t.Fatal("account_uuid is empty for claude-code-cli fingerprint identity") + } + sessionHeader := seenHeaders.Get("X-Claude-Code-Session-Id") + if sessionHeader == "" { + t.Fatal("missing X-Claude-Code-Session-Id") + } + if got := gjson.Get(userID, "session_id").String(); got != sessionHeader { + t.Fatalf("metadata session_id = %q, header = %q", got, sessionHeader) + } + + upstreamToolName := gjson.GetBytes(seenBody, "tools.0.name").String() + if !strings.HasPrefix(upstreamToolName, "mcp__") { + t.Fatalf("upstream tool name = %q, want OAuth CLI MCP alias", upstreamToolName) + } + if got := gjson.GetBytes(response.Payload, "content.0.name").String(); got != "read_file" { + t.Fatalf("downstream tool name = %q, want restored caller name", got) + } +} + +type claudeFingerprintRoundTripperFunc func(*http.Request) (*http.Response, error) + +func (f claudeFingerprintRoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestClaudeExecutor_OfficialAPIKeyDefaultRespectsClient(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + seenHeaders = req.Header.Clone() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }, nil + }) + ctx := context.WithValue( + context.Background(), + "cliproxy.roundtripper", + http.RoundTripper(transport), + ) + cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{ + APIKey: "key-official-default", + }}} + auth := &cliproxyauth.Auth{ + ID: "official-api-key-default", + Attributes: map[string]string{ + "api_key": "key-official-default", + }, + } + payload := []byte(`{"model":"claude-sonnet-5","max_tokens":64,"messages":[{"role":"user","content":"hello"}],"tools":[{"name":"read_file","input_schema":{"type":"object"}}]}`) + + _, errExecute := NewClaudeExecutor(cfg).Execute(ctx, auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: http.Header{ + "Anthropic-Beta": []string{"caller-private-beta-2099-01-01"}, + "User-Agent": []string{"caller-agent/1.0"}, + }, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if diagnostics := gjson.GetBytes(seenBody, "diagnostics"); diagnostics.Exists() { + t.Fatalf("diagnostics = %s, default official API key must not inject CLI diagnostics", diagnostics.Raw) + } + if got := gjson.GetBytes(seenBody, "tools.0.name").String(); got != "read_file" { + t.Fatalf("tools.0.name = %q, want caller name", got) + } + if strings.Contains(string(seenBody), "x-anthropic-billing-header:") || strings.Contains(string(seenBody), "cch=") { + t.Fatalf("default official API key must not inject billing/CCH: %s", seenBody) + } + userID := gjson.GetBytes(seenBody, "metadata.user_id").String() + if userID != "" && gjson.Get(userID, "account_uuid").String() != "" { + t.Fatalf("metadata.user_id = %q, default official API key must not synthesize CLI account_uuid", userID) + } + betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta") + if strings.Contains(betas, "oauth-2025-04-20") { + t.Fatalf("Anthropic-Beta = %q, default official API key must not add oauth beta", betas) + } + if betas != "caller-private-beta-2099-01-01" { + t.Fatalf("Anthropic-Beta = %q, want exact caller beta", betas) + } + if got := claudeFingerprintHeaderValue(seenHeaders, "User-Agent"); got != "caller-agent/1.0" { + t.Fatalf("User-Agent = %q, want caller value", got) + } + if got := claudeFingerprintHeaderValue(seenHeaders, "x-api-key"); got != "key-official-default" { + t.Fatalf("x-api-key = %q, want API key auth", got) + } +} + +func TestClaudeExecutor_OfficialAPIKeyDefaultPreservesCallerCCH(t *testing.T) { + var seenBody []byte + transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`)), + }, nil + }) + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) + auth := &cliproxyauth.Auth{ID: "official-caller-cch", Attributes: map[string]string{"api_key": "key-official-caller-cch"}} + payload := []byte(`{"model":"claude-sonnet-5","max_tokens":64,"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=caller; cch=abcde;"},{"type":"text","text":"Keep this rule."}],"messages":[{"role":"user","content":"hello"}]}`) + if _, errExecute := NewClaudeExecutor(&config.Config{}).Execute(ctx, auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload}); errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != "x-anthropic-billing-header: cc_version=caller; cch=abcde;" { + t.Fatalf("caller billing/CCH = %q, want byte-preserved text", got) + } + if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != "Keep this rule." { + t.Fatalf("caller system text = %q, want preserved", got) + } +} + +func TestClaudeExecutor_OfficialAPIKeyClaudeCodeCLIFingerprintIncludesDiagnostics(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + seenHeaders = req.Header.Clone() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }, nil + }) + ctx := context.WithValue( + context.Background(), + "cliproxy.roundtripper", + http.RoundTripper(transport), + ) + cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{ + APIKey: "key-official-fp", + FingerprintProfile: "claude-code-cli", + }}} + auth := &cliproxyauth.Auth{ + ID: "official-api-key-fp", + Attributes: map[string]string{ + "api_key": "key-official-fp", + "fingerprint_profile": "claude-code-cli", + }, + } + payload := []byte(`{"model":"claude-sonnet-5","max_tokens":64,"thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"hello"}]}`) + + _, errExecute := NewClaudeExecutor(cfg).Execute(ctx, auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if diagnostics := gjson.GetBytes(seenBody, "diagnostics"); !diagnostics.IsObject() { + t.Fatalf("diagnostics = %s, want object after fingerprint-profile opt-in", diagnostics.Raw) + } + userID := gjson.GetBytes(seenBody, "metadata.user_id").String() + if !helps.IsValidUserID(userID) || gjson.Get(userID, "account_uuid").String() == "" { + t.Fatalf("metadata.user_id = %q, want synthesized CLI identity", userID) + } + betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta") + if !strings.Contains(betas, "oauth-2025-04-20") { + t.Fatalf("Anthropic-Beta = %q, want oauth beta after fingerprint-profile opt-in", betas) + } + if !strings.Contains(betas, claudeCacheDiagnosisBeta) { + t.Fatalf("Anthropic-Beta = %q, want %q", betas, claudeCacheDiagnosisBeta) + } + if got := claudeFingerprintHeaderValue(seenHeaders, "x-api-key"); got != "key-official-fp" { + t.Fatalf("x-api-key = %q, want API key auth", got) + } +} + +func TestClaudeExecutor_ClaudeCodeCLIFingerprintStreamMatchesWirePolicy(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte( + "event: message_start\n" + + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_1\"}}\n\n" + + "event: message_stop\n" + + "data: {\"type\":\"message_stop\"}\n\n", + )) + })) + defer server.Close() + + cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{ + APIKey: "key-claude-code-cli-stream", + BaseURL: server.URL, + FingerprintProfile: "claude-code-cli", + Cloak: &config.CloakConfig{Mode: "always"}, + }}} + auth := &cliproxyauth.Auth{ + ID: "claude-code-cli-stream", + Attributes: map[string]string{ + "api_key": "key-claude-code-cli-stream", + "base_url": server.URL, + "fingerprint_profile": "claude-code-cli", + }, + } + payload := []byte(`{"model":"claude-sonnet-5","max_tokens":64,"thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"hello"}],"tools":[{"name":"read_file","input_schema":{"type":"object"}}]}`) + + result, errStream := NewClaudeExecutor(cfg).ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if errStream != nil { + t.Fatalf("ExecuteStream() error = %v", errStream) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error = %v", chunk.Err) + } + } + if diagnostics := gjson.GetBytes(seenBody, "diagnostics"); diagnostics.Exists() { + t.Fatalf("diagnostics = %s, custom gateway must not inherit official diagnostics", diagnostics.Raw) + } + if got := gjson.GetBytes(seenBody, "tools.0.name").String(); !strings.HasPrefix(got, "mcp__") { + t.Fatalf("stream tool name = %q, want OAuth CLI MCP alias", got) + } + betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta") + if !strings.Contains(betas, "oauth-2025-04-20") { + t.Fatalf("Anthropic-Beta = %q, want oauth beta on custom gateway", betas) + } +} + +func TestClaudeExecutor_ClaudeCodeCLIFingerprintCountTokensKeepsNativeShape(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + seenHeaders = req.Header.Clone() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"input_tokens":12}`)), + }, nil + }) + ctx := context.WithValue( + context.Background(), + "cliproxy.roundtripper", + http.RoundTripper(transport), + ) + cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{ + APIKey: "key-claude-code-cli-count", + FingerprintProfile: "claude-code-cli", + }}} + auth := &cliproxyauth.Auth{ + ID: "claude-code-cli-count", + Attributes: map[string]string{ + "api_key": "key-claude-code-cli-count", + "fingerprint_profile": "claude-code-cli", + }, + } + payload := []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"hello"}],"tools":[{"name":"read_file","input_schema":{"type":"object"}}],"metadata":{"user_id":"remove"},"diagnostics":{"previous_message_id":"remove"}}`) + + _, errCount := NewClaudeExecutor(cfg).countTokensUpstream(ctx, auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if errCount != nil { + t.Fatalf("countTokensUpstream() error = %v", errCount) + } + for _, field := range []string{"system", "metadata", "context_management", "diagnostics"} { + if got := gjson.GetBytes(seenBody, field); got.Exists() { + t.Fatalf("count_tokens %s = %s, want absent", field, got.Raw) + } + } + if strings.Contains(string(seenBody), "cch=") { + t.Fatalf("count_tokens body contains CCH: %s", seenBody) + } + if got := gjson.GetBytes(seenBody, "tools.0.name").String(); !strings.HasPrefix(got, "mcp__") { + t.Fatalf("count_tokens tool name = %q, want OAuth CLI MCP alias after fingerprint-profile opt-in", got) + } + if got, want := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta"), claudeCountTokensBetasForCredential(true); got != want { + t.Fatalf("Anthropic-Beta = %q, want %q", got, want) + } +} + +func TestKimiExecutor_ClaudeMessagesWithAndWithoutClaudeCodeCLIFingerprint(t *testing.T) { + var seenBodies [][]byte + var seenHeaders []http.Header + var mu sync.Mutex + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + mu.Lock() + seenBodies = append(seenBodies, body) + seenHeaders = append(seenHeaders, req.Header.Clone()) + mu.Unlock() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"msg_test","type":"message","role":"assistant","model":"k2.5","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }, nil + })) + + executor := NewKimiExecutor(&config.Config{}) + payload := []byte(`{"model":"kimi-k2.5(max)","max_tokens":32,"messages":[{"role":"user","content":"hello"}]}`) + + // 1. Default Kimi OAuth: no fingerprint injection. + defaultAuth := &cliproxyauth.Auth{ + ID: "kimi-auth-default", + Provider: "kimi", + Attributes: map[string]string{}, + Metadata: map[string]any{"access_token": "test-token"}, + } + _, errDefault := executor.Execute(ctx, defaultAuth, cliproxyexecutor.Request{ + Model: "kimi-k2.5(max)", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + OriginalRequest: payload, + Headers: http.Header{ + "Anthropic-Beta": []string{"kimi-caller-beta"}, + "User-Agent": []string{"kimi-caller/1.0"}, + }, + }) + if errDefault != nil { + t.Fatalf("default Execute() error = %v", errDefault) + } + + // 2. Kimi OAuth with fingerprint_profile: "claude-code-cli": opts into Claude Code CLI fingerprint. + fpAuth := &cliproxyauth.Auth{ + ID: "kimi-auth-profile", + Provider: "kimi", + Attributes: map[string]string{}, + Metadata: map[string]any{ + "access_token": "test-token", + "fingerprint_profile": "claude-code-cli", + }, + } + _, errFP := executor.Execute(ctx, fpAuth, cliproxyexecutor.Request{ + Model: "kimi-k2.5(max)", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + OriginalRequest: payload, + }) + if errFP != nil { + t.Fatalf("fingerprint Execute() error = %v", errFP) + } + + mu.Lock() + defer mu.Unlock() + if len(seenBodies) != 2 { + t.Fatalf("expected 2 captured requests, got %d", len(seenBodies)) + } + + // Default Kimi preserves caller fingerprint headers and strips billing/CCH. + if got := gjson.GetBytes(seenBodies[0], "metadata.user_id").String(); got != "" { + t.Fatalf("default Kimi request should not have metadata.user_id: %q", got) + } + if strings.Contains(string(seenBodies[0]), "cch=") || strings.Contains(string(seenBodies[0]), "x-anthropic-billing-header:") { + t.Fatalf("default Kimi request should not have billing/CCH: %s", seenBodies[0]) + } + if got := claudeFingerprintHeaderValue(seenHeaders[0], "Anthropic-Beta"); got != "kimi-caller-beta" { + t.Fatalf("default Kimi Anthropic-Beta = %q, want caller beta", got) + } + if got := claudeFingerprintHeaderValue(seenHeaders[0], "User-Agent"); got != "kimi-caller/1.0" { + t.Fatalf("default Kimi User-Agent = %q, want caller value", got) + } + + // Opt-in Kimi requests use the complete CLI fingerprint, including signed CCH. + userID := gjson.GetBytes(seenBodies[1], "metadata.user_id").String() + if !helps.IsValidUserID(userID) { + t.Fatalf("opt-in Kimi metadata.user_id = %q, want valid synthesized user_id", userID) + } + if got := gjson.Get(userID, "account_uuid").String(); got == "" { + t.Fatal("opt-in Kimi request should have non-empty synthesized account_uuid") + } + billing := gjson.GetBytes(seenBodies[1], "system.0.text").String() + if !strings.HasPrefix(billing, "x-anthropic-billing-header:") || !strings.Contains(billing, "cch=") { + t.Fatalf("opt-in Kimi request must carry signed Claude billing/CCH attribution: %s", seenBodies[1]) + } + if strings.Contains(billing, "cch=00000") { + t.Fatalf("opt-in Kimi CCH must be finalized: %q", billing) + } + betas := claudeFingerprintHeaderValue(seenHeaders[1], "Anthropic-Beta") + if !strings.Contains(betas, "oauth-2025-04-20") || !strings.Contains(betas, "extended-cache-ttl-2025-04-11") { + t.Fatalf("opt-in Kimi Anthropic-Beta = %q, want full OAuth CLI beta set", betas) + } +} + +func TestKimiExecutor_ClaudeCodeCLIProfileStreamSignsCCHAndCountTokensKeepsNativeShape(t *testing.T) { + for _, test := range []struct { + name string + run func(context.Context, *KimiExecutor, *cliproxyauth.Auth, []byte) error + }{ + {name: "stream", run: func(ctx context.Context, executor *KimiExecutor, auth *cliproxyauth.Auth, payload []byte) error { + result, errStream := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "kimi-k2.5(max)", Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload}) + if errStream != nil { + return errStream + } + for chunk := range result.Chunks { + if chunk.Err != nil { + return chunk.Err + } + } + return nil + }}, + {name: "count tokens", run: func(ctx context.Context, executor *KimiExecutor, auth *cliproxyauth.Auth, payload []byte) error { + _, errCount := executor.CountTokens(ctx, auth, cliproxyexecutor.Request{Model: "kimi-k2.5(max)", Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload}) + return errCount + }}, + } { + t.Run(test.name, func(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + seenHeaders = req.Header.Clone() + if strings.Contains(req.URL.Path, "count_tokens") { + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"input_tokens":7}`))}, nil + } + stream := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_test\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"k2.5\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(stream))}, nil + })) + auth := &cliproxyauth.Auth{ + ID: "kimi-profile-" + test.name, + Provider: "kimi", + Attributes: map[string]string{}, + Metadata: map[string]any{ + "access_token": "test-token", + "fingerprint_profile": "claude-code-cli", + }, + } + payload := []byte(`{"model":"kimi-k2.5(max)","max_tokens":32,"messages":[{"role":"user","content":"hello"}]}`) + if errRun := test.run(ctx, NewKimiExecutor(&config.Config{}), auth, payload); errRun != nil { + t.Fatalf("request error = %v", errRun) + } + betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta") + if test.name == "count tokens" { + for _, field := range []string{"system", "metadata", "context_management", "diagnostics"} { + if got := gjson.GetBytes(seenBody, field); got.Exists() { + t.Fatalf("count_tokens %s = %s, want absent", field, got.Raw) + } + } + if strings.Contains(string(seenBody), "cch=") || strings.Contains(string(seenBody), "currentDate") { + t.Fatalf("count_tokens must keep the native shape without CCH/currentDate: %s", seenBody) + } + if want := claudeCountTokensBetasForCredential(true); betas != want { + t.Fatalf("Anthropic-Beta = %q, want count_tokens CLI set %q", betas, want) + } + return + } + billing := gjson.GetBytes(seenBody, "system.0.text").String() + if !strings.HasPrefix(billing, "x-anthropic-billing-header:") || !strings.Contains(billing, "cch=") { + t.Fatalf("upstream body is missing opt-in billing/CCH: %s", seenBody) + } + if strings.Contains(billing, "cch=00000") { + t.Fatalf("opt-in Kimi CCH must be finalized: %q", billing) + } + if !strings.Contains(betas, "oauth-2025-04-20") || !strings.Contains(betas, "extended-cache-ttl-2025-04-11") { + t.Fatalf("Anthropic-Beta = %q, want full OAuth CLI beta set", betas) + } + }) + } +} + +// The custom-header escape hatch must have the same scope in caller-owned mode as +// it has on the CLI path: a non-streaming third-party gateway keeps operator +// overrides, while api.anthropic.com and streaming requests claw them back. +func TestApplyClaudeHeaders_CallerOwnedScopesOperatorHeaderOverrides(t *testing.T) { + newAuth := func() *cliproxyauth.Auth { + return &cliproxyauth.Auth{ + ID: "caller-owned-operator-headers", + Attributes: map[string]string{ + "api_key": "key-operator-headers", + "header:Accept": "application/vnd.gateway+json", + "header:Accept-Encoding": "identity", + }, + } + } + body := []byte(`{"model":"claude-opus-4-6"}`) + // The caller deliberately sends neither header; only the operator configured them. + incoming := http.Header{} + + // Non-streaming custom gateway: the documented escape hatch wins. This is the + // case the caller-owned branch used to break by resetting on "caller sent none" + // instead of on upstream/stream scope. + gatewayReq := httptest.NewRequest(http.MethodPost, "https://gateway.example/v1/messages", nil) + if err := applyClaudeHeaders(gatewayReq, newAuth(), "key-operator-headers", false, nil, body, nil, incoming, false); err != nil { + t.Fatalf("applyClaudeHeaders(gateway) error = %v", err) + } + if got := gatewayReq.Header.Get("Accept"); got != "application/vnd.gateway+json" { + t.Fatalf("gateway Accept = %q, want the operator override preserved", got) + } + if got := gatewayReq.Header.Get("Accept-Encoding"); got != "identity" { + t.Fatalf("gateway Accept-Encoding = %q, want the operator override preserved", got) + } + + // Streaming custom gateway: transport negotiation is restored so an Accept + // override cannot silently disable SSE. + streamReq := httptest.NewRequest(http.MethodPost, "https://gateway.example/v1/messages", nil) + if err := applyClaudeHeaders(streamReq, newAuth(), "key-operator-headers", true, nil, body, nil, incoming, false); err != nil { + t.Fatalf("applyClaudeHeaders(stream) error = %v", err) + } + if got := streamReq.Header.Get("Accept"); got != "text/event-stream" { + t.Fatalf("stream Accept = %q, want event-stream negotiation restored", got) + } + + // api.anthropic.com: first-party identity is never operator-overridable. + directReq := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(directReq, newAuth(), "key-operator-headers", false, nil, body, nil, incoming, false); err != nil { + t.Fatalf("applyClaudeHeaders(direct) error = %v", err) + } + if got := directReq.Header.Get("Accept-Encoding"); got != "gzip, deflate, br, zstd" { + t.Fatalf("direct Accept-Encoding = %q, want the operator override clawed back", got) + } +} + +// Restoring transport negotiation must restore the caller's own choice, not CPA's +// default: this mode is caller-owned. +func TestApplyClaudeHeaders_CallerOwnedRestoreKeepsCallerAccept(t *testing.T) { + auth := &cliproxyauth.Auth{ + ID: "caller-owned-restore", + Attributes: map[string]string{ + "api_key": "key-restore", + "header:Accept": "application/vnd.operator+json", + }, + } + incoming := http.Header{"Accept": {"application/vnd.caller+json"}} + req := newClaudeHeaderTestRequest(t, incoming) + if err := applyClaudeHeaders(req, auth, "key-restore", false, nil, + []byte(`{"model":"claude-opus-4-6"}`), nil, incoming, false); err != nil { + t.Fatalf("applyClaudeHeaders() error = %v", err) + } + if got := req.Header.Get("Accept"); got != "application/vnd.caller+json" { + t.Fatalf("Accept = %q, want the caller value restored rather than a CPA default", got) + } +} + +// A caller that sends no User-Agent must not reach the upstream as Go's transport +// default, which reads as a bot signature. Uses a real socket because that default +// is added by the transport, not by the header builder. +func TestClaudeExecutor_CallerOwnedNeverSendsGoTransportUserAgent(t *testing.T) { + var seen http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","content":[],"usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer server.Close() + + auth := &cliproxyauth.Auth{ID: "caller-owned-ua", Attributes: map[string]string{ + "api_key": "key-caller-owned-ua", + "base_url": server.URL, + }} + if _, err := NewClaudeExecutor(&config.Config{}).Execute(context.Background(), auth, + cliproxyexecutor.Request{Model: "claude-opus-4-6", Payload: []byte(`{"model":"claude-opus-4-6","messages":[{"role":"user","content":"hi"}]}`)}, + cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); err != nil { + t.Fatalf("Execute() error = %v", err) + } + got := seen.Get("User-Agent") + if strings.HasPrefix(got, "Go-http-client") { + t.Fatalf("User-Agent = %q, want CPA's own identity rather than Go's transport default", got) + } + if !strings.HasPrefix(got, "CLIProxyAPI/") { + t.Fatalf("User-Agent = %q, want a CLIProxyAPI/ fallback", got) + } +} + +// A caller that does send a User-Agent keeps it verbatim. +func TestClaudeExecutor_CallerOwnedForwardsCallerUserAgent(t *testing.T) { + var seen http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","content":[],"usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer server.Close() + + auth := &cliproxyauth.Auth{ID: "caller-owned-ua-keep", Attributes: map[string]string{ + "api_key": "key-caller-owned-ua-keep", + "base_url": server.URL, + }} + if _, err := NewClaudeExecutor(&config.Config{}).Execute(context.Background(), auth, + cliproxyexecutor.Request{Model: "claude-opus-4-6", Payload: []byte(`{"model":"claude-opus-4-6","messages":[{"role":"user","content":"hi"}]}`)}, + cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: http.Header{"User-Agent": {"my-sdk/1.2.3"}}, + }); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if got := seen.Get("User-Agent"); got != "my-sdk/1.2.3" { + t.Fatalf("User-Agent = %q, want the caller value forwarded verbatim", got) + } +} + +// Without a profile opt-in the caller owns its count_tokens body. A caller that +// deliberately sends context_management expects the returned count to reflect it, +// so CPA must not quietly reshape the request into the CLI contract. +func TestKimiExecutor_DefaultCountTokensRespectsCallerBody(t *testing.T) { + var seenBody []byte + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"input_tokens":7}`)), + }, nil + })) + auth := &cliproxyauth.Auth{ + ID: "kimi-default-count-tokens", + Provider: "kimi", + Attributes: map[string]string{}, + Metadata: map[string]any{"access_token": "test-token"}, + } + payload := []byte(`{"model":"kimi-k2.5(max)","system":"caller system","messages":[{"role":"user","content":"hello"}],"metadata":{"user_id":"caller-user"},"context_management":{"edits":[]}}`) + if _, errCount := NewKimiExecutor(&config.Config{}).CountTokens(ctx, auth, + cliproxyexecutor.Request{Model: "kimi-k2.5(max)", Payload: payload}, + cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload}); errCount != nil { + t.Fatalf("CountTokens() error = %v", errCount) + } + if len(seenBody) == 0 { + t.Fatal("expected an upstream count_tokens request") + } + // Positive pins first: assert the request really arrived for this model, so the + // preservation assertions below cannot pass vacuously. + if got := gjson.GetBytes(seenBody, "model").String(); got == "" { + t.Fatalf("upstream model is empty: %s", seenBody) + } + if got := gjson.GetBytes(seenBody, "messages.#").Int(); got != 1 { + t.Fatalf("upstream messages length = %d, want 1: %s", got, seenBody) + } + for _, field := range []string{"system", "metadata", "context_management"} { + if !gjson.GetBytes(seenBody, field).Exists() { + t.Fatalf("default count_tokens dropped caller-owned %q: %s", field, seenBody) + } + } + if got := gjson.GetBytes(seenBody, "metadata.user_id").String(); got != "caller-user" { + t.Fatalf("metadata.user_id = %q, want the caller value preserved", got) + } + // Default mode must not add the CLI billing/CCH attribution either. + if strings.Contains(string(seenBody), "x-anthropic-billing-header:") || strings.Contains(string(seenBody), "cch=") { + t.Fatalf("default count_tokens must not inject billing/CCH: %s", seenBody) + } +} + +// api.anthropic.com rejects metadata/context_management/diagnostics on +// count_tokens regardless of profile, so upstream compatibility still strips them +// for an unprofiled first-party API key. +func TestClaudeExecutor_DefaultCountTokensStillStripsAnthropicRejectedFields(t *testing.T) { + var seenBody []byte + transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"input_tokens":11}`)), + Request: req, + }, nil + }) + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) + auth := &cliproxyauth.Auth{ID: "anthropic-default-count", Attributes: map[string]string{"api_key": "key-default-count"}} + payload := []byte(`{"model":"claude-opus-4-6","messages":[{"role":"user","content":"hello"}],"metadata":{"user_id":"caller-user"},"context_management":{"edits":[]},"diagnostics":{"previous_message_id":null}}`) + if _, errCount := NewClaudeExecutor(&config.Config{}).CountTokens(ctx, auth, + cliproxyexecutor.Request{Model: "claude-opus-4-6", Payload: payload}, + cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload}); errCount != nil { + t.Fatalf("CountTokens() error = %v", errCount) + } + if len(seenBody) == 0 { + t.Fatal("expected an upstream count_tokens request") + } + if got := gjson.GetBytes(seenBody, "messages.#").Int(); got != 1 { + t.Fatalf("upstream messages length = %d, want 1: %s", got, seenBody) + } + for _, field := range []string{"metadata", "context_management", "diagnostics"} { + if got := gjson.GetBytes(seenBody, field); got.Exists() { + t.Fatalf("api.anthropic.com count_tokens %s = %s, want stripped", field, got.Raw) + } + } +} + +func TestKimiExecutor_ClaudeCodeCLIIdentitySurvivesAccessTokenRotation(t *testing.T) { + var seenBodies [][]byte + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + seenBodies = append(seenBodies, body) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"msg_test","type":"message","role":"assistant","model":"k2.5","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)), + }, nil + })) + payload := []byte(`{"model":"kimi-k2.5(max)","max_tokens":32,"messages":[{"role":"user","content":"hello"}]}`) + for _, token := range []string{"token-before-refresh", "token-after-refresh"} { + auth := &cliproxyauth.Auth{ + ID: "stable-kimi-auth", + Provider: "kimi", + Attributes: map[string]string{}, + Metadata: map[string]any{ + "access_token": token, + "fingerprint_profile": "claude-code-cli", + }, + } + if _, errExecute := NewKimiExecutor(&config.Config{}).Execute(ctx, auth, cliproxyexecutor.Request{ + Model: "kimi-k2.5(max)", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload}); errExecute != nil { + t.Fatalf("Execute(%q) error = %v", token, errExecute) + } + } + if len(seenBodies) != 2 { + t.Fatalf("captured %d requests, want 2", len(seenBodies)) + } + firstUserID := gjson.GetBytes(seenBodies[0], "metadata.user_id").String() + secondUserID := gjson.GetBytes(seenBodies[1], "metadata.user_id").String() + for _, field := range []string{"account_uuid", "device_id"} { + if first, second := gjson.Get(firstUserID, field).String(), gjson.Get(secondUserID, field).String(); first == "" || first != second { + t.Fatalf("%s changed across access token refresh: %q vs %q", field, first, second) + } + } +} + +func TestStripDefaultKimiClaudeCodeAttributionRespectsProfile(t *testing.T) { + t.Parallel() + + body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220; cch=abcde;"},{"type":"text","text":"Keep this rule."}],"messages":[]}`) + kimiAuth := &cliproxyauth.Auth{Provider: "kimi"} + if got := stripDefaultKimiClaudeCodeAttribution(kimiAuth, "https://api.kimi.com/coding/v1/messages", false, body); strings.Contains(string(got), "cch=") || !strings.Contains(string(got), "Keep this rule.") { + t.Fatalf("default Kimi stripping produced %s", got) + } + if got := stripDefaultKimiClaudeCodeAttribution(kimiAuth, "https://api.kimi.com/coding/v1/messages", true, body); !strings.Contains(string(got), "cch=abcde") { + t.Fatalf("profiled Kimi request lost caller CCH: %s", got) + } +} + +func TestKimiExecutor_StripsCallerClaudeCodeCCH(t *testing.T) { + var seenBody []byte + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"msg_test","type":"message","role":"assistant","model":"k2.5","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }, nil + })) + payload := []byte(`{"model":"kimi-k2.5(max)","max_tokens":32,"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220; cch=abcde;"},{"type":"text","text":"Keep this rule."}],"messages":[{"role":"user","content":"hello"}]}`) + _, errExecute := NewKimiExecutor(&config.Config{}).Execute(ctx, &cliproxyauth.Auth{ + Provider: "kimi", + Attributes: map[string]string{}, + Metadata: map[string]any{"access_token": "test-token"}, + }, cliproxyexecutor.Request{ + Model: "kimi-k2.5(max)", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + OriginalRequest: payload, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if strings.Contains(string(seenBody), "cch=") || strings.Contains(string(seenBody), "x-anthropic-billing-header:") { + t.Fatalf("Kimi upstream body still has Claude CCH attribution: %s", seenBody) + } + if !strings.Contains(string(seenBody), "Keep this rule.") { + t.Fatalf("Kimi upstream body dropped caller system text: %s", seenBody) + } +} + +func claudeFingerprintHeaderValue(headers http.Header, name string) string { + for key, values := range headers { + if strings.EqualFold(key, name) { + return strings.Join(values, ",") + } + } + return "" +} diff --git a/internal/runtime/executor/claude_mid_system_model_test.go b/internal/runtime/executor/claude_mid_system_model_test.go --- a/internal/runtime/executor/claude_mid_system_model_test.go +++ b/internal/runtime/executor/claude_mid_system_model_test.go @@ -89,7 +89,7 @@ func midSystemAuth() *cliproxyauth.Auth { // No base_url, so the executor keeps Anthropic's first-party origin. - return &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-123"}} + return &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-123", "cloak_mode": "always"}} } func midSystemConfig() *config.Config { diff --git a/internal/runtime/executor/claude_signing.go b/internal/runtime/executor/claude_signing.go --- a/internal/runtime/executor/claude_signing.go +++ b/internal/runtime/executor/claude_signing.go @@ -145,30 +145,30 @@ return updated, nil } -// claudeCCHSigningEnabled applies CPA's CCH policy. Every Claude OAuth -// request is signed, while non-OAuth requests require a supported upstream. -func claudeCCHSigningEnabled(apiKey string, kind claudeCCHUpstreamKind, endpoint string) bool { - if isClaudeOAuthToken(apiKey) { - return true - } - if kind == claudeCCHUpstreamVertex { - return true - } - if kind != claudeCCHUpstreamAnthropic { - return false - } - +func isKimiAPIEndpoint(endpoint string) bool { parsed, err := url.Parse(strings.TrimSpace(endpoint)) - if err != nil || parsed.User != nil || !strings.EqualFold(parsed.Scheme, "https") { + if err != nil { return false } - if !strings.EqualFold(parsed.Hostname(), "api.anthropic.com") { - return false + return strings.EqualFold(parsed.Hostname(), "api.kimi.com") +} + +func isKimiMessagesUpstream(auth *cliproxyauth.Auth, endpoint string) bool { + if auth != nil && strings.EqualFold(strings.TrimSpace(auth.Provider), "kimi") { + return true } - if port := parsed.Port(); port != "" && port != "443" { - return false + return isKimiAPIEndpoint(endpoint) +} + +// claudeCCHSigningEnabled applies CPA's CCH policy. Real Claude OAuth tokens +// and explicit claude-code-cli fingerprint profiles sign. Default API-key and +// delegated-provider requests preserve the caller body instead. Vertex keeps +// its existing provider-native signing behavior. +func claudeCCHSigningEnabled(apiKey string, kind claudeCCHUpstreamKind, cliFingerprint bool) bool { + if isClaudeOAuthToken(apiKey) || cliFingerprint { + return true } - return strings.Contains(parsed.EscapedPath(), "/v1/messages") + return kind == claudeCCHUpstreamVertex } // signAnthropicMessagesBody reproduces Claude Code 2.1.220's final-body CCH. diff --git a/internal/runtime/executor/claude_signing_test.go b/internal/runtime/executor/claude_signing_test.go --- a/internal/runtime/executor/claude_signing_test.go +++ b/internal/runtime/executor/claude_signing_test.go @@ -128,31 +128,26 @@ t.Parallel() tests := []struct { - name string - apiKey string - kind claudeCCHUpstreamKind - endpoint string - want bool + name string + apiKey string + kind claudeCCHUpstreamKind + cliFingerprint bool + want bool }{ - {name: "official messages API key", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, endpoint: "https://api.anthropic.com/v1/messages?beta=true", want: true}, - {name: "official count tokens API key", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, endpoint: "https://api.anthropic.com/v1/messages/count_tokens?beta=true", want: true}, - {name: "official explicit default port", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, endpoint: "https://api.anthropic.com:443/v1/messages", want: true}, - {name: "custom gateway API key", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, endpoint: "https://gateway.example/v1/messages", want: false}, - {name: "loopback API key", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, endpoint: "http://127.0.0.1:8317/v1/messages", want: false}, - {name: "custom gateway OAuth", apiKey: "sk-ant-oat-custom", kind: claudeCCHUpstreamAnthropic, endpoint: "https://gateway.example/v1/messages", want: true}, - {name: "loopback OAuth", apiKey: "sk-ant-oat-loopback", kind: claudeCCHUpstreamAnthropic, endpoint: "http://127.0.0.1:8317/v1/messages", want: true}, - {name: "other provider OAuth", apiKey: "sk-ant-oat-other", kind: claudeCCHUpstreamOther, endpoint: "https://gateway.example/anything", want: true}, - {name: "lookalike host", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, endpoint: "https://api.anthropic.com.example/v1/messages", want: false}, - {name: "wrong port", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, endpoint: "https://api.anthropic.com:8443/v1/messages", want: false}, - {name: "wrong endpoint", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, endpoint: "https://api.anthropic.com/v1/complete", want: false}, - {name: "vertex provider API key", apiKey: "key-123", kind: claudeCCHUpstreamVertex, endpoint: "https://us-east5-aiplatform.googleapis.com/v1/projects/p/locations/l/publishers/anthropic/models/m:streamRawPredict", want: true}, - {name: "other provider API key", apiKey: "key-123", kind: claudeCCHUpstreamOther, endpoint: "https://api.anthropic.com/v1/messages", want: false}, + {name: "official API key default", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, want: false}, + {name: "official API key opt-in", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, cliFingerprint: true, want: true}, + {name: "Kimi API key default", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, want: false}, + {name: "Kimi API key opt-in", apiKey: "key-123", kind: claudeCCHUpstreamAnthropic, cliFingerprint: true, want: true}, + {name: "Claude OAuth", apiKey: "sk-ant-oat-custom", kind: claudeCCHUpstreamAnthropic, want: true}, + {name: "other provider Claude OAuth", apiKey: "sk-ant-oat-other", kind: claudeCCHUpstreamOther, want: true}, + {name: "Vertex provider API key", apiKey: "key-123", kind: claudeCCHUpstreamVertex, want: true}, + {name: "other provider API key", apiKey: "key-123", kind: claudeCCHUpstreamOther, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - if got := claudeCCHSigningEnabled(tt.apiKey, tt.kind, tt.endpoint); got != tt.want { + if got := claudeCCHSigningEnabled(tt.apiKey, tt.kind, tt.cliFingerprint); got != tt.want { t.Fatalf("claudeCCHSigningEnabled() = %t, want %t", got, tt.want) } }) diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -50,6 +50,13 @@ // Identifier returns the executor identifier. func (e *KimiExecutor) Identifier() string { return "kimi" } +func stripDefaultKimiClaudeCodeAttribution(auth *cliproxyauth.Auth, endpoint string, cliFingerprint bool, body []byte) []byte { + if cliFingerprint || !isKimiMessagesUpstream(auth, endpoint) { + return body + } + return util.StripClaudeCodeAttributionSystem(body) +} + // RequestToFormat reports the upstream request format used after auth selection. func (e *KimiExecutor) RequestToFormat(_ cliproxyexecutor.Request, opts cliproxyexecutor.Options) sdktranslator.Format { if opts.SourceFormat == sdktranslator.FormatClaude { diff --git a/internal/runtime/executor/kimi_executor_test.go b/internal/runtime/executor/kimi_executor_test.go --- a/internal/runtime/executor/kimi_executor_test.go +++ b/internal/runtime/executor/kimi_executor_test.go @@ -298,10 +298,8 @@ t.Fatalf("upstream URL = %q, want Kimi messages endpoint", got) } upstreamBetas := upstreamRequest.Header.Get("Anthropic-Beta") - for _, beta := range []string{"client-beta-one", "client-beta-two", "oauth-2025-04-20", "interleaved-thinking-2025-05-14"} { - if !strings.Contains(upstreamBetas, beta) { - t.Fatalf("Anthropic-Beta = %q, want %q", upstreamBetas, beta) - } + if upstreamBetas != "client-beta-one,client-beta-two" { + t.Fatalf("Anthropic-Beta = %q, want caller beta values only", upstreamBetas) } rawAPIRequest, existsRequest := ginCtx.Get("API_REQUEST") diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -265,6 +265,9 @@ if o.RebuildMidSystemMessage != n.RebuildMidSystemMessage { changes = append(changes, fmt.Sprintf("claude[%d].rebuild-mid-system-message: %t -> %t", i, o.RebuildMidSystemMessage, n.RebuildMidSystemMessage)) } + if strings.TrimSpace(o.FingerprintProfile) != strings.TrimSpace(n.FingerprintProfile) { + changes = append(changes, fmt.Sprintf("claude[%d].fingerprint-profile: %s -> %s", i, strings.TrimSpace(o.FingerprintProfile), strings.TrimSpace(n.FingerprintProfile))) + } changes = appendOptionalIntChange(changes, fmt.Sprintf("claude[%d].request-retry", i), o.RequestRetry, n.RequestRetry) if o.Cloak != nil && n.Cloak != nil { if strings.TrimSpace(o.Cloak.Mode) != strings.TrimSpace(n.Cloak.Mode) { diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -166,6 +166,9 @@ if ck.RebuildMidSystemMessage { attrs["rebuild_mid_system_message"] = "true" } + if profile := strings.ToLower(strings.TrimSpace(ck.FingerprintProfile)); profile != "" { + attrs["fingerprint_profile"] = profile + } if hash := diff.ComputeClaudeModelsHash(ck.Models); hash != "" { attrs["models_hash"] = hash } diff --git a/internal/watcher/synthesizer/config_test.go b/internal/watcher/synthesizer/config_test.go --- a/internal/watcher/synthesizer/config_test.go +++ b/internal/watcher/synthesizer/config_test.go @@ -229,6 +229,7 @@ BaseURL: "https://api.anthropic.com", DisableCooling: boolPointer(true), RebuildMidSystemMessage: true, + FingerprintProfile: "claude-code-cli", Models: []config.ClaudeModel{ {Name: "claude-3-opus"}, {Name: "claude-3-sonnet"}, @@ -268,6 +269,9 @@ } if got := auths[0].Attributes["rebuild_mid_system_message"]; got != "true" { t.Errorf("expected rebuild_mid_system_message=true, got %s", got) + } + if got := auths[0].Attributes["fingerprint_profile"]; got != "claude-code-cli" { + t.Errorf("expected fingerprint_profile=claude-code-cli, got %s", got) } if v, ok := auths[0].Metadata["disable_cooling"].(bool); !ok || !v { t.Errorf("expected disable_cooling=true, got %v", auths[0].Metadata["disable_cooling"]) diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -133,6 +133,7 @@ coreauth.SetOAuthModelAliasesAttribute(auth, perAccountModelAliases) ApplyAuthExcludedModelsMeta(auth, cfg, perAccountExcluded, "oauth") coreauth.ApplyCustomHeadersFromMetadata(auth) + applyFingerprintProfileAttribute(auth, metadata) } return auths, nil } @@ -222,6 +223,7 @@ coreauth.ApplyCustomHeadersFromMetadata(a) coreauth.SetOAuthModelAliasesAttribute(a, perAccountModelAliases) ApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, "oauth") + applyFingerprintProfileAttribute(a, metadata) // For codex auth files, extract plan_type from the JWT id_token. if provider == "codex" { if idTokenRaw, ok := metadata["id_token"].(string); ok && strings.TrimSpace(idTokenRaw) != "" { diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -132,6 +132,45 @@ } } +func TestFileSynthesizer_Synthesize_KimiFingerprintProfile(t *testing.T) { + tempDir := t.TempDir() + authData := map[string]any{ + "type": "kimi", + "access_token": "kimi-access-token", + "refresh_token": "kimi-refresh-token", + "fingerprint-profile": "claude-code-cli", + } + data, errMarshal := json.Marshal(authData) + if errMarshal != nil { + t.Fatalf("marshal kimi auth: %v", errMarshal) + } + if err := os.WriteFile(filepath.Join(tempDir, "kimi-auth.json"), data, 0644); err != nil { + t.Fatalf("failed to write kimi auth file: %v", err) + } + + auths, err := NewFileSynthesizer().Synthesize(&SynthesisContext{ + Config: &config.Config{}, + AuthDir: tempDir, + Now: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + IDGenerator: NewStableIDGenerator(), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + if auths[0].Provider != "kimi" { + t.Fatalf("provider = %q, want kimi", auths[0].Provider) + } + if got := auths[0].Attributes["fingerprint_profile"]; got != "claude-code-cli" { + t.Fatalf("attributes fingerprint_profile = %q, want claude-code-cli", got) + } + if got, _ := auths[0].Metadata["fingerprint-profile"].(string); got != "claude-code-cli" { + t.Fatalf("metadata fingerprint-profile = %q, want claude-code-cli", got) + } +} + func TestFileSynthesizer_Synthesize_IgnoresGeminiProviderFile(t *testing.T) { tempDir := t.TempDir() diff --git a/internal/watcher/synthesizer/helpers.go b/internal/watcher/synthesizer/helpers.go --- a/internal/watcher/synthesizer/helpers.go +++ b/internal/watcher/synthesizer/helpers.go @@ -122,6 +122,36 @@ // addConfigHeadersToAttrs adds header configuration to auth attributes. // Headers are prefixed with "header:" in the attributes map. +func fingerprintProfileFromMetadata(metadata map[string]any) string { + if metadata == nil { + return "" + } + for _, key := range []string{"fingerprint_profile", "fingerprint-profile"} { + raw, _ := metadata[key].(string) + if profile := strings.ToLower(strings.TrimSpace(raw)); profile != "" { + return profile + } + } + return "" +} + +// applyFingerprintProfileAttribute copies fingerprint-profile from an OAuth JSON +// file (Kimi, Claude, etc.) onto auth attributes so Claude Messages opt-in works +// the same way as claude-api-key config. +func applyFingerprintProfileAttribute(auth *coreauth.Auth, metadata map[string]any) { + if auth == nil { + return + } + profile := fingerprintProfileFromMetadata(metadata) + if profile == "" { + return + } + if auth.Attributes == nil { + auth.Attributes = make(map[string]string) + } + auth.Attributes["fingerprint_profile"] = profile +} + func addConfigHeadersToAttrs(headers map[string]string, attrs map[string]string) { if len(headers) == 0 || attrs == nil { return diff --git a/internal/api/handlers/management/config_claude_key_test.go b/internal/api/handlers/management/config_claude_key_test.go new file mode 100644 --- /dev/null +++ b/internal/api/handlers/management/config_claude_key_test.go @@ -0,0 +1,50 @@ +package management + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestPatchClaudeKeyFingerprintProfile(t *testing.T) { + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{ + {APIKey: "test-claude-key"}, + }, + } + h := &Handler{cfg: cfg, configFilePath: writeTestConfigFile(t)} + + // Patch fingerprint-profile to claude-code-cli + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPatch, "/v0/management/claude-api-key", + strings.NewReader(`{"index":0,"value":{"fingerprint-profile":"claude-code-cli"}}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + h.PatchClaudeKey(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got := cfg.ClaudeKey[0].FingerprintProfile; got != "claude-code-cli" { + t.Fatalf("FingerprintProfile = %q, want %q", got, "claude-code-cli") + } + + // Patch fingerprint-profile back to empty + rec = httptest.NewRecorder() + ctx, _ = gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPatch, "/v0/management/claude-api-key", + strings.NewReader(`{"index":0,"value":{"fingerprint-profile":""}}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + h.PatchClaudeKey(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got := cfg.ClaudeKey[0].FingerprintProfile; got != "" { + t.Fatalf("FingerprintProfile = %q, want empty", got) + } +} diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -551,6 +551,7 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) { type claudeKeyPatch struct { APIKey *string `json:"api-key"` + FingerprintProfile *string `json:"fingerprint-profile"` Weight json.RawMessage `json:"weight"` Prefix *string `json:"prefix"` BaseURL *string `json:"base-url"` @@ -596,6 +597,9 @@ entry := h.cfg.ClaudeKey[targetIndex] if body.Value.APIKey != nil { entry.APIKey = strings.TrimSpace(*body.Value.APIKey) + } + if body.Value.FingerprintProfile != nil { + entry.FingerprintProfile = strings.TrimSpace(*body.Value.FingerprintProfile) } if len(body.Value.Weight) > 0 { weight, errWeight := parseCredentialWeightPatch(body.Value.Weight) @@ -1682,6 +1686,7 @@ return } entry.APIKey = strings.TrimSpace(entry.APIKey) + entry.FingerprintProfile = strings.TrimSpace(entry.FingerprintProfile) entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.Headers = config.NormalizeHeaders(entry.Headers) diff --git a/internal/runtime/executor/helps/claude_cli_identity_seed.go b/internal/runtime/executor/helps/claude_cli_identity_seed.go new file mode 100644 --- /dev/null +++ b/internal/runtime/executor/helps/claude_cli_identity_seed.go @@ -0,0 +1,92 @@ +package helps + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "github.com/google/uuid" + claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" +) + +// Stable identity seeds for fingerprint-profile=claude-code-cli on non-OAuth credentials. +// Real OAuth credentials keep their stored account/device pool; this only fills gaps +// so ApplyClaudeCredentialMetadata can run as the single identity algorithm. +var claudeCLIIdentityNamespace = uuid.MustParse("6ba7b812-9dad-11d1-80b4-00c04fd430c8") + +func stableClaudeCLIDeviceID(seed string) string { + sum := sha256.Sum256([]byte("cpa-claude-code-cli-device|" + seed)) + return hex.EncodeToString(sum[:]) +} + +func stableClaudeCLIAccountUUID(seed string) string { + return uuid.NewSHA1(claudeCLIIdentityNamespace, []byte("cpa-claude-code-cli-account|"+seed)).String() +} + +// ClaudeCLIAuthIdentitySeed returns a stable credential identity that does not +// rotate with delegated-provider access tokens. +func ClaudeCLIAuthIdentitySeed(auth *cliproxyauth.Auth) string { + if auth != nil { + if id := strings.TrimSpace(auth.ID); id != "" { + return "auth-id|" + id + } + if index := strings.TrimSpace(auth.Index); index != "" { + return "auth-index|" + index + } + if fileName := strings.TrimSpace(auth.FileName); fileName != "" { + return "auth-file|" + fileName + } + } + return "" +} + +// PrepareClaudeCLIFingerprintAuth returns the auth object that should receive +// ApplyClaudeCredentialMetadata. Synthesized API-key / delegated-provider +// identity is written to a clone so the shared credential metadata map is not +// mutated on the request path. +func PrepareClaudeCLIFingerprintAuth(auth *cliproxyauth.Auth, seed string, synthesizeMissing bool) (*cliproxyauth.Auth, error) { + if auth == nil { + return nil, fmt.Errorf("auth is nil") + } + if !synthesizeMissing { + return auth, nil + } + local := auth.Clone() + if err := EnsureClaudeCLIFingerprintIdentity(local, seed, true); err != nil { + return nil, err + } + return local, nil +} + +// EnsureClaudeCLIFingerprintIdentity prepares auth.Metadata so the shared +// ApplyClaudeCredentialMetadata path can run. +// +// When synthesizeMissing is false (real OAuth), this is a no-op: missing account +// or device data must surface as credential errors. +// When synthesizeMissing is true (fingerprint-profile=claude-code-cli on API keys), +// missing account_uuid / device pool are filled with stable values derived from seed. +// Callers that hold a shared Auth must use PrepareClaudeCLIFingerprintAuth instead. +func EnsureClaudeCLIFingerprintIdentity(auth *cliproxyauth.Auth, seed string, synthesizeMissing bool) error { + if auth == nil { + return fmt.Errorf("auth is nil") + } + if !synthesizeMissing { + return nil + } + seed = strings.TrimSpace(seed) + if seed == "" { + seed = "anonymous" + } + if ClaudeCredentialAccountUUID(auth) == "" { + claudeauth.StoreMetadataString(&auth.Metadata, "account_uuid", stableClaudeCLIAccountUUID(seed)) + } + if !claudeauth.HasCanonicalDeviceIDPool(claudeauth.ReadDeviceIDPool(&auth.Metadata)) { + claudeauth.StoreDeviceIDPool(&auth.Metadata, []string{stableClaudeCLIDeviceID(seed)}) + } + if _, _, errPool := claudeauth.EnsureDeviceIDPoolFor(&auth.Metadata); errPool != nil { + return fmt.Errorf("ensure device pool: %w", errPool) + } + return nil +} diff --git a/internal/runtime/executor/helps/claude_cli_identity_seed_test.go b/internal/runtime/executor/helps/claude_cli_identity_seed_test.go new file mode 100644 --- /dev/null +++ b/internal/runtime/executor/helps/claude_cli_identity_seed_test.go @@ -0,0 +1,175 @@ +package helps + +import ( + "sync" + "testing" + + claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/tidwall/gjson" +) + +func TestEnsureClaudeCLIFingerprintIdentitySynthesizesStableSources(t *testing.T) { + t.Parallel() + + auth := &cliproxyauth.Auth{} + if err := EnsureClaudeCLIFingerprintIdentity(auth, "key-a", true); err != nil { + t.Fatalf("EnsureClaudeCLIFingerprintIdentity() error = %v", err) + } + account := ClaudeCredentialAccountUUID(auth) + if account == "" { + t.Fatal("account_uuid is empty") + } + deviceIDs, _, errPool := claudeauth.EnsureDeviceIDPoolFor(&auth.Metadata) + if errPool != nil { + t.Fatalf("EnsureDeviceIDPoolFor() error = %v", errPool) + } + if len(deviceIDs) != 1 || deviceIDs[0] != stableClaudeCLIDeviceID("key-a") { + t.Fatalf("device pool = %#v, want stable single device", deviceIDs) + } + + // Second call must not rotate identity. + if err := EnsureClaudeCLIFingerprintIdentity(auth, "key-a", true); err != nil { + t.Fatalf("second EnsureClaudeCLIFingerprintIdentity() error = %v", err) + } + if got := ClaudeCredentialAccountUUID(auth); got != account { + t.Fatalf("account_uuid changed: %q vs %q", got, account) + } + + const sessionID = "11111111-2222-4333-8444-555555555555" + updated, deviceID, errApply := ApplyClaudeCredentialMetadata([]byte(`{"messages":[]}`), auth, sessionID) + if errApply != nil { + t.Fatalf("ApplyClaudeCredentialMetadata() error = %v", errApply) + } + if deviceID != deviceIDs[0] { + t.Fatalf("selected device = %q, want %q", deviceID, deviceIDs[0]) + } + userID := gjson.GetBytes(updated, "metadata.user_id").String() + if !IsValidUserID(userID) { + t.Fatalf("user_id = %q, want valid", userID) + } + if got := gjson.Get(userID, "account_uuid").String(); got != account { + t.Fatalf("user_id account = %q, want %q", got, account) + } + if got := gjson.Get(userID, "session_id").String(); got != sessionID { + t.Fatalf("user_id session = %q, want %q", got, sessionID) + } +} + +func TestClaudeCLIAuthIdentitySeedPrefersStableAuthIdentity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + auth *cliproxyauth.Auth + want string + }{ + {name: "auth ID", auth: &cliproxyauth.Auth{ID: "kimi-auth"}, want: "auth-id|kimi-auth"}, + {name: "auth index", auth: &cliproxyauth.Auth{Index: "kimi-index"}, want: "auth-index|kimi-index"}, + {name: "auth file", auth: &cliproxyauth.Auth{FileName: "kimi.json"}, want: "auth-file|kimi.json"}, + {name: "missing identity", auth: &cliproxyauth.Auth{}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := ClaudeCLIAuthIdentitySeed(tt.auth); got != tt.want { + t.Fatalf("ClaudeCLIAuthIdentitySeed() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestPrepareClaudeCLIFingerprintAuthDoesNotMutateSharedMetadata(t *testing.T) { + t.Parallel() + + shared := &cliproxyauth.Auth{ + ID: "kimi-shared", + Metadata: map[string]any{ + "access_token": "token-1", + }, + } + prepared, errPrepare := PrepareClaudeCLIFingerprintAuth(shared, ClaudeCLIAuthIdentitySeed(shared), true) + if errPrepare != nil { + t.Fatalf("PrepareClaudeCLIFingerprintAuth() error = %v", errPrepare) + } + if prepared == shared { + t.Fatal("PrepareClaudeCLIFingerprintAuth() returned the shared auth") + } + if ClaudeCredentialAccountUUID(shared) != "" { + t.Fatalf("shared account_uuid = %q, want empty", ClaudeCredentialAccountUUID(shared)) + } + if ClaudeCredentialAccountUUID(prepared) == "" { + t.Fatal("prepared account_uuid is empty") + } + if _, ok := shared.Metadata[claudeauth.ClaudeDeviceIDsMetadataKey]; ok { + t.Fatalf("shared metadata gained device pool: %#v", shared.Metadata) + } +} + +func TestPrepareClaudeCLIFingerprintAuthIsolatesUnlockedMetadataReaders(t *testing.T) { + shared := &cliproxyauth.Auth{ + ID: "kimi-race", + Metadata: map[string]any{ + "access_token": "token-1", + "refresh_token": "refresh-1", + }, + } + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for range 200 { + prepared, errPrepare := PrepareClaudeCLIFingerprintAuth(shared, ClaudeCLIAuthIdentitySeed(shared), true) + if errPrepare != nil { + t.Errorf("PrepareClaudeCLIFingerprintAuth() error = %v", errPrepare) + return + } + if ClaudeCredentialAccountUUID(prepared) == "" { + t.Error("prepared account_uuid is empty") + return + } + } + }() + go func() { + defer wg.Done() + for range 200 { + // Same unlocked read Kimi OpenAI-compat requests perform via kimiCreds. + _ = shared.Metadata["access_token"].(string) + } + }() + wg.Wait() +} + +func TestEnsureClaudeCLIFingerprintIdentityNoopWithoutSynthesize(t *testing.T) { + t.Parallel() + + auth := &cliproxyauth.Auth{} + if err := EnsureClaudeCLIFingerprintIdentity(auth, "key-a", false); err != nil { + t.Fatalf("EnsureClaudeCLIFingerprintIdentity() error = %v", err) + } + if ClaudeCredentialAccountUUID(auth) != "" { + t.Fatal("expected no synthesized account without synthesizeMissing") + } +} + +func TestEnsureClaudeCLIFingerprintIdentityPreservesExistingOAuthSources(t *testing.T) { + t.Parallel() + + auth := &cliproxyauth.Auth{Metadata: map[string]any{ + "account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + claudeauth.ClaudeDeviceIDsMetadataKey: []string{"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + }} + if err := EnsureClaudeCLIFingerprintIdentity(auth, "key-a", true); err != nil { + t.Fatalf("EnsureClaudeCLIFingerprintIdentity() error = %v", err) + } + if got := ClaudeCredentialAccountUUID(auth); got != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" { + t.Fatalf("account_uuid = %q, want preserved", got) + } + deviceIDs, _, errPool := claudeauth.EnsureDeviceIDPoolFor(&auth.Metadata) + if errPool != nil { + t.Fatalf("EnsureDeviceIDPoolFor() error = %v", errPool) + } + if deviceIDs[0] != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" { + t.Fatalf("device pool mutated: %#v", deviceIDs) + } +}