From a2933c7737a4f22bf013ea70490c9c3b453bf981 Mon Sep 17 00:00:00 2001 From: sususu Date: Sun, 2 Aug 2026 11:01:17 +0800 Subject: [PATCH] fix(claude): scope Anthropic beta and count_tokens policies Claude Code builds Anthropic-Beta per request instead of sending a fixed list. Captured from an isolated 2.1.220 profile pointed at api.anthropic.com through a local proxy, over two rounds covering 11 model IDs and the [1m] variants: constant claude-code, interleaved-thinking, redact-thinking, thinking-token-count, context-management, prompt-caching-scope tools advanced-tool-use-2025-11-20 only when tools are declared model mid-conversation-system-2026-04-07 only on models that accept a role=system turn [1m] context-1m-2025-08-07, directly after claude-code-20250219 rather than at the end trailing effort-2025-11-24, then server-side-fallback-2026-06-01 claude-sonnet-5 emits mid-conversation-system-2026-04-07, so it accepts a role=system turn and must not sit in the legacy reminder whitelist. count_tokens does not reuse the inference fingerprint. Running /context in an interactive session issues 37 identical calls, which made the endpoint observable for the first time: four betas only, and 21 headers rather than 22 because X-Stainless-Timeout is absent. The profile is selected from the request path so no call site has to thread another flag. --- config.example.yaml | 9 + internal/runtime/executor/claude_executor.go | 2 +- .../claude_executor_beta_policy_test.go | 215 ++++++++++ .../executor/claude_executor_cloaking.go | 23 +- .../executor/claude_executor_execute.go | 8 +- .../executor/claude_executor_request.go | 302 ++++++++++++-- .../executor/claude_executor_stream.go | 7 +- .../runtime/executor/claude_executor_test.go | 389 ++++++++++++++++-- .../executor/claude_executor_tokens.go | 14 +- 9 files changed, 882 insertions(+), 87 deletions(-) create mode 100644 internal/runtime/executor/claude_executor_beta_policy_test.go diff --git a/config.example.yaml b/config.example.yaml index d6f06cc99..7ca4f1610 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -423,6 +423,15 @@ nonstream-keepalive-interval: 0 # 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 +# 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 +# is added only for models that accept a role=system turn, advanced-tool-use only when +# the request declares tools, and server-side-fallback / fallback-credit / +# structured-outputs trail effort. On direct api.anthropic.com a caller may only ask for +# betas real Claude Code also sends, and they are placed at their observed positions; +# anything else is dropped so the outgoing set stays one a real client could produce. +# Other Anthropic-compatible upstreams still forward caller betas verbatim. +# # Default headers for Claude API requests. Update when Claude Code releases new versions. # Unconfirmed clients use this minimum CLI baseline; verified native Claude Code CLI, # sdk-cli, and VSCode requests preserve or may upgrade their real software fingerprint. In legacy mode, diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 9ed81dde0..06204da81 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -178,7 +178,7 @@ func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Au return nil } useAPIKey := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != "" - isAnthropicBase := req.URL != nil && strings.EqualFold(req.URL.Scheme, "https") && strings.EqualFold(req.URL.Host, "api.anthropic.com") + isAnthropicBase := isAnthropicUpstreamURL(req.URL) if isAnthropicBase && useAPIKey { req.Header.Del("Authorization") req.Header.Set("x-api-key", apiKey) diff --git a/internal/runtime/executor/claude_executor_beta_policy_test.go b/internal/runtime/executor/claude_executor_beta_policy_test.go new file mode 100644 index 000000000..6b375d1a8 --- /dev/null +++ b/internal/runtime/executor/claude_executor_beta_policy_test.go @@ -0,0 +1,215 @@ +package executor + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + 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" +) + +const claudeRaceProbeOAuthKey = "sk-ant-oat-beta-policy" + +func claudeOAuthAuthForBetaPolicy() *cliproxyauth.Auth { + return &cliproxyauth.Auth{ + ID: "claude-beta-policy", + Metadata: map[string]any{"access_token": claudeRaceProbeOAuthKey}, + } +} + +// A confirmed native client authenticates to CPA with the user's configured key +// and cannot know CPA will pick an OAuth credential upstream, so its header never +// carries the OAuth betas. Passing it through verbatim produced a Bearer request +// declaring neither of them. +func TestApplyClaudeHeaders_ConfirmedClientKeepsOAuthCredentialBetas(t *testing.T) { + incoming := http.Header{} + incoming.Set("Anthropic-Beta", claudeCodeBeta+",interleaved-thinking-2025-05-14,"+claudeEffortBeta) + + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, false, nil, + []byte(`{"model":"claude-opus-5"}`), nil, incoming, true); err != nil { + t.Fatalf("applyClaudeHeaders() error = %v", err) + } + + got := req.Header.Get("Anthropic-Beta") + parts := strings.Split(got, ",") + if len(parts) < 2 || parts[0] != claudeCodeBeta || parts[1] != claudeOAuthBeta { + t.Fatalf("Anthropic-Beta = %q, want %s at position 2", got, claudeOAuthBeta) + } + if parts[len(parts)-1] != claudeExtendedCacheTTLBeta { + t.Fatalf("Anthropic-Beta = %q, want %s last", got, claudeExtendedCacheTTLBeta) + } + // The caller's own betas survive the restoration. + for _, want := range []string{"interleaved-thinking-2025-05-14", claudeEffortBeta} { + if !strings.Contains(got, want) { + t.Fatalf("Anthropic-Beta = %q, want caller beta %s preserved", got, want) + } + } +} + +func TestApplyClaudeHeaders_ConfirmedAPIKeyClientKeepsPurePassthrough(t *testing.T) { + incoming := http.Header{} + incoming.Set("Anthropic-Beta", claudeCodeBeta+","+claudeEffortBeta) + + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-passthrough"}} + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(req, auth, "key-passthrough", false, nil, + []byte(`{"model":"claude-opus-5"}`), nil, incoming, true); err != nil { + t.Fatalf("applyClaudeHeaders() error = %v", err) + } + if got, want := req.Header.Get("Anthropic-Beta"), claudeCodeBeta+","+claudeEffortBeta; got != want { + t.Fatalf("Anthropic-Beta = %q, want untouched passthrough %q", got, want) + } +} + +// 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) { + 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) + } +} + +func TestApplyClaudeHeaders_KnownBodyBetaStillPlacedOnAnthropic(t *testing.T) { + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-known-body-beta"}} + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(req, auth, "key-known-body-beta", false, []string{claudeContext1MBeta}, + []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) + } +} + +// Custom credential headers run after the whole header set is assembled, so they +// could rewrite the reconstructed identity on Anthropic itself. +func TestApplyClaudeHeaders_CustomHeadersCannotOverrideAnthropicIdentity(t *testing.T) { + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-custom-headers", + "header:Anthropic-Beta": "attacker-controlled-2099-01-01", + "header:Accept-Encoding": "identity", + }} + + for _, stream := range []bool{false, true} { + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(req, auth, "key-custom-headers", stream, nil, + []byte(`{"model":"claude-opus-5"}`), nil, nil, false); err != nil { + t.Fatalf("applyClaudeHeaders(stream=%v) error = %v", stream, err) + } + if got := req.Header.Get("Anthropic-Beta"); got == "attacker-controlled-2099-01-01" { + t.Fatalf("stream=%v: custom header overrode Anthropic-Beta", stream) + } + if got := req.Header.Get("Accept-Encoding"); got != "gzip, deflate, br, zstd" { + t.Fatalf("stream=%v: Accept-Encoding = %q, want the negotiated transport", stream, got) + } + } +} + +// Kimi rewrites base_url to api.kimi.com and custom gateways set their own host, +// yet both delegate to ClaudeExecutor and are therefore cloaked. Keying the +// context_management injection on the cloaked flag alone leaked a Claude Code +// field into their traffic. +func TestClaudeExecutor_ContextManagementNeverLeaksToOtherUpstreams(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + upstreamBody = bytes.Clone(body) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`) + })) + defer server.Close() + + executor := NewClaudeExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + ID: "claude-non-anthropic-upstream", + Attributes: map[string]string{"api_key": "sk-ant-oat-non-anthropic", "base_url": server.URL}, + } + payload := []byte(`{"model":"claude-opus-5","system":"p","messages":[{"role":"user","content":"hi"}]}`) + + if _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-opus-5", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if got := gjson.GetBytes(upstreamBody, "context_management"); got.Exists() { + t.Fatalf("non-Anthropic upstream received context_management = %s", got.Raw) + } +} + +func TestIsAnthropicUpstreamBase(t *testing.T) { + cases := map[string]bool{ + "https://api.anthropic.com": true, + "https://API.Anthropic.com": true, + "https://api.kimi.com": false, + "http://api.anthropic.com": false, + "https://api.anthropic.com.evil": false, + "https://gateway.example.com": false, + "": false, + } + for base, want := range cases { + if got := isAnthropicUpstreamBase(base); got != want { + t.Fatalf("isAnthropicUpstreamBase(%q) = %v, want %v", base, got, want) + } + } +} + +// Streaming previously never reached the fast-mode derivation, so speed:"fast" +// produced a 400 on every streamed request. +func TestApplyClaudeHeaders_FastModeBetaMatchesAcrossStreamModes(t *testing.T) { + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-fast-parity"}} + body := []byte(`{"model":"claude-opus-5","speed":"fast"}`) + + var seen []string + for _, stream := range []bool{false, true} { + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(req, auth, "key-fast-parity", stream, nil, body, nil, nil, false); err != nil { + t.Fatalf("applyClaudeHeaders(stream=%v) error = %v", stream, err) + } + got := req.Header.Get("Anthropic-Beta") + if !strings.Contains(got, claudeFastModeBeta) { + t.Fatalf("stream=%v: Anthropic-Beta = %q, want %s", stream, got, claudeFastModeBeta) + } + seen = append(seen, got) + } + if seen[0] != seen[1] { + t.Fatalf("stream and non-stream disagree:\n non-stream %q\n stream %q", seen[0], seen[1]) + } +} + +// extended-cache-ttl is the one measured trailing invariant; fast-mode has no +// captured position and must not displace it. +func TestApplyClaudeHeaders_FastModePrecedesOAuthTrailer(t *testing.T) { + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, true, nil, + []byte(`{"model":"claude-opus-5","speed":"fast"}`), nil, nil, false); err != nil { + t.Fatalf("applyClaudeHeaders() error = %v", err) + } + got := req.Header.Get("Anthropic-Beta") + parts := strings.Split(got, ",") + if parts[len(parts)-1] != claudeExtendedCacheTTLBeta { + t.Fatalf("Anthropic-Beta = %q, want %s last", got, claudeExtendedCacheTTLBeta) + } + if parts[len(parts)-2] != claudeFastModeBeta { + t.Fatalf("Anthropic-Beta = %q, want %s immediately before the OAuth trailer", got, claudeFastModeBeta) + } +} diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index ba1cebe3e..29688e378 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -263,7 +263,6 @@ var claudeLegacySystemReminderModels = map[string]struct{}{ "claude-sonnet-4-5": {}, "claude-sonnet-4-5-20250929": {}, "claude-sonnet-4-6": {}, - "claude-sonnet-5": {}, } func claudeUsesLegacySystemReminder(payload []byte) bool { @@ -528,6 +527,28 @@ func injectClaudeCodeCurrentDate(payload []byte, now time.Time) []byte { return payload } +// claudeCodeContextManagement is the context_management object Claude Code +// 2.1.220 sends on every Messages request, captured 2026-08-01 from an isolated +// profile talking to api.anthropic.com. keep:"all" retains every thinking block, +// so replicating the client's exact value cannot produce upstream behaviour the +// real client does not already get. +const claudeCodeContextManagement = `{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}` + +// injectClaudeCodeContextManagement supplies context_management when the caller +// omitted it. CPA already claims context-management-2025-06-27 in Anthropic-Beta, +// so a missing body field is an observable inconsistency with the real client. A +// caller that sent its own object keeps it untouched. +func injectClaudeCodeContextManagement(payload []byte) []byte { + if gjson.GetBytes(payload, "context_management").Exists() { + return payload + } + updated, err := sjson.SetRawBytes(payload, "context_management", []byte(claudeCodeContextManagement)) + if err != nil { + return payload + } + return updated +} + func withEphemeralCacheControl(rawBlock string) string { updated, err := sjson.SetRawBytes([]byte(rawBlock), "cache_control", []byte(`{"type":"ephemeral"}`)) if err != nil { diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index f6ab2d7d7..0285072bb 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -76,6 +76,11 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if err != nil { return resp, err } + // Only the Messages endpoint on Anthropic itself was captured; count_tokens + // keeps its own shape and other gateways never see this field. + if cloaked && isAnthropicUpstreamBase(baseURL) { + body = injectClaudeCodeContextManagement(body) + } requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) @@ -106,7 +111,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Extract betas from body and convert to header var extraBetas []string extraBetas, body = extractAndRemoveBetas(body) - extraBetas = appendClaudeFastModeBeta(body, extraBetas) bodyForTranslation := body bodyForUpstream := body var oauthToolNamesReverseMap map[string]string @@ -133,7 +137,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if err != nil { return resp, err } - if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, upstreamStream, extraBetas, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil { + if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, upstreamStream, extraBetas, bodyForUpstream, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil { return resp, errHeaders } var authID, authLabel, authType, authValue string diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index b5a6a121f..dc861267c 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "github.com/andybalholm/brotli" @@ -26,11 +27,202 @@ import ( ) const ( - defaultClaudeCodeCLIBetas = "claude-code-20250219,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01" - claudeTokenCountingBeta = "token-counting-2024-11-01" - claudeFastModeBeta = "fast-mode-2026-02-01" + claudeTokenCountingBeta = "token-counting-2024-11-01" + claudeFastModeBeta = "fast-mode-2026-02-01" + claudeOAuthBeta = "oauth-2025-04-20" + claudeCodeBeta = "claude-code-20250219" + claudeContext1MBeta = "context-1m-2025-08-07" + claudeMidConvSystemBeta = "mid-conversation-system-2026-04-07" + claudeAdvancedToolUseBeta = "advanced-tool-use-2025-11-20" + claudeEffortBeta = "effort-2025-11-24" + claudeServerSideFallbackBeta = "server-side-fallback-2026-06-01" + claudeFallbackCreditBeta = "fallback-credit-2026-06-01" + claudeStructuredOutputsBeta = "structured-outputs-2025-12-15" + claudeExtendedCacheTTLBeta = "extended-cache-ttl-2025-04-11" ) +// claudeCodeCLIConstantBetas are the betas Claude Code 2.1.220 sends on every +// /v1/messages request from the "cli" entrypoint, in wire order, excluding the +// leading claude-code-20250219. +// +// redact-thinking-2026-02-12 belongs here because cloaked requests always claim +// cc_entrypoint=cli; the "sdk-cli" entrypoint omits it. +var claudeCodeCLIConstantBetas = []string{ + "interleaved-thinking-2025-05-14", + "redact-thinking-2026-02-12", + "thinking-token-count-2026-05-13", + "context-management-2025-06-27", + "prompt-caching-scope-2026-01-05", +} + +// claudeCodeTrailingBetas are caller-supplied betas that real Claude Code emits +// after effort-2025-11-24, in that relative order. They are forwarded when the +// caller asks for them and dropped otherwise. +var claudeCodeTrailingBetas = []string{ + claudeServerSideFallbackBeta, + claudeFallbackCreditBeta, + claudeStructuredOutputsBeta, +} + +// claudeCodeCLIBetas assembles the Anthropic-Beta baseline the way Claude Code +// 2.1.220 does: the list is per-request, not a fixed string. requested holds the +// betas the caller asked for, which decide the capability flags below. +// +// Verified 2026-08-01 against api.anthropic.com with isolated 2.1.220 profiles on +// both the API-key and OAuth paths, across 11 model IDs and the [1m] variants. +// The full observed order is: +// +// 1 claude-code-20250219 +// 2 oauth-2025-04-20 OAuth credentials only +// 3 context-1m-2025-08-07 [1m] model variants only +// 4 interleaved-thinking-2025-05-14 +// 5 redact-thinking-2026-02-12 cli entrypoint only +// 6 thinking-token-count-2026-05-13 +// 7 context-management-2025-06-27 +// 8 prompt-caching-scope-2026-01-05 +// 9 mid-conversation-system-2026-04-07 models accepting a role=system turn +// 10 advanced-tool-use-2025-11-20 requests declaring tools +// 11 effort-2025-11-24 +// 12 server-side-fallback-2026-06-01 +// 13 fallback-credit-2026-06-01 +// 14 extended-cache-ttl-2025-04-11 OAuth credentials only, always last +// +// fast-mode-2026-02-01 has no captured position; it is emitted just before the +// OAuth trailer so the one measured invariant, extended-cache-ttl last, holds. +// +// An empty body keeps the optimistic role=system default, matching the cloaking +// policy for unknown and future model IDs. +func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) string { + betas := make([]string, 0, len(claudeCodeCLIConstantBetas)+len(claudeCodeTrailingBetas)+6) + betas = append(betas, claudeCodeBeta) + if oauthToken { + betas = append(betas, claudeOAuthBeta) + } + if requested[claudeContext1MBeta] { + betas = append(betas, claudeContext1MBeta) + } + betas = append(betas, claudeCodeCLIConstantBetas...) + if !claudeUsesLegacySystemReminder(body) { + betas = append(betas, claudeMidConvSystemBeta) + } + if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { + betas = append(betas, claudeAdvancedToolUseBeta) + } + betas = append(betas, claudeEffortBeta) + for _, beta := range claudeCodeTrailingBetas { + if requested[beta] { + betas = append(betas, beta) + } + } + if claudeRequestUsesFastMode(body, requested) { + betas = append(betas, claudeFastModeBeta) + } + if oauthToken { + betas = append(betas, claudeExtendedCacheTTLBeta) + } + return strings.Join(betas, ",") +} + +// claudeRequestUsesFastMode reports whether the request selects the fast service +// tier. Anthropic rejects the body's speed field with "Extra inputs are not +// permitted" unless fast-mode-2026-02-01 is declared, so the beta has to follow +// the body. Deriving it here rather than at the call sites is deliberate: the +// streaming and non-streaming paths previously disagreed and streaming silently +// dropped the beta, turning every fast request into a 400. +func claudeRequestUsesFastMode(body []byte, requested map[string]bool) bool { + if requested[claudeFastModeBeta] { + return true + } + speed := gjson.GetBytes(body, "speed") + return speed.Type == gjson.String && strings.EqualFold(strings.TrimSpace(speed.String()), "fast") +} + +// claudeCountTokensBetas is the fixed profile Claude Code 2.1.220 sends to +// /v1/messages/count_tokens. It is far smaller than the inference baseline: +// redact-thinking, thinking-token-count, prompt-caching-scope, effort and every +// conditional beta are absent. Verified identical across 37 captured calls. +var claudeCountTokensBetas = []string{ + claudeCodeBeta, + "interleaved-thinking-2025-05-14", + "context-management-2025-06-27", + claudeTokenCountingBeta, +} + +// withClaudeOAuthCredentialBetas restores the two betas that describe the +// upstream credential rather than the caller's capabilities. +// +// A confirmed native client authenticates to CPA with whatever key the user +// configured and cannot know that CPA will select an OAuth credential upstream, +// so its header never carries the OAuth betas. Passing it through verbatim ships +// a Bearer request that declares neither oauth-2025-04-20 nor +// extended-cache-ttl-2025-04-11, which no real OAuth client ever does. Passthrough +// governs what the caller expressed; the credential is CPA's own choice and has to +// be described accurately. +// +// Betas already present are left exactly where the caller put them. +func withClaudeOAuthCredentialBetas(betas string) string { + parts := make([]string, 0, 16) + seen := make(map[string]bool) + for _, beta := range strings.Split(betas, ",") { + if beta = strings.TrimSpace(beta); beta != "" && !seen[beta] { + parts = append(parts, beta) + seen[beta] = true + } + } + if !seen[claudeOAuthBeta] { + // Captured position 2, directly after claude-code-20250219. + insertAt := 0 + if len(parts) > 0 && parts[0] == claudeCodeBeta { + insertAt = 1 + } + parts = append(parts, "") + copy(parts[insertAt+1:], parts[insertAt:]) + parts[insertAt] = claudeOAuthBeta + } + if !seen[claudeExtendedCacheTTLBeta] { + parts = append(parts, claudeExtendedCacheTTLBeta) + } + return strings.Join(parts, ",") +} + +// claudeRequestedBetas collects every beta the caller asked for, from the +// Anthropic-Beta header and from betas lifted out of the request body. +func claudeRequestedBetas(incomingBetas string, extraBetas []string) map[string]bool { + requested := make(map[string]bool) + for _, beta := range strings.Split(incomingBetas, ",") { + if beta = strings.TrimSpace(beta); beta != "" { + requested[beta] = true + } + } + for _, beta := range extraBetas { + if beta = strings.TrimSpace(beta); beta != "" { + requested[beta] = true + } + } + return requested +} + +// isAnthropicUpstreamURL reports whether a resolved request targets Anthropic's +// first-party API. +// +// Every rule that reconstructs Claude Code's identity must key on this rather +// than on the cloaked flag. Kimi rewrites base_url to api.kimi.com and custom +// gateways set their own host, yet both delegate to ClaudeExecutor and are +// therefore cloaked; a cloak-keyed rule silently rewrites their traffic too. +func isAnthropicUpstreamURL(u *url.URL) bool { + return u != nil && strings.EqualFold(u.Scheme, "https") && strings.EqualFold(u.Host, "api.anthropic.com") +} + +// isAnthropicUpstreamBase reports whether a configured base URL targets Anthropic's +// first-party API. Used before the outgoing request exists. +func isAnthropicUpstreamBase(baseURL string) bool { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return false + } + return isAnthropicUpstreamURL(parsed) +} + // extractAndRemoveBetas extracts the "betas" array from the body and removes it. // Returns the extracted betas as a string slice and the modified body. func extractAndRemoveBetas(body []byte) ([]string, []byte) { @@ -52,19 +244,6 @@ func extractAndRemoveBetas(body []byte) ([]string, []byte) { return betas, body } -func appendClaudeFastModeBeta(body []byte, betas []string) []string { - speed := gjson.GetBytes(body, "speed") - if speed.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(speed.String()), "fast") { - return betas - } - for _, beta := range betas { - if strings.TrimSpace(beta) == claudeFastModeBeta { - return betas - } - } - return append(betas, claudeFastModeBeta) -} - // disableThinkingIfToolChoiceForced checks if tool_choice forces tool use and disables thinking. // Anthropic API does not allow thinking when tool_choice is set to "any" or a specific tool. // See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations @@ -227,7 +406,7 @@ func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadClos return body, nil } -func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, cfg *config.Config, incomingHeaders http.Header, confirmedClaudeCode bool, sessionIDs ...string) error { +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 { if r == nil { return nil } @@ -246,7 +425,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, hasAPIKeyAttr := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != "" oauthToken := isClaudeOAuthToken(apiKey) || !hasAPIKeyAttr useAPIKey := !oauthToken - isAnthropicBase := r.URL != nil && strings.EqualFold(r.URL.Scheme, "https") && strings.EqualFold(r.URL.Host, "api.anthropic.com") + isAnthropicBase := isAnthropicUpstreamURL(r.URL) if isAnthropicBase && useAPIKey { r.Header.Del("Authorization") r.Header.Set("x-api-key", apiKey) @@ -270,10 +449,17 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, } } - baseBetas := defaultClaudeCodeCLIBetas 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 = strings.Join(claudeCountTokensBetas, ",") + } if confirmedClaudeCode && incomingBetas != "" { baseBetas = incomingBetas + if oauthToken && !countTokens { + baseBetas = withClaudeOAuthCredentialBetas(baseBetas) + } } existingSet := make(map[string]bool) for _, beta := range strings.Split(baseBetas, ",") { @@ -289,16 +475,32 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, baseBetas += "," + beta existingSet[beta] = true } - if !confirmedClaudeCode && incomingBetas != "" { + // 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 oauthToken { - appendBeta("oauth-2025-04-20") + // The OAuth betas have known positions on /v1/messages and are placed by + // claudeCodeCLIBetas. count_tokens was only captured over an API key, so its + // OAuth shape keeps the previous appended form until it can be measured. + if oauthToken && countTokens { + appendBeta(claudeOAuthBeta) } - for _, beta := range extraBetas { - appendBeta(beta) + // 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) + } } r.Header.Set("Anthropic-Beta", baseBetas) @@ -316,7 +518,15 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, identityHeader("X-Stainless-Retry-Count", "0") identityHeader("X-Stainless-Runtime", "node") identityHeader("X-Stainless-Lang", "js") - identityHeader("X-Stainless-Timeout", hdrDefault(hd.Timeout, "600")) + // Claude Code omits X-Stainless-Timeout on count_tokens; only a confirmed + // native client that sent one of its own keeps it there. + if !countTokens { + identityHeader("X-Stainless-Timeout", hdrDefault(hd.Timeout, "600")) + } else if confirmedClaudeCode { + if incomingTimeout := incomingHeaders.Get("X-Stainless-Timeout"); incomingTimeout != "" { + r.Header.Set("X-Stainless-Timeout", incomingTimeout) + } + } // Selected-credential OAuth identity is an explicit native passthrough // exception. Callers pass the same agent-conversation UUID written to // metadata.user_id; legacy paths retain their previous cached fallback. @@ -342,16 +552,26 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, identityHeader("x-client-request-id", uuid.New().String()) } r.Header.Set("Connection", "keep-alive") - if stream { - r.Header.Set("Accept", "text/event-stream") - // SSE streams must not be compressed: the downstream scanner reads - // line-delimited text and cannot parse compressed bytes. Using - // "identity" tells the upstream to send an uncompressed stream. - r.Header.Set("Accept-Encoding", "identity") - } else { + // Claude Code negotiates transport identically for streaming and non-streaming + // requests: Accept stays application/json and full compression is offered even + // when the body sets stream:true, because Anthropic selects SSE from the body + // rather than from Accept. Verified across every captured 2.1.220 stream. + // Forcing text/event-stream plus identity here would otherwise mark every + // streaming request, which is nearly all traffic. decodeResponseBody already + // wraps the success path, so a compressed SSE body is decoded transparently. + applyTransportNegotiation := func() { + if stream && !isAnthropicBase { + // Other Anthropic-compatible upstreams (Kimi, custom gateways) may select + // SSE from Accept and need not compress predictably, so they keep the + // conservative contract. + r.Header.Set("Accept", "text/event-stream") + r.Header.Set("Accept-Encoding", "identity") + return + } r.Header.Set("Accept", "application/json") r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") } + applyTransportNegotiation() // Confirmed Claude Code requests may contribute their real software profile. // Unconfirmed clients always receive the CLI baseline instead of being // allowed to populate or reuse another client's software profile. @@ -369,11 +589,19 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(r, attrs) - // Re-enforce the SSE transport contract after custom headers. A custom Accept - // value can disable event negotiation, while compressed SSE breaks line parsing. - if stream { - r.Header.Set("Accept", "text/event-stream") - r.Header.Set("Accept-Encoding", "identity") + // Custom credential headers are a configuration escape hatch for third-party + // gateways, so they keep the last word there. On api.anthropic.com they must + // not rewrite the reconstructed identity: an overridden Anthropic-Beta yields a + // combination real Claude Code never sends and the API rejects, and an + // overridden Accept-Encoding contradicts the negotiated transport. Both were + // reachable because this ran after the whole header set was assembled. + if isAnthropicBase { + r.Header.Set("Anthropic-Beta", baseBetas) + applyTransportNegotiation() + } else if stream { + // Elsewhere only streaming is protected, so an Accept override cannot + // silently disable event negotiation. + applyTransportNegotiation() } return nil } diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index ec960f759..037bf6c79 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -76,6 +76,11 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if err != nil { return nil, err } + // Only the Messages endpoint on Anthropic itself was captured; count_tokens + // keeps its own shape and other gateways never see this field. + if cloaked && isAnthropicUpstreamBase(baseURL) { + body = injectClaudeCodeContextManagement(body) + } requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) @@ -126,7 +131,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if err != nil { return nil, err } - if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, true, extraBetas, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil { + if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, true, extraBetas, bodyForUpstream, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil { return nil, errHeaders } var authID, authLabel, authType, authValue string diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index e270fc8c5..138cbfe66 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -71,8 +71,9 @@ func assertClaudeFingerprint(t *testing.T, headers http.Header, userAgent, pkgVe } func TestApplyClaudeHeaders_FastModeBetaIsConditional(t *testing.T) { - const betasWithoutFastMode = defaultClaudeCodeCLIBetas - const betasWithFastMode = defaultClaudeCodeCLIBetas + "," + claudeFastModeBeta + baseline := claudeCodeCLIBetas([]byte(`{"model":"claude-opus-5"}`), nil, false) + betasWithoutFastMode := baseline + betasWithFastMode := baseline + "," + claudeFastModeBeta tests := []struct { name string @@ -100,9 +101,8 @@ func TestApplyClaudeHeaders_FastModeBetaIsConditional(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { extraBetas, body := extractAndRemoveBetas([]byte(tt.body)) - extraBetas = appendClaudeFastModeBeta(body, extraBetas) req := newClaudeHeaderTestRequest(t, nil) - if errApply := applyClaudeHeaders(req, auth, "key-fast-mode-beta", false, extraBetas, nil, nil, false); errApply != nil { + if errApply := applyClaudeHeaders(req, auth, "key-fast-mode-beta", false, extraBetas, body, nil, nil, false); errApply != nil { t.Fatalf("applyClaudeHeaders() error = %v", errApply) } if got := req.Header.Get("Anthropic-Beta"); got != tt.want { @@ -142,6 +142,27 @@ func assertClaudeCredentialIdentity(t *testing.T, body []byte, headers http.Head } } +// assertClaudeCountTokensIdentity pins the count_tokens shape captured from real +// Claude Code 2.1.220: the endpoint carries no metadata whatsoever. Anthropic +// rejects the field there with "metadata: Extra inputs are not permitted", so the +// credential identity travels only on the header and on the Messages endpoint. +func assertClaudeCountTokensIdentity(t *testing.T, body []byte, headers http.Header) { + t.Helper() + if got := gjson.GetBytes(body, "metadata"); got.Exists() { + t.Fatalf("count_tokens metadata = %s, want it absent", got.Raw) + } + if got := headers.Get("X-Claude-Code-Session-Id"); got == "" { + t.Fatal("count_tokens is missing X-Claude-Code-Session-Id") + } + resigned, errResign := finalizeAnthropicMessagesBodyCCH(body, "") + if errResign != nil { + t.Fatalf("re-finalize Claude CCH: %v", errResign) + } + if !bytes.Equal(resigned, body) { + t.Fatal("count_tokens CCH was calculated before the final body rewrite") + } +} + func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) { resetClaudeDeviceProfileCache() stabilize := true @@ -176,7 +197,7 @@ func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) { } req := newClaudeHeaderTestRequest(t, incoming) - applyClaudeHeaders(req, auth, "key-baseline", false, nil, cfg, nil, false) + applyClaudeHeaders(req, auth, "key-baseline", false, nil, nil, cfg, nil, false) assertClaudeFingerprint(t, req.Header, "evil-client/9.9", "9.9.9", "v24.5.0", "Linux", "x64") if got := req.Header.Get("X-Stainless-Timeout"); got != "900" { @@ -212,7 +233,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(firstReq, auth, "key-upgrade", false, nil, cfg, nil, true) + applyClaudeHeaders(firstReq, auth, "key-upgrade", false, nil, nil, cfg, nil, true) assertClaudeFingerprint(t, firstReq.Header, "claude-cli/2.1.62 (external, cli)", "0.74.0", "v24.3.0", "MacOS", "arm64") thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -222,7 +243,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "X-Stainless-Os": []string{"Windows"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(thirdPartyReq, auth, "key-upgrade", false, nil, cfg, nil, false) + applyClaudeHeaders(thirdPartyReq, auth, "key-upgrade", false, nil, nil, cfg, nil, false) assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "MacOS", "arm64") higherReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -232,7 +253,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "X-Stainless-Os": []string{"MacOS"}, "X-Stainless-Arch": []string{"arm64"}, }) - applyClaudeHeaders(higherReq, auth, "key-upgrade", false, nil, cfg, nil, true) + applyClaudeHeaders(higherReq, auth, "key-upgrade", false, nil, nil, cfg, nil, true) assertClaudeFingerprint(t, higherReq.Header, "claude-cli/2.1.63 (external, cli)", "0.75.0", "v24.4.0", "MacOS", "arm64") lowerReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -242,7 +263,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "X-Stainless-Os": []string{"Windows"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(lowerReq, auth, "key-upgrade", false, nil, cfg, nil, true) + applyClaudeHeaders(lowerReq, auth, "key-upgrade", false, nil, nil, cfg, nil, true) assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.63 (external, cli)", "0.75.0", "v24.4.0", "MacOS", "arm64") } @@ -274,7 +295,7 @@ func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClien "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(olderClaudeReq, auth, "key-baseline-floor", false, nil, cfg, nil, true) + applyClaudeHeaders(olderClaudeReq, auth, "key-baseline-floor", false, nil, nil, cfg, nil, true) assertClaudeFingerprint(t, olderClaudeReq.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64") newerClaudeReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -284,7 +305,7 @@ func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClien "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(newerClaudeReq, auth, "key-baseline-floor", false, nil, cfg, nil, true) + applyClaudeHeaders(newerClaudeReq, auth, "key-baseline-floor", false, nil, nil, cfg, nil, true) assertClaudeFingerprint(t, newerClaudeReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "MacOS", "arm64") } @@ -326,7 +347,7 @@ func TestApplyClaudeHeaders_UpgradesCachedSoftwareFingerprintWhenBaselineAdvance "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(officialReq, auth, "key-baseline-reload", false, nil, oldCfg, nil, true) + applyClaudeHeaders(officialReq, auth, "key-baseline-reload", false, nil, nil, oldCfg, nil, true) assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "MacOS", "arm64") thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -336,7 +357,7 @@ func TestApplyClaudeHeaders_UpgradesCachedSoftwareFingerprintWhenBaselineAdvance "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(thirdPartyReq, auth, "key-baseline-reload", false, nil, newCfg, nil, false) + applyClaudeHeaders(thirdPartyReq, auth, "key-baseline-reload", false, nil, nil, newCfg, nil, false) assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") } @@ -368,7 +389,7 @@ func TestApplyClaudeHeaders_LearnsOfficialFingerprintAfterCustomBaselineFallback "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(thirdPartyReq, auth, "key-custom-baseline-learning", false, nil, cfg, nil, false) + applyClaudeHeaders(thirdPartyReq, auth, "key-custom-baseline-learning", false, nil, nil, cfg, nil, false) assertClaudeFingerprint(t, thirdPartyReq.Header, "my-gateway/1.0", "custom-pkg", "custom-runtime", "MacOS", "arm64") officialReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -378,7 +399,7 @@ func TestApplyClaudeHeaders_LearnsOfficialFingerprintAfterCustomBaselineFallback "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(officialReq, auth, "key-custom-baseline-learning", false, nil, cfg, nil, true) + applyClaudeHeaders(officialReq, auth, "key-custom-baseline-learning", false, nil, nil, cfg, nil, true) assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") postLearningThirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -388,7 +409,7 @@ func TestApplyClaudeHeaders_LearnsOfficialFingerprintAfterCustomBaselineFallback "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(postLearningThirdPartyReq, auth, "key-custom-baseline-learning", false, nil, cfg, nil, false) + applyClaudeHeaders(postLearningThirdPartyReq, auth, "key-custom-baseline-learning", false, nil, nil, cfg, nil, false) assertClaudeFingerprint(t, postLearningThirdPartyReq.Header, "my-gateway/1.0", "custom-pkg", "custom-runtime", "MacOS", "arm64") } @@ -520,7 +541,7 @@ func TestApplyClaudeHeaders_ThirdPartyBaselineThenOfficialUpgradeKeepsPinnedPlat "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(thirdPartyReq, auth, "key-third-party-then-official", false, nil, cfg, nil, false) + applyClaudeHeaders(thirdPartyReq, auth, "key-third-party-then-official", false, nil, nil, cfg, nil, false) assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64") officialReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -530,7 +551,7 @@ func TestApplyClaudeHeaders_ThirdPartyBaselineThenOfficialUpgradeKeepsPinnedPlat "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(officialReq, auth, "key-third-party-then-official", false, nil, cfg, nil, true) + applyClaudeHeaders(officialReq, auth, "key-third-party-then-official", false, nil, nil, cfg, nil, true) assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") } @@ -562,7 +583,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(firstReq, auth, "key-disable-stability", false, nil, cfg, nil, true) + applyClaudeHeaders(firstReq, auth, "key-disable-stability", false, nil, nil, cfg, nil, true) assertClaudeFingerprint(t, firstReq.Header, "claude-cli/2.1.62 (external, cli)", "0.74.0", "v24.3.0", "Linux", "x64") thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -572,7 +593,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { "X-Stainless-Os": []string{"Windows"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(thirdPartyReq, auth, "key-disable-stability", false, nil, cfg, nil, false) + applyClaudeHeaders(thirdPartyReq, auth, "key-disable-stability", false, nil, nil, cfg, nil, false) assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", helps.MapStainlessOS(), helps.MapStainlessArch()) lowerReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -582,7 +603,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { "X-Stainless-Os": []string{"Windows"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(lowerReq, auth, "key-disable-stability", false, nil, cfg, nil, true) + applyClaudeHeaders(lowerReq, auth, "key-disable-stability", false, nil, nil, cfg, nil, true) assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.61 (external, cli)", "0.73.0", "v24.2.0", "Windows", "x64") } @@ -613,7 +634,7 @@ func TestApplyClaudeHeaders_LegacyModePreservesConfiguredUserAgentOverrideForCla "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(req, auth, "key-legacy-ua-override", false, nil, cfg, nil, true) + applyClaudeHeaders(req, auth, "key-legacy-ua-override", false, nil, nil, cfg, nil, true) assertClaudeFingerprint(t, req.Header, "config-ua/1.0", "0.74.0", "v24.3.0", "Linux", "x64") } @@ -642,7 +663,7 @@ func TestApplyClaudeHeaders_LegacyThirdPartyUsesStableConfiguredOSArch(t *testin req := newClaudeHeaderTestRequest(t, http.Header{ "User-Agent": []string{"curl/8.7.1"}, }) - applyClaudeHeaders(req, auth, "key-legacy-runtime-os-arch", false, nil, cfg, nil, false) + applyClaudeHeaders(req, auth, "key-legacy-runtime-os-arch", false, nil, nil, cfg, nil, false) assertClaudeFingerprint(t, req.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "Windows", "x64") } @@ -669,7 +690,7 @@ func TestApplyClaudeHeaders_UnsetStabilizationUsesStableConfiguredOSArch(t *test req := newClaudeHeaderTestRequest(t, http.Header{ "User-Agent": []string{"curl/8.7.1"}, }) - applyClaudeHeaders(req, auth, "key-unset-runtime-os-arch", false, nil, cfg, nil, false) + applyClaudeHeaders(req, auth, "key-unset-runtime-os-arch", false, nil, nil, cfg, nil, false) assertClaudeFingerprint(t, req.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "Linux", "x64") } @@ -677,7 +698,7 @@ func TestApplyClaudeHeaders_UnsetStabilizationUsesStableConfiguredOSArch(t *test func TestApplyClaudeHeaders_UsesOAuthAuthorizationAndBrowserFingerprint(t *testing.T) { auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-ant-oat-header-test"}} req := newClaudeHeaderTestRequest(t, nil) - if errHeaders := applyClaudeHeaders(req, auth, "sk-ant-oat-header-test", false, nil, &config.Config{}, nil, false, "11111111-2222-4333-8444-555555555555"); errHeaders != nil { + if errHeaders := applyClaudeHeaders(req, auth, "sk-ant-oat-header-test", false, nil, nil, &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 sk-ant-oat-header-test" { @@ -724,8 +745,8 @@ func TestClaudeExecutor_NonClaudeRequestUsesClaudeCode220CLIFingerprint(t *testi if got := seenHeaders.Get("X-App"); got != "cli" { t.Fatalf("X-App = %q, want cli", got) } - if got := seenHeaders.Get("Anthropic-Beta"); got != defaultClaudeCodeCLIBetas { - t.Fatalf("Anthropic-Beta = %q, want %q", got, defaultClaudeCodeCLIBetas) + if want := claudeCodeCLIBetas(payload, nil, false); seenHeaders.Get("Anthropic-Beta") != want { + t.Fatalf("Anthropic-Beta = %q, want %q", seenHeaders.Get("Anthropic-Beta"), want) } system := gjson.GetBytes(seenBody, "system").Array() @@ -1735,6 +1756,70 @@ func TestClaudeExecutor_ExecuteStreamDirectPassthroughEmitsCompleteSSEEvents(t * } } +// TestClaudeExecutor_ExecuteStreamDecodesCompressedSSE guards the dependency that +// lets CPA advertise the real client's Accept-Encoding on streaming requests: +// once compression is offered the upstream may compress the SSE body, so the +// streaming success path must decode it and still emit event boundaries intact. +func TestClaudeExecutor_ExecuteStreamDecodesCompressedSSE(t *testing.T) { + firstData := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}` + secondData := `{"type":"message_stop"}` + upstreamStream := "event: content_block_delta\n" + + "data: " + firstData + "\n" + + "\n" + + "event: message_stop\n" + + "data: " + secondData + "\n" + + "\n" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Content-Encoding", "gzip") + gzipWriter := gzip.NewWriter(w) + if _, errWrite := gzipWriter.Write([]byte(upstreamStream)); errWrite != nil { + t.Errorf("gzip write: %v", errWrite) + } + if errClose := gzipWriter.Close(); errClose != nil { + t.Errorf("gzip close: %v", errClose) + } + })) + defer server.Close() + + executor := NewClaudeExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-123", + "base_url": server.URL, + }} + payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) + + result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-3-5-sonnet-20241022", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}) + if err != nil { + t.Fatalf("ExecuteStream() error = %v", err) + } + + var payloads []string + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("unexpected chunk error: %v", chunk.Err) + } + payloads = append(payloads, string(chunk.Payload)) + } + + want := []string{ + "event: content_block_delta\n" + "data: " + firstData + "\n\n", + "event: message_stop\n" + "data: " + secondData + "\n\n", + } + if len(payloads) != len(want) { + t.Fatalf("payload count = %d, want %d: %#v", len(payloads), len(want), payloads) + } + for i := range want { + if payloads[i] != want[i] { + t.Fatalf("payload[%d] = %q, want %q", i, payloads[i], want[i]) + } + } +} + func TestClaudeExecutor_CountTokensExcludesInvalidOpenAIThinking(t *testing.T) { executor := NewClaudeExecutor(&config.Config{}) countTokens := func(payload []byte) int64 { @@ -1823,10 +1908,15 @@ func TestClaudeExecutor_CountTokensOAuthUsesUpstreamCLIShape(t *testing.T) { if got := upstreamHeaders.Get("User-Agent"); got != "claude-cli/2.1.220 (external, cli)" { t.Fatalf("count_tokens User-Agent = %q, want CLI identity", got) } - wantBetas := defaultClaudeCodeCLIBetas + ",oauth-2025-04-20," + claudeTokenCountingBeta + // count_tokens carries its own much smaller profile, not the inference baseline. + wantBetas := strings.Join(claudeCountTokensBetas, ",") + "," + claudeOAuthBeta if got := upstreamHeaders.Get("Anthropic-Beta"); got != wantBetas { t.Fatalf("count_tokens Anthropic-Beta = %q, want %q", got, wantBetas) } + // Claude Code omits X-Stainless-Timeout on count_tokens. + if got := upstreamHeaders.Get("X-Stainless-Timeout"); got != "" { + t.Fatalf("count_tokens X-Stainless-Timeout = %q, want it absent", got) + } if got := gjson.GetBytes(upstreamBody, "system.1.text").String(); got != claudeCodeCLIIdentity { t.Fatalf("count_tokens system.1.text = %q, want official CLI identity", got) } @@ -1846,7 +1936,7 @@ func TestClaudeExecutor_CountTokensOAuthUsesUpstreamCLIShape(t *testing.T) { if _, ok := claudeBillingCCHDigitsOffset(upstreamBody); !ok { t.Fatalf("count_tokens Claude OAuth custom BaseURL body is missing CCH: %s", upstreamBody) } - assertClaudeCredentialIdentity(t, upstreamBody, upstreamHeaders, deviceIDs, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + assertClaudeCountTokensIdentity(t, upstreamBody, upstreamHeaders) if got := gjson.GetBytes(resp.Payload, "input_tokens").Int(); got != 7 { t.Fatalf("input_tokens = %d, want 7", got) } @@ -1875,7 +1965,7 @@ func TestClaudeExecutor_LegacySystemReminderAcrossMessagesStreamAndCountTokens(t _, _ = w.Write([]byte("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")) default: w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"id":"msg_legacy","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`)) + _, _ = w.Write([]byte(`{"id":"msg_legacy","type":"message","model":"claude-opus-4-6","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`)) } })) defer server.Close() @@ -1899,21 +1989,21 @@ func TestClaudeExecutor_LegacySystemReminderAcrossMessagesStreamAndCountTokens(t if stream { streamField = `,"stream":true` } - return []byte(`{"model":"claude-sonnet-5","system":"legacy-system-prompt","messages":[{"role":"user","content":` + fmt.Sprintf("%q", userText) + `}]` + streamField + `}`) + return []byte(`{"model":"claude-opus-4-6","system":"legacy-system-prompt","messages":[{"role":"user","content":` + fmt.Sprintf("%q", userText) + `}]` + streamField + `}`) } if _, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "claude-sonnet-5", Payload: makePayload("messages-user", false), + Model: "claude-opus-4-6", Payload: makePayload("messages-user", false), }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); errExecute != nil { t.Fatalf("Execute() error = %v", errExecute) } if _, errCount := executor.CountTokens(context.Background(), auth, cliproxyexecutor.Request{ - Model: "claude-sonnet-5", Payload: makePayload("count-user", false), + Model: "claude-opus-4-6", Payload: makePayload("count-user", false), }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); errCount != nil { t.Fatalf("CountTokens() error = %v", errCount) } streamResult, errStream := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ - Model: "claude-sonnet-5", Payload: makePayload("stream-user", true), + Model: "claude-opus-4-6", Payload: makePayload("stream-user", true), }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) if errStream != nil { t.Fatalf("ExecuteStream() error = %v", errStream) @@ -1986,7 +2076,7 @@ func TestClaudeExecutor_CountTokensUpstreamCloakNeverPreservesCustomTool(t *test if got := gjson.GetBytes(upstreamBody, "tools.0.name").String(); got != "search_web" { t.Fatalf("count_tokens tool name = %q, want cloak=never passthrough", got) } - assertClaudeCredentialIdentity(t, upstreamBody, upstreamHeaders, deviceIDs, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + assertClaudeCountTokensIdentity(t, upstreamBody, upstreamHeaders) } func TestClaudeExecutor_CountTokensUpstreamConfirmedVSCodePreservesCustomTool(t *testing.T) { @@ -3376,7 +3466,7 @@ func TestClaudeUsesLegacySystemReminder(t *testing.T) { tests := map[string]bool{ "claude-opus-4-6": true, "claude-opus-4-7": true, - "claude-sonnet-5": true, + "claude-sonnet-5": false, "prefix/claude-sonnet-4-6": true, "claude-3-5-haiku-latest": true, "claude-opus-5": false, @@ -4373,7 +4463,7 @@ func TestClaudeExecutor_ExecuteOAuthCustomToolMCPAliasRoundTrip(t *testing.T) { if got := upstreamHeaders.Get("User-Agent"); got != "claude-cli/2.1.220 (external, cli)" { t.Fatalf("Messages User-Agent = %q, want CLI identity", got) } - wantBetas := defaultClaudeCodeCLIBetas + ",oauth-2025-04-20" + wantBetas := claudeCodeCLIBetas(payload, nil, true) if got := upstreamHeaders.Get("Anthropic-Beta"); got != wantBetas { t.Fatalf("Messages Anthropic-Beta = %q, want %q", got, wantBetas) } @@ -4450,7 +4540,7 @@ func TestClaudeExecutor_ExecuteStreamOAuthCustomToolMCPAliasRoundTrip(t *testing if got := upstreamHeaders.Get("User-Agent"); got != "claude-cli/2.1.220 (external, cli)" { t.Fatalf("streaming User-Agent = %q, want CLI identity", got) } - wantBetas := defaultClaudeCodeCLIBetas + ",oauth-2025-04-20" + wantBetas := claudeCodeCLIBetas(payload, nil, true) if got := upstreamHeaders.Get("Anthropic-Beta"); got != wantBetas { t.Fatalf("streaming Anthropic-Beta = %q, want %q", got, wantBetas) } @@ -4575,3 +4665,226 @@ func TestInsertClaudeMidConversationSystemMessage_IsIdempotent(t *testing.T) { t.Fatalf("mid-conversation system insertion is not idempotent:\nfirst: %s\nsecond: %s", first, second) } } + +// TestClaudeCodeCLIBetas_MatchesObservedClientMatrix pins the Anthropic-Beta +// baseline to the Claude Code 2.1.220 behavior captured on 2026-08-01 against +// api.anthropic.com with an isolated profile. +func TestClaudeCodeCLIBetas_MatchesObservedClientMatrix(t *testing.T) { + const constants = "claude-code-20250219,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05" + + tests := []struct { + name string + body string + requested map[string]bool + oauth bool + want string + }{ + { + name: "legacy model without tools omits both conditional betas", + body: `{"model":"claude-opus-4-6"}`, + want: constants + ",effort-2025-11-24", + }, + { + name: "context 1m sits right after claude-code, not at the end", + body: `{"model":"claude-opus-4-6"}`, + requested: map[string]bool{claudeContext1MBeta: true}, + want: "claude-code-20250219,context-1m-2025-08-07," + + "interleaved-thinking-2025-05-14,redact-thinking-2026-02-12," + + "thinking-token-count-2026-05-13,context-management-2025-06-27," + + "prompt-caching-scope-2026-01-05,effort-2025-11-24", + }, + { + name: "opus-5 1m variant reproduces the full observed order", + body: `{"model":"claude-opus-5","tools":[{"name":"Read"}]}`, + requested: map[string]bool{ + claudeContext1MBeta: true, + claudeServerSideFallbackBeta: true, + claudeFallbackCreditBeta: true, + }, + want: "claude-code-20250219,context-1m-2025-08-07," + + "interleaved-thinking-2025-05-14,redact-thinking-2026-02-12," + + "thinking-token-count-2026-05-13,context-management-2025-06-27," + + "prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07," + + "advanced-tool-use-2025-11-20,effort-2025-11-24," + + "server-side-fallback-2026-06-01,fallback-credit-2026-06-01", + }, + { + name: "structured outputs trails effort", + body: `{"model":"claude-opus-4-6"}`, + requested: map[string]bool{claudeStructuredOutputsBeta: true}, + want: constants + ",effort-2025-11-24,structured-outputs-2025-12-15", + }, + { + name: "unknown caller beta is not smuggled into the baseline", + body: `{"model":"claude-opus-4-6"}`, + requested: map[string]bool{"totally-made-up-2030-01-01": true}, + want: constants + ",effort-2025-11-24", + }, + { + name: "claude-sonnet-5 accepts role=system", + body: `{"model":"claude-sonnet-5"}`, + want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24", + }, + { + name: "claude-opus-4-8 accepts role=system", + body: `{"model":"claude-opus-4-8"}`, + want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24", + }, + { + name: "claude-fable-5 accepts role=system", + body: `{"model":"claude-fable-5"}`, + want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24", + }, + { + name: "claude-opus-4-7 stays on the reminder path", + body: `{"model":"claude-opus-4-7"}`, + want: constants + ",effort-2025-11-24", + }, + { + name: "oauth sits second and extended-cache-ttl last", + body: `{"model":"claude-opus-4-6","tools":[{"name":"Read"}]}`, + oauth: true, + want: "claude-code-20250219,oauth-2025-04-20," + + "interleaved-thinking-2025-05-14,redact-thinking-2026-02-12," + + "thinking-token-count-2026-05-13,context-management-2025-06-27," + + "prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20," + + "effort-2025-11-24,extended-cache-ttl-2025-04-11", + }, + { + name: "oauth precedes context-1m", + body: `{"model":"claude-opus-5","tools":[{"name":"Read"}]}`, + oauth: true, + requested: map[string]bool{ + claudeContext1MBeta: true, + claudeServerSideFallbackBeta: true, + claudeFallbackCreditBeta: true, + }, + want: "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07," + + "interleaved-thinking-2025-05-14,redact-thinking-2026-02-12," + + "thinking-token-count-2026-05-13,context-management-2025-06-27," + + "prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07," + + "advanced-tool-use-2025-11-20,effort-2025-11-24," + + "server-side-fallback-2026-06-01,fallback-credit-2026-06-01," + + "extended-cache-ttl-2025-04-11", + }, + { + name: "api key path sends neither oauth beta", + body: `{"model":"claude-opus-4-6"}`, + want: constants + ",effort-2025-11-24", + }, + { + name: "claude-haiku-4-5-20251001 stays on the reminder path", + body: `{"model":"claude-haiku-4-5-20251001"}`, + want: constants + ",effort-2025-11-24", + }, + { + name: "legacy model with tools adds advanced tool use only", + body: `{"model":"claude-sonnet-4-6","tools":[{"name":"Read"}]}`, + want: constants + ",advanced-tool-use-2025-11-20,effort-2025-11-24", + }, + { + name: "role=system model without tools adds mid conversation system only", + body: `{"model":"claude-opus-5"}`, + want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24", + }, + { + name: "role=system model with tools adds both in wire order", + body: `{"model":"claude-opus-5","tools":[{"name":"Read"}]}`, + want: constants + ",mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24", + }, + { + name: "empty tools array does not add advanced tool use", + body: `{"model":"claude-opus-4-6","tools":[]}`, + want: constants + ",effort-2025-11-24", + }, + { + name: "unknown future model keeps the optimistic role=system default", + body: `{"model":"claude-future-9"}`, + want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := claudeCodeCLIBetas([]byte(tt.body), tt.requested, tt.oauth); got != tt.want { + t.Fatalf("claudeCodeCLIBetas() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestApplyClaudeHeaders_StreamTransportNegotiation pins the observed 2.1.220 +// behaviour: a streaming request to api.anthropic.com negotiates exactly like a +// non-streaming one, because Anthropic selects SSE from the body. Other +// Anthropic-compatible upstreams keep the conservative SSE contract. +func TestApplyClaudeHeaders_StreamTransportNegotiation(t *testing.T) { + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-stream-accept"}} + body := []byte(`{"model":"claude-opus-4-6","stream":true}`) + + directReq := newClaudeHeaderTestRequest(t, http.Header{}) + if errApply := applyClaudeHeaders(directReq, auth, "key-stream-accept", true, nil, body, nil, http.Header{}, false); errApply != nil { + t.Fatalf("applyClaudeHeaders() error = %v", errApply) + } + if got, want := directReq.Header.Get("Accept"), "application/json"; got != want { + t.Fatalf("streaming Accept = %q, want %q to match the real client", got, want) + } + if got, want := directReq.Header.Get("Accept-Encoding"), "gzip, deflate, br, zstd"; got != want { + t.Fatalf("streaming Accept-Encoding = %q, want %q to match the real client", got, want) + } + + gatewayReq := httptest.NewRequest(http.MethodPost, "https://api.kimi.com/coding/v1/messages", nil) + gatewayReq = gatewayReq.WithContext(directReq.Context()) + if errApply := applyClaudeHeaders(gatewayReq, auth, "key-stream-accept", true, nil, body, nil, http.Header{}, false); errApply != nil { + t.Fatalf("applyClaudeHeaders() error = %v", errApply) + } + if got, want := gatewayReq.Header.Get("Accept"), "text/event-stream"; got != want { + t.Fatalf("gateway streaming Accept = %q, want %q", got, want) + } + if got, want := gatewayReq.Header.Get("Accept-Encoding"), "identity"; got != want { + t.Fatalf("gateway streaming Accept-Encoding = %q, want %q", got, want) + } +} + +func TestApplyClaudeHeaders_CallerBetasScopedByUpstream(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. + 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) + } + + // Other Anthropic-compatible upstreams keep caller betas functional. + gatewayReq := httptest.NewRequest(http.MethodPost, "https://api.kimi.com/coding/v1/messages", nil) + gatewayReq = gatewayReq.WithContext(directReq.Context()) + if errApply := applyClaudeHeaders(gatewayReq, auth, "key-caller-betas", false, nil, body, nil, incoming, false); errApply != nil { + t.Fatalf("applyClaudeHeaders() error = %v", errApply) + } + if got := gatewayReq.Header.Get("Anthropic-Beta"); !strings.Contains(got, "caller-only-beta") { + t.Fatalf("Anthropic-Beta = %q, want caller beta preserved on non-Anthropic upstream", got) + } +} + +// TestInjectClaudeCodeContextManagement pins the captured 2.1.220 object and the +// rule that a caller's own context_management is never overwritten. +func TestInjectClaudeCodeContextManagement(t *testing.T) { + const captured = `{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}` + + got := injectClaudeCodeContextManagement([]byte(`{"model":"claude-opus-4-6"}`)) + if diff := gjson.GetBytes(got, "context_management").Raw; diff != captured { + t.Fatalf("context_management = %s, want the captured object %s", diff, captured) + } + + callerOwned := []byte(`{"model":"claude-opus-4-6","context_management":{"edits":[]}}`) + if got := injectClaudeCodeContextManagement(callerOwned); !bytes.Equal(got, callerOwned) { + t.Fatalf("caller context_management was modified: %s", got) + } +} diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index b2b7212eb..ae3512fe8 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -14,6 +14,7 @@ import ( sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { @@ -179,12 +180,11 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy body, _ = prepareClaudeOAuthToolNamesForUpstream(body, mcpAliases) } body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel) - if oauthToken { - var errIdentity error - body, _, errIdentity = helps.ApplyClaudeCredentialMetadata(body, auth, claudeSessionID) - if errIdentity != nil { - return cliproxyexecutor.Response{}, fmt.Errorf("apply Claude credential metadata: %w", errIdentity) - } + // 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 isAnthropicUpstreamBase(baseURL) { + body, _ = sjson.DeleteBytes(body, "metadata") } if cchSigning { fallbackBilling := claudeCCHFallbackBillingHeader(ctx, e.cfg, body, claudeCodeDetection.Entrypoint) @@ -199,7 +199,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy if err != nil { return cliproxyexecutor.Response{}, err } - if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil { + if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, body, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil { return cliproxyexecutor.Response{}, errHeaders } var authID, authLabel, authType, authValue string