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) + } + }) }