From a8bbbea2b9b53149f90710d59668a53b4b0427a4 Mon Sep 17 00:00:00 2001 From: sususu Date: Tue, 11 Aug 2026 07:47:42 +0000 Subject: [PATCH] fix(claude): recognize and pass through native Haiku helpers Claude Code issues measured Haiku helper requests that intentionally omit the claude-code beta and, for the minimal shape, the system field. Treat those profiles as confirmed native clients so CPA does not cloak or rewrite them. Accept the native metadata builder optional parent_session_id, require platform headers only for presence and software baseline rather than exact equality, derive timeout and version from the same header defaults the emitter uses, and keep discriminating signals strict: exact beta allowlist, body shape, lowercase hex CCH, and session binding. Preserve helper transport headers, omit synthetic stream false on markerless helpers, and gate the billing and CCH fallback on system presence so a later pipeline-attached system prompt cannot go upstream unsigned while the measured no-system wire stays intact. --- internal/runtime/executor/claude_executor_execute.go | 27 +++++++++++++++++++++++---- internal/runtime/executor/claude_executor_native_helper_test.go | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/runtime/executor/claude_executor_request.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- internal/runtime/executor/claude_executor_stream.go | 20 +++++++++++++++++--- internal/runtime/executor/claude_executor_test.go | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- internal/runtime/executor/claude_signing.go | 13 +++++++++++++ internal/runtime/executor/helps/claude_client_detection.go | 373 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ internal/runtime/executor/helps/claude_client_detection_test.go | 379 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 file(s) changed, 1278 insertion(s)(+), 33 deletion(s)(-) 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 @@ -108,7 +108,7 @@ // Disable thinking if tool_choice forces tool use (Anthropic API constraint) body = disableThinkingIfToolChoiceForced(body) body = reconcileClaudeCodeContextManagement(body, contextManagementState) - body = normalizeClaudeSamplingForUpstream(body) + body = normalizeClaudeSamplingForUpstream(body, confirmedClaudeCode) // Default cache_control for translated entrypoints (Responses/Chat/Gemini) and other // non-native callers. Confirmed native Claude Code owns its marker placement and must @@ -145,7 +145,12 @@ body = normalizeCacheControlTTL(body) // Payload rules and other request processing may rewrite stream. Keep the // upstream body, transport headers, and response parser on one authority. - body = helps.SetBoolIfDifferent(body, "stream", upstreamStream) + // Native non-stream Haiku helper requests omit stream rather than sending + // false, so preserve that measured wire shape when the transport agrees. + streamField := gjson.GetBytes(body, "stream") + if !claudeCodeDetection.HelperProfile || streamField.Exists() || upstreamStream { + body = helps.SetBoolIfDifferent(body, "stream", upstreamStream) + } // Extract betas from body and convert to header var extraBetas []string @@ -166,7 +171,9 @@ } cchBilling := "" if cchSigning { - cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) + if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) { + cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) + } bodyForUpstream, err = finalizeAnthropicMessagesBodyCCH(bodyForUpstream, cchBilling) if err != nil { return resp, fmt.Errorf("finalize Claude CCH: %w", err) @@ -177,7 +184,19 @@ if err != nil { return resp, err } - if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, upstreamStream, extraBetas, bodyForUpstream, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil { + if errHeaders := applyClaudeHeadersWithNativeProfile( + httpReq, + auth, + apiKey, + upstreamStream, + extraBetas, + bodyForUpstream, + e.cfg, + incomingHeaders, + confirmedClaudeCode && !cloaked, + claudeCodeDetection.HelperProfile, + claudeSessionID, + ); errHeaders != nil { return resp, errHeaders } fastRequest := isAnthropicUpstreamBase(baseURL) && claudeRequestIsFast(httpReq, bodyForUpstream) diff --git a/internal/runtime/executor/claude_executor_native_helper_test.go b/internal/runtime/executor/claude_executor_native_helper_test.go new file mode 100644 --- /dev/null +++ b/internal/runtime/executor/claude_executor_native_helper_test.go @@ -0,0 +1,288 @@ +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 ( + claudeNativeHelperSessionID = "11111111-2222-4333-8444-555555555555" + claudeNativeHelperUserID = `{"device_id":"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","account_uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","session_id":"11111111-2222-4333-8444-555555555555"}` + claudeNativeHelperCoreBetas = "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" +) + +func claudeNativeHelperHeaders(betas, compression string, structured bool) http.Header { + headers := http.Header{ + "Accept": {"application/json"}, + "Accept-Encoding": {compression}, + "Content-Type": {"application/json"}, + "User-Agent": {"claude-cli/2.1.220 (external, cli)"}, + "X-App": {"cli"}, + "Anthropic-Beta": {betas}, + "Anthropic-Version": {"2023-06-01"}, + "Anthropic-Dangerous-Direct-Browser-Access": {"true"}, + "X-Claude-Code-Session-Id": {claudeNativeHelperSessionID}, + "X-Client-Request-Id": {"66666666-7777-4888-8999-aaaaaaaaaaaa"}, + "X-Stainless-Lang": {"js"}, + "X-Stainless-Runtime": {"node"}, + "X-Stainless-Package-Version": {"0.94.0"}, + "X-Stainless-Runtime-Version": {"v26.3.0"}, + "X-Stainless-OS": {"MacOS"}, + "X-Stainless-Arch": {"arm64"}, + "X-Stainless-Retry-Count": {"0"}, + "X-Stainless-Timeout": {"600"}, + } + if structured { + headers.Set("X-Stainless-Async", "async") + } + canonical := make(http.Header, len(headers)) + for name, values := range headers { + for _, value := range values { + canonical.Add(name, value) + } + } + return canonical +} + +func claudeNativeHelperOAuthAuth(baseURL string) *cliproxyauth.Auth { + return &cliproxyauth.Auth{ + ID: "native-helper-oauth", + Attributes: map[string]string{ + "api_key": "sk-ant-oat-native-helper", + "base_url": baseURL, + }, + Metadata: claudeOAuthTestMetadata(), + } +} + +func TestApplyClaudeHeadersPreservesAsyncOnlyForConfirmedNative(t *testing.T) { + for _, test := range []struct { + name string + confirmed bool + wantAsync string + }{ + {name: "confirmed native", confirmed: true, wantAsync: "async"}, + {name: "unconfirmed caller", confirmed: false}, + } { + t.Run(test.name, func(t *testing.T) { + request, errRequest := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", nil) + if errRequest != nil { + t.Fatal(errRequest) + } + incoming := http.Header{"X-Stainless-Async": {"async"}} + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "test-api-key"}} + if errHeaders := applyClaudeHeaders( + request, + auth, + "test-api-key", + true, + nil, + []byte(`{"model":"claude-haiku-4-5-20251001"}`), + &config.Config{}, + incoming, + test.confirmed, + claudeNativeHelperSessionID, + ); errHeaders != nil { + t.Fatalf("applyClaudeHeaders() error = %v", errHeaders) + } + if got := request.Header.Get("X-Stainless-Async"); got != test.wantAsync { + t.Fatalf("X-Stainless-Async = %q, want %q", got, test.wantAsync) + } + }) + } +} + +func TestClaudeExecutorMinimalNativeHelperPreservesMarkerlessWire(t *testing.T) { + var upstreamBody []byte + var upstreamHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamBody, _ = io.ReadAll(r.Body) + upstreamHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-haiku-4-5-20251001","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer server.Close() + + payload := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"helper probe"}],"metadata":{"user_id":"` + strings.ReplaceAll(claudeNativeHelperUserID, `"`, `\"`) + `"}}`) + headers := claudeNativeHelperHeaders(claudeNativeHelperCoreBetas, "gzip", false) + executor := NewClaudeExecutor(&config.Config{}) + _, errExecute := executor.Execute(context.Background(), claudeNativeHelperOAuthAuth(server.URL), cliproxyexecutor.Request{ + Model: "claude-haiku-4-5-20251001", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + OriginalRequest: payload, + Headers: headers, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + + for _, path := range []string{"system", "stream", "context_management", "output_config"} { + if got := gjson.GetBytes(upstreamBody, path); got.Exists() { + t.Fatalf("helper body unexpectedly contains %s=%s: %s", path, got.Raw, upstreamBody) + } + } + if bytes.Contains(upstreamBody, []byte(`"cache_control"`)) { + t.Fatalf("helper body unexpectedly contains cache_control: %s", upstreamBody) + } + if got := gjson.GetBytes(upstreamBody, "messages.0.content").String(); got != "helper probe" { + t.Fatalf("messages.0.content = %q, want preserved string", got) + } + if !bytes.HasPrefix(upstreamBody, []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":`)) { + t.Fatalf("helper top-level order changed: %s", upstreamBody) + } + assertClaudeNativeHelperHeaders(t, upstreamHeaders, headers) +} + +func TestClaudeExecutorStructuredNativeHelperPreservesStreamProfile(t *testing.T) { + var upstreamBody []byte + var upstreamHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamBody, _ = io.ReadAll(r.Body) + upstreamHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-haiku-4-5-20251001\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") + })) + defer server.Close() + + betas := claudeNativeHelperCoreBetas + ",structured-outputs-2025-12-15" + payload := []byte(`{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":[{"type":"text","text":"helper probe"}]}],"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."},{"type":"text","text":"Return a short title."}],"tools":[],"metadata":{"user_id":"` + strings.ReplaceAll(claudeNativeHelperUserID, `"`, `\"`) + `"},"max_tokens":32000,"thinking":{"type":"disabled"},"temperature":1,"output_config":{"format":{"type":"json_schema","schema":{"type":"object","properties":{"title":{"type":"string"}},"required":["title"],"additionalProperties":false}}},"stream":true}`) + headers := claudeNativeHelperHeaders(betas, "gzip, deflate, br, zstd", true) + executor := NewClaudeExecutor(&config.Config{}) + result, errStream := executor.ExecuteStream(context.Background(), claudeNativeHelperOAuthAuth(server.URL), cliproxyexecutor.Request{ + Model: "claude-haiku-4-5-20251001", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + OriginalRequest: payload, + Headers: headers, + }) + 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 got := gjson.GetBytes(upstreamBody, "system.#").Int(); got != 3 { + t.Fatalf("system block count = %d, want native 3: %s", got, upstreamBody) + } + if !bytes.HasPrefix(upstreamBody, []byte(`{"model":"claude-haiku-4-5-20251001","messages":`)) { + t.Fatalf("structured helper top-level order changed: %s", upstreamBody) + } + for _, path := range []string{"context_management", "output_config.effort"} { + if got := gjson.GetBytes(upstreamBody, path); got.Exists() { + t.Fatalf("structured helper unexpectedly contains %s=%s: %s", path, got.Raw, upstreamBody) + } + } + if bytes.Contains(upstreamBody, []byte(`"cache_control"`)) { + t.Fatalf("structured helper unexpectedly contains cache_control: %s", upstreamBody) + } + if got := gjson.GetBytes(upstreamBody, "stream").Bool(); !got { + t.Fatalf("structured helper stream = false, want true: %s", upstreamBody) + } + if got := gjson.GetBytes(upstreamBody, "system.0.text").String(); strings.Contains(got, "cch=00000") || !strings.Contains(got, " cch=") { + t.Fatalf("structured helper billing CCH was not re-signed: %q", got) + } + assertClaudeNativeHelperHeaders(t, upstreamHeaders, headers) +} + +func assertClaudeNativeHelperHeaders(t *testing.T, got, incoming http.Header) { + t.Helper() + if got.Get("Anthropic-Beta") != incoming.Get("Anthropic-Beta") { + t.Fatalf("Anthropic-Beta = %q, want exact native helper profile %q", got.Get("Anthropic-Beta"), incoming.Get("Anthropic-Beta")) + } + if strings.Contains(got.Get("Anthropic-Beta"), claudeExtendedCacheTTLBeta) || strings.Contains(got.Get("Anthropic-Beta"), claudeCodeBeta) { + t.Fatalf("Anthropic-Beta gained standard Claude Code cache betas: %q", got.Get("Anthropic-Beta")) + } + for _, name := range []string{ + "Accept", + "Accept-Encoding", + "Content-Type", + "User-Agent", + "X-App", + "Anthropic-Version", + "Anthropic-Dangerous-Direct-Browser-Access", + "X-Claude-Code-Session-Id", + "X-Client-Request-Id", + "X-Stainless-Async", + "X-Stainless-Lang", + "X-Stainless-Runtime", + "X-Stainless-Package-Version", + "X-Stainless-Runtime-Version", + "X-Stainless-OS", + "X-Stainless-Arch", + "X-Stainless-Retry-Count", + "X-Stainless-Timeout", + } { + gotValue := claudeNativeHelperHeaderValue(got, name) + wantValue := claudeNativeHelperHeaderValue(incoming, name) + if gotValue != wantValue { + t.Fatalf("%s = %q, want preserved %q", name, gotValue, wantValue) + } + } +} + +func claudeNativeHelperHeaderValue(headers http.Header, name string) string { + for key, values := range headers { + if strings.EqualFold(key, name) { + return strings.Join(values, ",") + } + } + return "" +} + +// The measured minimal helper has no system field at all, so injecting a billing +// header would itself be the deviation. Keying the fallback on system presence means +// that if a payload rule later attaches a system prompt, the billing header and its +// CCH come back instead of shipping a system block native would never send unsigned. +func TestClaudeBodyNeedsBillingFallbackTracksSystemPresence(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + { + name: "measured minimal helper has no system", + body: `{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"probe"}]}`, + want: false, + }, + { + name: "structured helper carries its own billing header", + body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220; cc_entrypoint=cli; cch=00000;"}]}`, + want: true, + }, + { + name: "pipeline attached a system prompt without a billing header", + body: `{"system":[{"type":"text","text":"injected by a payload rule"}]}`, + want: true, + }, + { + name: "string system prompt also needs the fallback", + body: `{"system":"injected by a payload rule"}`, + want: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := claudeBodyNeedsBillingFallback([]byte(test.body)); got != test.want { + t.Fatalf("claudeBodyNeedsBillingFallback() = %v, want %v", got, test.want) + } + }) + } +} 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 @@ -369,15 +369,49 @@ } // normalizeClaudeSamplingForUpstream keeps Anthropic message requests valid. -func normalizeClaudeSamplingForUpstream(body []byte) []byte { - body, _ = sjson.DeleteBytes(body, "temperature") - body, _ = sjson.DeleteBytes(body, "top_p") - - thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) - switch thinkingType { +// +// Translated and cloaked callers keep the conservative normalization: their +// sampling knobs come from a protocol that was not written for Anthropic, and +// Anthropic rejects several combinations outright, so neither temperature nor +// top_p is worth forwarding. +// +// A confirmed native Claude Code client owns its own wire, exactly like +// cache_control placement. The measured structured Haiku helper sends +// "temperature":1 and claudeCodeHelperShapeStructured keys on it, so stripping +// it would emit a shape no native client ever produces. Keep what the caller +// sent and drop only what Anthropic actually rejects (verified live): +// - thinking active: temperature must be 1, top_p must be >= 0.95, top_k unset +// - otherwise: temperature and top_p cannot both be specified +func normalizeClaudeSamplingForUpstream(body []byte, nativeOwned bool) []byte { + thinkingActive := false + switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) { case "enabled", "adaptive", "auto": + thinkingActive = true + } + + if !nativeOwned { + body, _ = sjson.DeleteBytes(body, "temperature") body, _ = sjson.DeleteBytes(body, "top_p") + if thinkingActive { + body, _ = sjson.DeleteBytes(body, "top_k") + } + return body + } + + if thinkingActive { + if temperature := gjson.GetBytes(body, "temperature"); temperature.Exists() && temperature.Num != 1 { + body, _ = sjson.DeleteBytes(body, "temperature") + } + if topP := gjson.GetBytes(body, "top_p"); topP.Exists() && topP.Num < 0.95 { + body, _ = sjson.DeleteBytes(body, "top_p") + } body, _ = sjson.DeleteBytes(body, "top_k") + return body + } + // Anthropic accepts either one but not both; temperature is the knob native + // Claude Code actually sends, so top_p is the one that gives way. + if gjson.GetBytes(body, "temperature").Exists() && gjson.GetBytes(body, "top_p").Exists() { + body, _ = sjson.DeleteBytes(body, "top_p") } return body } @@ -549,6 +583,34 @@ } 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, + auth, + apiKey, + stream, + extraBetas, + body, + cfg, + incomingHeaders, + confirmedClaudeCode, + false, + sessionIDs..., + ) +} + +func applyClaudeHeadersWithNativeProfile( + r *http.Request, + auth *cliproxyauth.Auth, + apiKey string, + stream bool, + extraBetas []string, + body []byte, + cfg *config.Config, + incomingHeaders http.Header, + confirmedClaudeCode bool, + helperProfile bool, + sessionIDs ...string, +) error { if r == nil { return nil } @@ -598,7 +660,9 @@ } if confirmedClaudeCode && incomingBetas != "" { baseBetas = incomingBetas - if oauthToken { + // Measured Haiku helper requests already carry the exact credential + // beta profile and intentionally omit extended-cache-ttl. + if oauthToken && !helperProfile { if countTokens { baseBetas = withClaudeCountTokensOAuthBeta(baseBetas) } else { @@ -657,6 +721,11 @@ identityHeader("X-Stainless-Retry-Count", "0") identityHeader("X-Stainless-Runtime", "node") identityHeader("X-Stainless-Lang", "js") + // Native async SDK helpers add this header independently of body.stream. + // Preserve it only after the complete native-client detector succeeds. + if confirmedClaudeCode && incomingHeaders.Get("X-Stainless-Async") == "async" { + r.Header.Set("X-Stainless-Async", "async") + } // 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 { @@ -687,18 +756,24 @@ identityHeader("X-Claude-Code-Session-Id", sessionID) } // Per-request UUID, matches Claude Code's x-client-request-id for first-party API. - if isAnthropicBase { + // identityHeader prefers the incoming value for a confirmed client, so a confirmed + // helper keeps its own native request ID and this fresh UUID only covers a caller + // that sent none. Helpers opt in on custom gateways too. + if isAnthropicBase || helperProfile { identityHeader("x-client-request-id", uuid.New().String()) } r.Header.Set("Connection", "keep-alive") - // 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. + // Regular Claude Code requests negotiate transport identically for streaming + // and non-streaming requests. Measured Haiku helpers are the exception: their + // minimal non-stream request offers gzip only, while the structured streaming + // helper offers the full compression set. Confirmed helpers preserve the + // incoming native values. applyTransportNegotiation := func() { + if helperProfile { + identityHeader("Accept", "application/json") + identityHeader("Accept-Encoding", "gzip") + return + } if stream && !isAnthropicBase { // Other Anthropic-compatible upstreams (Kimi, custom gateways) may select // SSE from Accept and need not compress predictably, so they keep the 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 @@ -112,7 +112,7 @@ // Disable thinking if tool_choice forces tool use (Anthropic API constraint) body = disableThinkingIfToolChoiceForced(body) body = reconcileClaudeCodeContextManagement(body, contextManagementState) - body = normalizeClaudeSamplingForUpstream(body) + body = normalizeClaudeSamplingForUpstream(body, confirmedClaudeCode) // Default cache_control for translated entrypoints (Responses/Chat/Gemini) and other // non-native callers. Confirmed native Claude Code owns its marker placement and must @@ -164,7 +164,9 @@ } cchBilling := "" if cchSigning { - cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) + if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) { + cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) + } bodyForUpstream, err = finalizeAnthropicMessagesBodyCCH(bodyForUpstream, cchBilling) if err != nil { return nil, fmt.Errorf("finalize Claude CCH: %w", err) @@ -175,7 +177,19 @@ if err != nil { return nil, err } - if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, true, extraBetas, bodyForUpstream, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil { + if errHeaders := applyClaudeHeadersWithNativeProfile( + httpReq, + auth, + apiKey, + true, + extraBetas, + bodyForUpstream, + e.cfg, + incomingHeaders, + confirmedClaudeCode && !cloaked, + claudeCodeDetection.HelperProfile, + claudeSessionID, + ); errHeaders != nil { return nil, errHeaders } fastRequest := isAnthropicUpstreamBase(baseURL) && claudeRequestIsFast(httpReq, bodyForUpstream) 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 @@ -4489,7 +4489,7 @@ func TestNormalizeClaudeSamplingForUpstream_RemovesTemperature(t *testing.T) { payload := []byte(`{"temperature":0,"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`) - out := normalizeClaudeSamplingForUpstream(payload) + out := normalizeClaudeSamplingForUpstream(payload, false) if gjson.GetBytes(out, "temperature").Exists() { t.Fatalf("temperature should be removed") @@ -4498,7 +4498,7 @@ func TestNormalizeClaudeSamplingForUpstream_RemovesTemperatureWithThinkingEnabled(t *testing.T) { payload := []byte(`{"temperature":0.2,"thinking":{"type":"enabled","budget_tokens":2048}}`) - out := normalizeClaudeSamplingForUpstream(payload) + out := normalizeClaudeSamplingForUpstream(payload, false) if gjson.GetBytes(out, "temperature").Exists() { t.Fatalf("temperature should be removed") @@ -4507,7 +4507,7 @@ func TestNormalizeClaudeSamplingForUpstream_RemovesTopPAndTopKForThinking(t *testing.T) { payload := []byte(`{"temperature":0.2,"top_p":0.9,"top_k":40,"thinking":{"type":"adaptive"}}`) - out := normalizeClaudeSamplingForUpstream(payload) + out := normalizeClaudeSamplingForUpstream(payload, false) if gjson.GetBytes(out, "temperature").Exists() { t.Fatalf("temperature should be removed") @@ -4522,7 +4522,7 @@ func TestNormalizeClaudeSamplingForUpstream_NoThinkingRemovesTemperatureAndTopP(t *testing.T) { payload := []byte(`{"temperature":0,"top_p":0.9,"top_k":40,"messages":[{"role":"user","content":"hi"}]}`) - out := normalizeClaudeSamplingForUpstream(payload) + out := normalizeClaudeSamplingForUpstream(payload, false) if gjson.GetBytes(out, "temperature").Exists() { t.Fatalf("temperature should be removed") @@ -4538,13 +4538,109 @@ func TestNormalizeClaudeSamplingForUpstream_AfterForcedToolChoiceRemovesTemperature(t *testing.T) { payload := []byte(`{"temperature":0,"thinking":{"type":"adaptive"},"output_config":{"effort":"max"},"tool_choice":{"type":"any"}}`) out := disableThinkingIfToolChoiceForced(payload) - out = normalizeClaudeSamplingForUpstream(out) + out = normalizeClaudeSamplingForUpstream(out, false) if gjson.GetBytes(out, "thinking").Exists() { t.Fatalf("thinking should be removed when tool_choice forces tool use") } if gjson.GetBytes(out, "temperature").Exists() { t.Fatalf("temperature should be removed") + } +} + +// The measured structured Haiku helper sends "temperature":1, and +// claudeCodeHelperShapeStructured keys on exactly that value. Stripping it would +// make CPA emit a shape no native client produces, so a confirmed native caller +// must keep it. +func TestNormalizeClaudeSamplingForUpstreamNativeKeepsMeasuredHelperTemperature(t *testing.T) { + // Top-level key order and values mirror the measured structured helper. + payload := []byte(`{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":[{"type":"text","text":"helper probe"}]}],"system":[{"type":"text","text":"Return a short title."}],"tools":[],"metadata":{"user_id":"u"},"max_tokens":32000,"thinking":{"type":"disabled"},"temperature":1,"output_config":{"format":{"type":"json_schema"}},"stream":true}`) + if got := gjson.GetBytes(payload, "temperature"); !got.Exists() || got.Num != 1 { + t.Fatalf("measured helper fixture should carry temperature=1, got %q", got.Raw) + } + + out := normalizeClaudeSamplingForUpstream(payload, true) + + if got := gjson.GetBytes(out, "temperature"); !got.Exists() || got.Num != 1 { + t.Fatalf("confirmed native must preserve the measured temperature, got %q", got.Raw) + } +} + +// Anthropic's real constraints, verified against the live API: with thinking +// active temperature must be 1, top_p must be >= 0.95 and top_k must be unset; +// otherwise temperature and top_p cannot both be specified. Preserving the +// native wire must never forward a combination that would 400. +func TestNormalizeClaudeSamplingForUpstreamNativeDropsOnlyRejectedCombinations(t *testing.T) { + tests := []struct { + name string + payload string + keep map[string]float64 + dropped []string + }{ + { + name: "thinking off keeps every accepted knob", + payload: `{"temperature":0.5,"top_k":40}`, + keep: map[string]float64{"temperature": 0.5, "top_k": 40}, + }, + { + name: "thinking off drops top_p when temperature is also set", + payload: `{"temperature":0.5,"top_p":0.9}`, + keep: map[string]float64{"temperature": 0.5}, + dropped: []string{"top_p"}, + }, + { + name: "thinking off keeps a lone top_p", + payload: `{"top_p":0.9}`, + keep: map[string]float64{"top_p": 0.9}, + }, + { + name: "thinking disabled is not thinking", + payload: `{"temperature":1,"thinking":{"type":"disabled"}}`, + keep: map[string]float64{"temperature": 1}, + }, + { + name: "thinking enabled keeps temperature 1", + payload: `{"temperature":1,"thinking":{"type":"enabled","budget_tokens":1024}}`, + keep: map[string]float64{"temperature": 1}, + }, + { + name: "thinking enabled drops temperature that is not 1", + payload: `{"temperature":0.5,"thinking":{"type":"enabled","budget_tokens":1024}}`, + dropped: []string{"temperature"}, + }, + { + name: "thinking enabled keeps top_p at or above 0.95", + payload: `{"top_p":0.99,"thinking":{"type":"enabled","budget_tokens":1024}}`, + keep: map[string]float64{"top_p": 0.99}, + }, + { + name: "thinking enabled drops top_p below 0.95", + payload: `{"top_p":0.9,"thinking":{"type":"enabled","budget_tokens":1024}}`, + dropped: []string{"top_p"}, + }, + { + name: "thinking enabled always drops top_k", + payload: `{"top_k":40,"thinking":{"type":"enabled","budget_tokens":1024}}`, + dropped: []string{"top_k"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := normalizeClaudeSamplingForUpstream([]byte(tc.payload), true) + + for field, want := range tc.keep { + got := gjson.GetBytes(out, field) + if !got.Exists() || got.Num != want { + t.Fatalf("%s = %q, want %v preserved", field, got.Raw, want) + } + } + for _, field := range tc.dropped { + if got := gjson.GetBytes(out, field); got.Exists() { + t.Fatalf("%s = %q, want dropped because Anthropic rejects it", field, got.Raw) + } + } + }) } } 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 @@ -56,6 +56,19 @@ return signAnthropicMessagesBody(bodyWithPlaceholder) } +// claudeBodyNeedsBillingFallback reports whether a confirmed native helper request +// still needs CPA's billing-header fallback. +// +// The measured minimal helper carries no system field at all, which is exactly the +// native wire shape, so injecting a billing header there would be the deviation. +// Keying on "system is absent" rather than "no billing header present" means that +// if anything later in the pipeline (a payload rule, for instance) does attach a +// system prompt, the fallback comes back and the request cannot go upstream with a +// system block that native would never send unsigned. +func claudeBodyNeedsBillingFallback(body []byte) bool { + return gjson.GetBytes(body, "system").Exists() +} + func ensureClaudeBillingHeaderCCHPlaceholder(body []byte, fallbackBilling string) ([]byte, error) { billing := gjson.GetBytes(body, "system.0.text") if billing.Type != gjson.String || !strings.HasPrefix(billing.String(), "x-anthropic-billing-header:") { diff --git a/internal/runtime/executor/helps/claude_client_detection.go b/internal/runtime/executor/helps/claude_client_detection.go --- a/internal/runtime/executor/helps/claude_client_detection.go +++ b/internal/runtime/executor/helps/claude_client_detection.go @@ -1,12 +1,29 @@ package helps import ( + "bytes" + "encoding/json" "net/http" "regexp" + "sort" "strings" + "github.com/google/uuid" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/tidwall/gjson" +) + +const ( + // claudeAnthropicVersion is the only Anthropic-Version Claude Code sends. + claudeAnthropicVersion = "2023-06-01" + // claudeDefaultStainlessTimeout is the X-Stainless-Timeout every measured + // native helper sends. It is deliberately NOT read from + // claude-header-defaults.timeout: applyClaudeHeaders routes a confirmed client + // through misc.EnsureHeader, which prefers the incoming header and only falls + // back to the configured value when the caller sent none. A confirmed helper + // therefore always forwards its own 600, so comparing against the operator + // value would make any non-600 configuration reject every genuine helper. + claudeDefaultStainlessTimeout = "600" ) var ( @@ -52,6 +69,39 @@ "claude-vscode": true, } +type claudeCodeHelperShape uint8 + +const ( + claudeCodeHelperShapeNone claudeCodeHelperShape = iota + claudeCodeHelperShapeMinimal + claudeCodeHelperShapeStructured + + claudeCodeHelperModel = "claude-haiku-4-5-20251001" +) + +// These are the six exact beta sequences observed across 14 markerless native +// Claude Code 2.1.220 Haiku helper requests. Keeping the allowlist exact avoids +// turning the helper exception into a generic no-claude-code-beta bypass. +var measuredClaudeCodeHelperBetaProfiles = map[string]claudeCodeHelperShape{ + claudeCodeHelperBetaProfile(true): claudeCodeHelperShapeMinimal, + claudeCodeHelperBetaProfile(false): claudeCodeHelperShapeMinimal, + claudeCodeHelperBetaProfile(true, + "advisor-tool-2026-03-01", + "structured-outputs-2025-12-15", + "cache-diagnosis-2026-04-07", + ): claudeCodeHelperShapeStructured, + claudeCodeHelperBetaProfile(true, + "structured-outputs-2025-12-15", + "fallback-credit-2026-06-01", + ): claudeCodeHelperShapeStructured, + claudeCodeHelperBetaProfile(true, + "structured-outputs-2025-12-15", + ): claudeCodeHelperShapeStructured, + claudeCodeHelperBetaProfile(false, + "structured-outputs-2025-12-15", + ): claudeCodeHelperShapeStructured, +} + // ClaudeCodeRequestDetection records the strong signals and first-party // subclient identity used to distinguish an official Claude Code request from // a client that only copied its User-Agent. @@ -63,17 +113,18 @@ UserAgent bool BetasPresent bool MetadataUserID bool + HelperProfile bool Entrypoint string Subclient string AgentSDKVersion string } // DetectClaudeCodeRequest first mirrors CCH's strong-signal contract, then -// applies CPA's native-client policy. Messages requests require all four strong -// signals; count_tokens omits metadata.user_id and uses the three header signals. -// Only Anthropic first-party product entrypoints are confirmed for pass-through. -// Generic sdk-ts/sdk-py Agent SDK entrypoints remain unconfirmed and receive -// CLI cloaking; native Claude Code print mode keeps its original sdk-cli identity. +// applies CPA's native-client policy. Standard Messages requests require all +// four strong signals; count_tokens omits metadata.user_id. A separate narrow +// profile recognizes measured native Haiku helper requests that intentionally +// omit claude-code-20250219. Generic sdk-ts/sdk-py Agent SDK entrypoints remain +// unconfirmed and receive CLI cloaking. func DetectClaudeCodeRequest(headers http.Header, payload []byte, countTokens bool, configs ...*config.Config) ClaudeCodeRequestDetection { var cfg *config.Config if len(configs) > 0 { @@ -92,10 +143,320 @@ metadataUserID := gjson.GetBytes(payload, "metadata.user_id") detection.MetadataUserID = metadataUserID.Exists() && metadataUserID.Type == gjson.String && isValidUserID(metadataUserID.String()) - detection.StrongSignals = detection.XAppCLI && detection.UserAgent && detection.BetasPresent && (countTokens || detection.MetadataUserID) detection.NativeClient = nativeClaudeEntrypoints[entrypoint] + standardSignals := detection.XAppCLI && detection.UserAgent && detection.BetasPresent && (countTokens || detection.MetadataUserID) + detection.HelperProfile = detection.NativeClient && matchesMeasuredClaudeCodeHelperProfile(headers, payload, countTokens, detection, cfg) + detection.StrongSignals = standardSignals || detection.HelperProfile detection.Confirmed = detection.StrongSignals && detection.NativeClient return detection +} + +func claudeCodeHelperBetaProfile(redactThinking bool, trailing ...string) string { + betas := []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"} + if redactThinking { + betas = append(betas, "redact-thinking-2026-02-12") + } + betas = append(betas, + "thinking-token-count-2026-05-13", + "context-management-2025-06-27", + "prompt-caching-scope-2026-01-05", + ) + betas = append(betas, trailing...) + return strings.Join(betas, ",") +} + +func matchesMeasuredClaudeCodeHelperProfile( + headers http.Header, + payload []byte, + countTokens bool, + detection ClaudeCodeRequestDetection, + cfg *config.Config, +) bool { + if countTokens || + detection.Entrypoint != "cli" || + detection.BetasPresent || + !detection.XAppCLI || + !detection.UserAgent || + !detection.MetadataUserID { + return false + } + + shape := measuredClaudeCodeHelperBetaProfiles[normalizedClaudeBetaHeader(headers)] + if shape == claudeCodeHelperShapeNone || measuredClaudeCodeHelperBodyShape(payload) != shape { + return false + } + if !measuredClaudeCodeHelperHeadersMatch(headers, cfg, shape) { + return false + } + return measuredClaudeCodeHelperSessionMatches(headers, payload) +} + +// normalizedClaudeBetaHeader joins every Anthropic-Beta value in wire order. +// Values() is tried first so canonical headers keep a deterministic order; the +// case-insensitive fallback only exists for hand-built header maps that store a +// non-canonical key, where ranging the map alone would be order-dependent. +func normalizedClaudeBetaHeader(headers http.Header) string { + if headers == nil { + return "" + } + values := headers.Values("Anthropic-Beta") + if len(values) == 0 { + keys := make([]string, 0, 2) + for key := range headers { + if strings.EqualFold(key, "Anthropic-Beta") { + keys = append(keys, key) + } + } + sort.Strings(keys) + for _, key := range keys { + values = append(values, headers[key]...) + } + } + betas := make([]string, 0, 12) + for _, value := range values { + for _, beta := range strings.Split(value, ",") { + if beta = strings.TrimSpace(beta); beta != "" { + betas = append(betas, beta) + } + } + } + return strings.Join(betas, ",") +} + +// measuredClaudeCodeHelperHeadersMatch validates the helper transport envelope. +// +// Platform and software-version headers are deliberately NOT compared for +// equality. The device-profile pipeline this detector feeds already pins OS/Arch +// to the configured baseline and replaces a non-baseline software tuple instead +// of rejecting it, so demanding equality here would classify a genuine Claude +// Code helper from Windows/Linux, or from a different Node or SDK build, as a +// foreign client and cloak it. Values that carry real discriminating power - the +// exact beta allowlist, the body shape, the billing CCH and the session binding - +// stay strict. +func measuredClaudeCodeHelperHeadersMatch(headers http.Header, cfg *config.Config, shape claudeCodeHelperShape) bool { + profile := defaultClaudeDeviceProfile(cfg) + expected := map[string]string{ + "Accept": "application/json", + "Content-Type": "application/json", + "X-Stainless-Lang": "js", + "X-Stainless-Runtime": "node", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Timeout": claudeDefaultStainlessTimeout, + "Anthropic-Version": claudeAnthropicVersion, + "Anthropic-Dangerous-Direct-Browser-Access": "true", + } + for name, want := range expected { + if headerValue(headers, name) != want { + return false + } + } + // Presence is still required: the native SDK always sends these. + for _, name := range []string{ + "X-Stainless-Package-Version", + "X-Stainless-Runtime-Version", + "X-Stainless-OS", + "X-Stainless-Arch", + } { + if headerValue(headers, name) == "" { + return false + } + } + candidate := ClaudeDeviceProfile{ + UserAgent: headerValue(headers, "User-Agent"), + PackageVersion: headerValue(headers, "X-Stainless-Package-Version"), + RuntimeVersion: headerValue(headers, "X-Stainless-Runtime-Version"), + } + if version, ok := parseClaudeCLIVersion(candidate.UserAgent); ok { + candidate.version = version + candidate.hasVersion = true + } + if !meetsClaudeDeviceProfileBaseline(candidate, profile) { + return false + } + if async := headerValue(headers, "X-Stainless-Async"); (shape == claudeCodeHelperShapeStructured && async != "async") || + (shape == claudeCodeHelperShapeMinimal && async != "") { + return false + } + compression := headerValue(headers, "Accept-Encoding") + if (shape == claudeCodeHelperShapeStructured && compression != "gzip, deflate, br, zstd") || + (shape == claudeCodeHelperShapeMinimal && compression != "gzip") { + return false + } + requestID := headerValue(headers, "X-Client-Request-Id") + _, errRequestID := uuid.Parse(requestID) + return errRequestID == nil +} + +func measuredClaudeCodeHelperSessionMatches(headers http.Header, payload []byte) bool { + metadata := gjson.GetBytes(payload, "metadata") + if !metadata.IsObject() || !claudeJSONObjectHasKeys([]byte(metadata.Raw), []string{"user_id"}) { + return false + } + userID := metadata.Get("user_id") + if userID.Type != gjson.String || !isValidUserID(userID.String()) { + return false + } + // The native metadata builder is + // {...extraMetadata, device_id, account_uuid, session_id, ...parentSessionId && {parent_session_id}} + // in 2.1.220, 2.1.221 and 2.1.227 alike, so parent_session_id is a legitimate + // optional trailing key for sub-agent and forked sessions. Rejecting it would + // cloak the helper requests those sessions issue. + identityRaw := []byte(userID.String()) + if !claudeJSONObjectHasKeys(identityRaw, []string{"device_id", "account_uuid", "session_id"}) && + !claudeJSONObjectHasKeys(identityRaw, []string{"device_id", "account_uuid", "session_id", "parent_session_id"}) { + return false + } + return headerValue(headers, ClaudeCodeSessionHeader) == gjson.GetBytes(identityRaw, "session_id").String() +} + +func measuredClaudeCodeHelperBodyShape(payload []byte) claudeCodeHelperShape { + minimalKeys := []string{"model", "max_tokens", "messages", "metadata"} + structuredKeys := []string{"model", "messages", "system", "tools", "metadata", "max_tokens", "thinking", "temperature", "output_config", "stream"} + shape := claudeCodeHelperShapeNone + switch { + case claudeJSONObjectHasKeys(payload, minimalKeys): + shape = claudeCodeHelperShapeMinimal + case claudeJSONObjectHasKeys(payload, structuredKeys): + shape = claudeCodeHelperShapeStructured + default: + return claudeCodeHelperShapeNone + } + + maxTokens := gjson.GetBytes(payload, "max_tokens") + if gjson.GetBytes(payload, "model").String() != claudeCodeHelperModel || + maxTokens.Type != gjson.Number { + return claudeCodeHelperShapeNone + } + messages := gjson.GetBytes(payload, "messages") + if !messages.IsArray() || len(messages.Array()) != 1 { + return claudeCodeHelperShapeNone + } + message := messages.Get("0") + if !claudeJSONObjectHasKeys([]byte(message.Raw), []string{"role", "content"}) || + message.Get("role").String() != "user" { + return claudeCodeHelperShapeNone + } + + if shape == claudeCodeHelperShapeMinimal { + if maxTokens.Raw != "1" || message.Get("content").Type != gjson.String { + return claudeCodeHelperShapeNone + } + return shape + } + + content := message.Get("content") + if !content.IsArray() || len(content.Array()) != 1 { + return claudeCodeHelperShapeNone + } + contentBlock := content.Get("0") + if !claudeJSONObjectHasKeys([]byte(contentBlock.Raw), []string{"type", "text"}) || + contentBlock.Get("type").String() != "text" { + return claudeCodeHelperShapeNone + } + if !measuredClaudeCodeHelperSystemMatches(gjson.GetBytes(payload, "system")) { + return claudeCodeHelperShapeNone + } + if tools := gjson.GetBytes(payload, "tools"); !tools.IsArray() || len(tools.Array()) != 0 { + return claudeCodeHelperShapeNone + } + thinking := gjson.GetBytes(payload, "thinking") + outputConfig := gjson.GetBytes(payload, "output_config") + if !claudeJSONObjectHasKeys([]byte(thinking.Raw), []string{"type"}) || + thinking.Get("type").String() != "disabled" { + return claudeCodeHelperShapeNone + } + format := outputConfig.Get("format") + schema := format.Get("schema") + properties := schema.Get("properties") + titleProperty := properties.Get("title") + required := schema.Get("required") + additionalProperties := schema.Get("additionalProperties") + if !claudeJSONObjectHasKeys([]byte(outputConfig.Raw), []string{"format"}) || + !claudeJSONObjectHasKeys([]byte(format.Raw), []string{"type", "schema"}) || + format.Get("type").String() != "json_schema" || + !claudeJSONObjectHasKeys([]byte(schema.Raw), []string{"type", "properties", "required", "additionalProperties"}) || + schema.Get("type").String() != "object" || + !claudeJSONObjectHasKeys([]byte(properties.Raw), []string{"title"}) || + !claudeJSONObjectHasKeys([]byte(titleProperty.Raw), []string{"type"}) || + titleProperty.Get("type").String() != "string" || + !required.IsArray() || len(required.Array()) != 1 || required.Get("0").String() != "title" || + additionalProperties.Type != gjson.False { + return claudeCodeHelperShapeNone + } + temperature := gjson.GetBytes(payload, "temperature") + if maxTokens.Raw != "32000" || + temperature.Raw != "1" || + gjson.GetBytes(payload, "stream").Type != gjson.True { + return claudeCodeHelperShapeNone + } + return shape +} + +func measuredClaudeCodeHelperSystemMatches(system gjson.Result) bool { + if !system.IsArray() || len(system.Array()) != 3 { + return false + } + for _, block := range system.Array() { + if !claudeJSONObjectHasKeys([]byte(block.Raw), []string{"type", "text"}) || block.Get("type").String() != "text" { + return false + } + } + billing := system.Get("0.text").String() + identity := system.Get("1.text").String() + return strings.HasPrefix(billing, "x-anthropic-billing-header:") && measuredClaudeBillingCCH(billing) && strings.HasPrefix(identity, "You are Claude Code") +} + +// measuredClaudeBillingCCH validates the five lowercase hexadecimal characters the +// native billing header carries. It duplicates isLowerHex in +// internal/runtime/executor/claude_signing.go because the signing side lives in the +// package that imports this one; keep the two definitions in step. +func measuredClaudeBillingCCH(billing string) bool { + marker := strings.Index(billing, " cch=") + if marker < 0 { + return false + } + valueStart := marker + len(" cch=") + valueEnd := valueStart + 5 + if valueEnd >= len(billing) || billing[valueEnd] != ';' { + return false + } + for _, character := range billing[valueStart:valueEnd] { + decimal := character >= '0' && character <= '9' + lowerHex := character >= 'a' && character <= 'f' + if !decimal && !lowerHex { + return false + } + } + return true +} + +func claudeJSONObjectHasKeys(raw []byte, want []string) bool { + if !json.Valid(raw) { + return false + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + opening, errOpening := decoder.Token() + if errOpening != nil || opening != json.Delim('{') { + return false + } + keyIndex := 0 + for decoder.More() { + token, errToken := decoder.Token() + if errToken != nil { + return false + } + key, okKey := token.(string) + if !okKey || keyIndex >= len(want) || key != want[keyIndex] { + return false + } + keyIndex++ + var value json.RawMessage + if errValue := decoder.Decode(&value); errValue != nil { + return false + } + } + closing, errClosing := decoder.Token() + return errClosing == nil && closing == json.Delim('}') && keyIndex == len(want) } func plausibleClaudeCodeUserAgent(userAgent string, cfg *config.Config) bool { diff --git a/internal/runtime/executor/helps/claude_client_detection_test.go b/internal/runtime/executor/helps/claude_client_detection_test.go --- a/internal/runtime/executor/helps/claude_client_detection_test.go +++ b/internal/runtime/executor/helps/claude_client_detection_test.go @@ -3,6 +3,7 @@ import ( "encoding/json" "net/http" + "strings" "testing" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" @@ -21,6 +22,51 @@ "X-App": {"cli"}, "Anthropic-Beta": {"claude-code-20250219,interleaved-thinking-2025-05-14"}, } +} + +func measuredClaudeCodeHelperHeaders(betaProfile string, structured bool) http.Header { + profile := defaultClaudeDeviceProfile(&config.Config{}) + headers := http.Header{ + "Accept": {"application/json"}, + "Accept-Encoding": {"gzip"}, + "Content-Type": {"application/json"}, + "User-Agent": {profile.UserAgent}, + "X-App": {"cli"}, + "Anthropic-Beta": {betaProfile}, + "Anthropic-Version": {"2023-06-01"}, + "Anthropic-Dangerous-Direct-Browser-Access": {"true"}, + "X-Claude-Code-Session-Id": {"11111111-2222-4333-8444-555555555555"}, + "X-Client-Request-Id": {"66666666-7777-4888-8999-aaaaaaaaaaaa"}, + "X-Stainless-Lang": {"js"}, + "X-Stainless-Runtime": {"node"}, + "X-Stainless-Package-Version": {profile.PackageVersion}, + "X-Stainless-Runtime-Version": {profile.RuntimeVersion}, + "X-Stainless-OS": {profile.OS}, + "X-Stainless-Arch": {profile.Arch}, + "X-Stainless-Retry-Count": {"0"}, + "X-Stainless-Timeout": {"600"}, + } + if structured { + headers.Set("Accept-Encoding", "gzip, deflate, br, zstd") + headers.Set("X-Stainless-Async", "async") + } + canonical := make(http.Header, len(headers)) + for name, values := range headers { + for _, value := range values { + canonical.Add(name, value) + } + } + return canonical +} + +func measuredClaudeCodeMinimalHelperPayload() []byte { + encodedUserID, _ := json.Marshal(validClaudeCodeMetadataUserID) + return []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"helper probe"}],"metadata":{"user_id":` + string(encodedUserID) + `}}`) +} + +func measuredClaudeCodeStructuredHelperPayload() []byte { + encodedUserID, _ := json.Marshal(validClaudeCodeMetadataUserID) + return []byte(`{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":[{"type":"text","text":"helper probe"}]}],"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."},{"type":"text","text":"Return a short title."}],"tools":[],"metadata":{"user_id":` + string(encodedUserID) + `},"max_tokens":32000,"thinking":{"type":"disabled"},"temperature":1,"output_config":{"format":{"type":"json_schema","schema":{"type":"object","properties":{"title":{"type":"string"}},"required":["title"],"additionalProperties":false}}},"stream":true}`) } func TestDetectClaudeCodeRequestRequiresAllFourMessageSignals(t *testing.T) { @@ -126,6 +172,172 @@ } } +func TestDetectClaudeCodeRequestRecognizesMeasuredHaikuHelpers(t *testing.T) { + tests := []struct { + name string + beta string + structured bool + payload []byte + }{ + { + name: "minimal with redact thinking", + beta: claudeCodeHelperBetaProfile(true), + payload: measuredClaudeCodeMinimalHelperPayload(), + }, + { + name: "minimal without redact thinking", + beta: claudeCodeHelperBetaProfile(false), + payload: measuredClaudeCodeMinimalHelperPayload(), + }, + { + name: "structured title helper with advisor", + beta: claudeCodeHelperBetaProfile(true, "advisor-tool-2026-03-01", "structured-outputs-2025-12-15", "cache-diagnosis-2026-04-07"), + structured: true, + payload: measuredClaudeCodeStructuredHelperPayload(), + }, + { + name: "structured title helper with fallback credit", + beta: claudeCodeHelperBetaProfile(true, "structured-outputs-2025-12-15", "fallback-credit-2026-06-01"), + structured: true, + payload: measuredClaudeCodeStructuredHelperPayload(), + }, + { + name: "structured title helper with lowercase hex CCH", + beta: claudeCodeHelperBetaProfile(true, "structured-outputs-2025-12-15"), + structured: true, + payload: []byte(strings.Replace(string(measuredClaudeCodeStructuredHelperPayload()), "cch=00000", "cch=7ee87", 1)), + }, + { + name: "structured title helper without redact thinking", + beta: claudeCodeHelperBetaProfile(false, "structured-outputs-2025-12-15"), + structured: true, + payload: measuredClaudeCodeStructuredHelperPayload(), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + detection := DetectClaudeCodeRequest( + measuredClaudeCodeHelperHeaders(test.beta, test.structured), + test.payload, + false, + ) + if !detection.Confirmed || !detection.StrongSignals || !detection.NativeClient || !detection.HelperProfile { + t.Fatalf("detection = %#v, want confirmed measured helper", detection) + } + if detection.BetasPresent { + t.Fatalf("claude-code beta signal = true, want helper profile to remain separate: %#v", detection) + } + }) + } +} + +func TestDetectClaudeCodeRequestRejectsMalformedStructuredHaikuHelpers(t *testing.T) { + basePayload := string(measuredClaudeCodeStructuredHelperPayload()) + beta := claudeCodeHelperBetaProfile(true, "structured-outputs-2025-12-15") + for _, test := range []struct { + name string + payload string + }{ + {name: "non-hex CCH", payload: strings.Replace(basePayload, "cch=00000", "cch=ghijk", 1)}, + {name: "uppercase CCH", payload: strings.Replace(basePayload, "cch=00000", "cch=7EE87", 1)}, + {name: "wrong token cap", payload: strings.Replace(basePayload, `"max_tokens":32000`, `"max_tokens":32001`, 1)}, + {name: "open schema", payload: strings.Replace(basePayload, `"additionalProperties":false`, `"additionalProperties":true`, 1)}, + } { + t.Run(test.name, func(t *testing.T) { + detection := DetectClaudeCodeRequest(measuredClaudeCodeHelperHeaders(beta, true), []byte(test.payload), false) + if detection.Confirmed || detection.HelperProfile { + t.Fatalf("detection = %#v, want malformed structured helper rejected", detection) + } + }) + } +} + +func TestDetectClaudeCodeRequestRejectsNearMissHaikuHelpers(t *testing.T) { + minimalPayload := string(measuredClaudeCodeMinimalHelperPayload()) + tests := []struct { + name string + mutate func(http.Header) + payload string + countTokens bool + }{ + { + name: "unexpected beta profile", + mutate: func(headers http.Header) { + headers.Set("Anthropic-Beta", headers.Get("Anthropic-Beta")+",unknown-beta") + }, + payload: minimalPayload, + }, + { + name: "missing stainless package", + mutate: func(headers http.Header) { + headers.Del("X-Stainless-Package-Version") + }, + payload: minimalPayload, + }, + { + name: "wrong compression profile", + mutate: func(headers http.Header) { + headers.Set("Accept-Encoding", "gzip, deflate, br, zstd") + }, + payload: minimalPayload, + }, + { + name: "mismatched session header", + mutate: func(headers http.Header) { + headers.Set("X-Claude-Code-Session-Id", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee") + }, + payload: minimalPayload, + }, + { + name: "invalid request id", + mutate: func(headers http.Header) { + headers.Set("X-Client-Request-Id", "not-a-uuid") + }, + payload: minimalPayload, + }, + { + name: "unexpected async mode", + mutate: func(headers http.Header) { + headers.Set("X-Stainless-Async", "async") + }, + payload: minimalPayload, + }, + { + name: "wrong helper model", + payload: strings.Replace(minimalPayload, claudeCodeHelperModel, "claude-sonnet-4-6", 1), + }, + { + name: "wrong helper token cap", + payload: strings.Replace(minimalPayload, `"max_tokens":1`, `"max_tokens":2`, 1), + }, + { + name: "extra root key", + payload: strings.TrimSuffix(minimalPayload, "}") + `,"tools":[]}`, + }, + { + name: "cache marker content shape", + payload: strings.Replace(minimalPayload, `"content":"helper probe"`, `"content":[{"type":"text","text":"helper probe","cache_control":{"type":"ephemeral","ttl":"1h"}}]`, 1), + }, + { + name: "count tokens endpoint", + payload: minimalPayload, + countTokens: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + headers := measuredClaudeCodeHelperHeaders(claudeCodeHelperBetaProfile(true), false) + if test.mutate != nil { + test.mutate(headers) + } + detection := DetectClaudeCodeRequest(headers, []byte(test.payload), test.countTokens) + if detection.Confirmed || detection.HelperProfile { + t.Fatalf("detection = %#v, want helper near miss rejected", detection) + } + }) + } +} + func TestDetectClaudeCodeRequestRejectsMalformedNativeSignals(t *testing.T) { tests := []struct { name string @@ -148,4 +360,171 @@ } }) } +} + +// Recovered from the native metadata builder in 2.1.220, 2.1.221 and 2.1.227: +// +// {...extraMetadata, device_id, account_uuid, session_id, ...parentSessionId && {parent_session_id}} +// +// parent_session_id is therefore a legitimate optional trailing key that sub-agent +// and forked sessions attach, and it must not disqualify a helper request. +func TestDetectClaudeCodeRequestAcceptsHelperSubagentParentSessionID(t *testing.T) { + identity := `{"device_id":"0000000000000000000000000000000000000000000000000000000000000000","account_uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","session_id":"11111111-2222-4333-8444-555555555555","parent_session_id":"99999999-8888-4777-8666-555555555555"}` + encoded, _ := json.Marshal(identity) + payload := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"helper probe"}],"metadata":{"user_id":` + string(encoded) + `}}`) + + detection := DetectClaudeCodeRequest( + measuredClaudeCodeHelperHeaders(claudeCodeHelperBetaProfile(true), false), + payload, + false, + ) + if !detection.Confirmed || !detection.HelperProfile { + t.Fatalf("detection = %#v, want a confirmed sub-agent helper", detection) + } +} + +func TestDetectClaudeCodeRequestRejectsHelperIdentityWithUnknownKeys(t *testing.T) { + identity := `{"device_id":"0000000000000000000000000000000000000000000000000000000000000000","account_uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","session_id":"11111111-2222-4333-8444-555555555555","spoofed":"x"}` + encoded, _ := json.Marshal(identity) + payload := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"helper probe"}],"metadata":{"user_id":` + string(encoded) + `}}`) + + detection := DetectClaudeCodeRequest( + measuredClaudeCodeHelperHeaders(claudeCodeHelperBetaProfile(true), false), + payload, + false, + ) + if detection.HelperProfile { + t.Fatalf("detection = %#v, want an unknown identity key to disqualify the helper profile", detection) + } +} + +// The surrounding device-profile pipeline pins OS/Arch to the configured baseline +// rather than rejecting a foreign platform, so a genuine Windows or Linux helper +// must still be recognized instead of being cloaked. +func TestDetectClaudeCodeRequestAcceptsHelperFromNonBaselinePlatform(t *testing.T) { + for _, platform := range []struct{ os, arch string }{ + {"Windows", "x64"}, + {"Linux", "x64"}, + {"MacOS", "x64"}, + } { + t.Run(platform.os+"/"+platform.arch, func(t *testing.T) { + headers := measuredClaudeCodeHelperHeaders(claudeCodeHelperBetaProfile(true), false) + headers.Set("X-Stainless-OS", platform.os) + headers.Set("X-Stainless-Arch", platform.arch) + + detection := DetectClaudeCodeRequest(headers, measuredClaudeCodeMinimalHelperPayload(), false) + if !detection.Confirmed || !detection.HelperProfile { + t.Fatalf("detection = %#v, want a confirmed helper on a non-baseline platform", detection) + } + }) + } +} + +func TestDetectClaudeCodeRequestRejectsHelperWithoutPlatformHeaders(t *testing.T) { + for _, name := range []string{ + "X-Stainless-OS", + "X-Stainless-Arch", + "X-Stainless-Package-Version", + "X-Stainless-Runtime-Version", + } { + t.Run("missing "+name, func(t *testing.T) { + headers := measuredClaudeCodeHelperHeaders(claudeCodeHelperBetaProfile(true), false) + headers.Del(name) + + detection := DetectClaudeCodeRequest(headers, measuredClaudeCodeMinimalHelperPayload(), false) + if detection.HelperProfile { + t.Fatalf("detection = %#v, want a missing %s to disqualify the helper profile", detection, name) + } + }) + } +} + +func TestDetectClaudeCodeRequestRejectsHelperWithForeignSoftwareTuple(t *testing.T) { + for name, value := range map[string]string{ + "X-Stainless-Package-Version": "0.0.1", + "X-Stainless-Runtime-Version": "v0.0.1", + } { + t.Run(name, func(t *testing.T) { + headers := measuredClaudeCodeHelperHeaders(claudeCodeHelperBetaProfile(true), false) + headers.Set(name, value) + + detection := DetectClaudeCodeRequest(headers, measuredClaudeCodeMinimalHelperPayload(), false) + if detection.HelperProfile { + t.Fatalf("detection = %#v, want a foreign %s to disqualify the helper profile", detection, name) + } + }) + } +} + +func TestNormalizedClaudeBetaHeaderIsDeterministic(t *testing.T) { + canonical := http.Header{} + canonical.Add("Anthropic-Beta", "oauth-2025-04-20") + canonical.Add("Anthropic-Beta", "interleaved-thinking-2025-05-14") + if got, want := normalizedClaudeBetaHeader(canonical), "oauth-2025-04-20,interleaved-thinking-2025-05-14"; got != want { + t.Fatalf("canonical join = %q, want %q", got, want) + } + + // Two non-canonical spellings in one map used to be joined in Go map order. + nonCanonical := http.Header{ + "anthropic-beta": {"oauth-2025-04-20"}, + "ANTHROPIC-BETA": {"interleaved-thinking-2025-05-14"}, + } + first := normalizedClaudeBetaHeader(nonCanonical) + for i := 0; i < 50; i++ { + if got := normalizedClaudeBetaHeader(nonCanonical); got != first { + t.Fatalf("non-canonical join is order-dependent: %q then %q", first, got) + } + } + if !strings.Contains(first, "oauth-2025-04-20") || !strings.Contains(first, "interleaved-thinking-2025-05-14") { + t.Fatalf("non-canonical join lost values: %q", first) + } + + if got := normalizedClaudeBetaHeader(nil); got != "" { + t.Fatalf("nil header join = %q, want empty", got) + } +} + +// A confirmed helper is routed through misc.EnsureHeader, so CPA forwards the +// helper's own X-Stainless-Timeout and never the operator default. Keying the +// detector on claude-header-defaults.timeout instead of the measured constant +// therefore rejected every genuine helper whenever that value was customized. +func TestMeasuredHelperProfileIgnoresConfiguredStainlessTimeout(t *testing.T) { + headers := measuredClaudeCodeHelperHeaders(claudeCodeHelperBetaProfile(true), false) + payload := measuredClaudeCodeMinimalHelperPayload() + if got := headers.Get("X-Stainless-Timeout"); got != claudeDefaultStainlessTimeout { + t.Fatalf("measured helper timeout = %q, want %q", got, claudeDefaultStainlessTimeout) + } + + withTimeout := func(timeout string) *config.Config { + cfg := &config.Config{} + cfg.ClaudeHeaderDefaults.Timeout = timeout + return cfg + } + for _, test := range []struct { + name string + cfg *config.Config + }{ + {name: "nil config"}, + {name: "unset", cfg: &config.Config{}}, + {name: "measured default", cfg: withTimeout(claudeDefaultStainlessTimeout)}, + {name: "shorter operator default", cfg: withTimeout("300")}, + {name: "longer operator default", cfg: withTimeout("900")}, + } { + t.Run(test.name, func(t *testing.T) { + detection := DetectClaudeCodeRequest(headers, payload, false, test.cfg) + if !detection.HelperProfile || !detection.Confirmed { + t.Fatalf("detection = %#v, want confirmed helper regardless of configured timeout", detection) + } + }) + } + + // The measured constant stays the only accepted value, so a caller that does not + // send it is still disqualified even when the operator default happens to match. + t.Run("foreign timeout stays rejected", func(t *testing.T) { + foreign := measuredClaudeCodeHelperHeaders(claudeCodeHelperBetaProfile(true), false) + foreign.Set("X-Stainless-Timeout", "900") + if detection := DetectClaudeCodeRequest(foreign, payload, false, withTimeout("900")); detection.HelperProfile { + t.Fatalf("detection = %#v, want a non-measured timeout to disqualify the helper profile", detection) + } + }) } -- tangled.sh