From 4332048dda1980baa3dbe2bf08f3deec1a40242e Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 25 Sep 2026 12:38:18 +0800 Subject: [PATCH] fix(claude): apply cloaking by default for direct Messages OAuth requests - Remove direct Messages passthrough bypass for unconfirmed OAuth credentials - Ensure standard cache control handling and CLI fingerprinting apply upstream - Support detection for Claude Code 2.1.280 title helper payload shape Closes: #6120 --- .../executor/claude_executor_cloaking.go | 33 ---- .../executor/claude_executor_execute.go | 43 +++--- .../claude_executor_native_helper_test.go | 4 +- .../executor/claude_executor_request.go | 20 +-- .../executor/claude_executor_stream.go | 41 +++-- .../executor/claude_issue_6120_test.go | 142 ++++++++++++++++++ .../claude_messages_passthrough_test.go | 33 ++-- .../executor/helps/claude_client_detection.go | 24 ++- .../helps/claude_client_detection_test.go | 37 ++++- 9 files changed, 262 insertions(+), 115 deletions(-) create mode 100644 internal/runtime/executor/claude_issue_6120_test.go diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index 6ca58e838..78afc13e0 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -41,34 +41,6 @@ func detectIncomingClaudeCodeRequest(ctx context.Context, incoming http.Header, return resolved, helps.DetectClaudeCodeRequest(resolved, payload, countTokens, cfg) } -type claudeInboundFormatContextKey struct{} -type claudeDirectMessagesPassthroughContextKey struct{} - -func withClaudeInboundFormat(ctx context.Context, format string) context.Context { - if ctx == nil { - ctx = context.Background() - } - return context.WithValue(ctx, claudeInboundFormatContextKey{}, strings.ToLower(strings.TrimSpace(format))) -} - -func withClaudeDirectMessagesPassthrough(ctx context.Context, enabled bool) context.Context { - if ctx == nil { - ctx = context.Background() - } - return context.WithValue(ctx, claudeDirectMessagesPassthroughContextKey{}, enabled) -} - -// claudeInboundMessagesPassthrough reports a direct Anthropic Messages caller. -// Translated Responses, Chat, and Gemini requests are cloaked separately. -func claudeInboundMessagesPassthrough(ctx context.Context) bool { - if ctx == nil { - return false - } - format, _ := ctx.Value(claudeInboundFormatContextKey{}).(string) - passthrough, _ := ctx.Value(claudeDirectMessagesPassthroughContextKey{}).(bool) - return format == "claude" && passthrough -} - // getWorkloadFromContext extracts workload identifier from the gin request headers. func getWorkloadFromContext(ctx context.Context) string { if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { @@ -1334,7 +1306,6 @@ type claudeWirePolicy struct { OAuth bool // real OAuth token runtime identity ProfileClaudeCodeCLI bool // request fingerprint looks like Claude Code CLI ConfirmedClaudeCode bool - CloakConfigured bool // operator explicitly configured cloak behavior Cloak bool } @@ -1381,7 +1352,6 @@ func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey OAuth: fp.AuthIsOAuthToken, ProfileClaudeCodeCLI: fp.ProfileClaudeCodeCLI, ConfirmedClaudeCode: confirmedClaudeCode, - CloakConfigured: cloakConfigured, Cloak: (fp.ProfileClaudeCodeCLI || cloakConfigured) && !confirmedClaudeCode, } if confirmedClaudeCode { @@ -1429,9 +1399,6 @@ func applyCloakingInternal( obfuscateSensitiveWords bool, ) ([]byte, bool, error) { policy, settings := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode) - if claudeInboundMessagesPassthrough(ctx) && policy.OAuth && !policy.CloakConfigured { - policy.Cloak = false - } if !policy.Cloak { return payload, false, nil } diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index e958882c1..14b3d022b 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -42,8 +42,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r reporter.SetUpstreamModel(upstreamModel) } from := opts.SourceFormat - ctx = withClaudeInboundFormat(ctx, from.String()) - ctx = withClaudeDirectMessagesPassthrough(ctx, from == sdktranslator.FormatClaude && isAnthropicUpstreamBase(baseURL)) responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("claude") var replayScope claudeThinkingReplayScope @@ -92,7 +90,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) // based on client type and configuration. - wirePolicy, wireSettings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) + _, wireSettings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) bodyBeforeCloaking := body isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(bodyBeforeCloaking) var cloaked bool @@ -208,23 +206,20 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r body = normalizeClaudeSamplingForUpstream(body, confirmedClaudeCode) // Default cache_control for translated entrypoints (Responses/Chat/Gemini) and other - // non-native callers. Confirmed native Claude Code and direct Messages callers own - // their marker placement and must not be rewritten. Cloaked requests always run - // section-independent ensure so cloaking's first-user marker cannot suppress - // system/latest-user breakpoints. - messagesPassthrough := !confirmedClaudeCode && claudeInboundMessagesPassthrough(ctx) && wirePolicy.OAuth && !wirePolicy.CloakConfigured - cpaOwnsCacheControl := !messagesPassthrough && shouldEnsureCacheControl(body, cloaked, confirmedClaudeCode) + // non-native callers. Confirmed native Claude Code owns its marker placement and must + // not be rewritten. Cloaked requests always run section-independent ensure so cloaking's + // first-user marker cannot suppress system/latest-user breakpoints. + // cloaked and confirmedClaudeCode are mutually exclusive: resolveClaudeWirePolicy + // forces Cloak off for a confirmed native client. + cpaOwnsCacheControl := shouldEnsureCacheControl(body, cloaked, confirmedClaudeCode) if cpaOwnsCacheControl { body = ensureCacheControl(body) } // Enforce Anthropic's cache_control block limit (max 4 breakpoints per request). // Cloaking and ensureCacheControl may push the total over 4 when the client - // already sends multiple cache_control blocks. Direct Messages callers retain - // their caller-owned cache layout. - if !messagesPassthrough { - body = enforceCacheControlLimit(body, 4) - } + // already sends multiple cache_control blocks. + body = enforceCacheControlLimit(body, 4) // Native selects the 1h cache pool only for OAuth credentials and pairs it with // extended-cache-ttl-2025-04-11, which claudeCodeCLIBetas emits on exactly the @@ -241,17 +236,15 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // probes omit both 1h cache and extended-cache-ttl. isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body) subagent1h := isSubagent && helps.ClaudeSubagentRequests1h(incomingHeaders, body) - if !messagesPassthrough { - if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && (!isSubagent || subagent1h) && !isProbeOrHelper { - body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) - } else if isProbeOrHelper || (isSubagent && !subagent1h) { - body = stripClaudeCacheControlTTL(body) - } - - // Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05. - // A 1h-TTL block must not appear after a 5m-TTL block in evaluation order (tools→system→messages). - body = normalizeCacheControlTTL(body) + if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && (!isSubagent || subagent1h) && !isProbeOrHelper { + body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) + } else if isProbeOrHelper || (isSubagent && !subagent1h) { + body = stripClaudeCacheControlTTL(body) } + + // Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05. + // A 1h-TTL block must not appear after a 5m-TTL block in evaluation order (tools→system→messages). + body = normalizeCacheControlTTL(body) // Payload rules and other request processing may rewrite stream. Keep the // upstream body, transport headers, and response parser on one authority. // Native non-stream Haiku helper requests omit stream rather than sending @@ -284,7 +277,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } cchBilling := "" if cchSigning { - if !messagesPassthrough && (!claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream)) { + if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) { cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) } bodyForUpstream, err = finalizeAnthropicMessagesBodyCCH(bodyForUpstream, cchBilling) diff --git a/internal/runtime/executor/claude_executor_native_helper_test.go b/internal/runtime/executor/claude_executor_native_helper_test.go index 83554f85e..0c4dade33 100644 --- a/internal/runtime/executor/claude_executor_native_helper_test.go +++ b/internal/runtime/executor/claude_executor_native_helper_test.go @@ -364,8 +364,8 @@ func TestClaudeExecutor_Native280HaikuTitleHelperPreservesServerSideFallbackBeta }) ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) const titleBetas = claudeNativeHelperCoreBetas + ",structured-outputs-2025-12-15,server-side-fallback-2026-06-01,fallback-credit-2026-06-01,cache-diagnosis-2026-04-07" - headers := claudeNativeHelperHeaders(titleBetas, "gzip") - payload := []byte(fmt.Sprintf(`{"model":"claude-haiku-4-5-20251001","max_tokens":80,"messages":[{"role":"user","content":"generate title"}],"metadata":{"user_id":%s},"output_config":{"format":{"type":"json_schema","schema":{"type":"object"}}}}`, claudeNativeHelperUserID)) + headers := claudeNativeHelperHeaders(titleBetas, "gzip, deflate, br, zstd") + payload := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":80,"messages":[{"role":"user","content":"generate title"}],"metadata":{"user_id":"` + strings.ReplaceAll(claudeNativeHelperUserID, `"`, `\"`) + `"},"output_config":{"format":{"type":"json_schema","schema":{"type":"object"}}}}`) _, err := NewClaudeExecutor(&config.Config{}).Execute(ctx, claudeNativeHelperOAuthAuth("https://api.anthropic.com"), cliproxyexecutor.Request{ Model: "claude-haiku-4-5-20251001", diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index d3bc5abbb..6061ea784 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -1093,9 +1093,8 @@ func applyClaudeHeadersWithNativeProfile( useAPIKey := !credentialUsesBearer fp := resolveClaudeFingerprintPolicy(cfg, auth, apiKey) wirePolicy, _ := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode) - messagesPassthrough := !confirmedClaudeCode && claudeInboundMessagesPassthrough(r.Context()) && wirePolicy.OAuth && !wirePolicy.CloakConfigured - applyCLIFingerprint := !messagesPassthrough && (fp.ProfileClaudeCodeCLI || wirePolicy.Cloak) - preserveCallerFingerprint := messagesPassthrough || (!applyCLIFingerprint && !confirmedClaudeCode) + applyCLIFingerprint := fp.ProfileClaudeCodeCLI || wirePolicy.Cloak + preserveCallerFingerprint := !applyCLIFingerprint && !confirmedClaudeCode useOAuthBetas := fp.UseOAuthBetas isAnthropicBase := isAnthropicUpstreamURL(r.URL) if strings.TrimSpace(apiKey) != "" { @@ -1168,7 +1167,7 @@ func applyClaudeHeadersWithNativeProfile( baseBetas = withClaudeOAuthCredentialBetas(baseBetas, false) } } - if preserveCallerFingerprint && !messagesPassthrough && advisorNeeded { + if preserveCallerFingerprint && advisorNeeded { baseBetas = withClaudeAdvisorToolBeta(baseBetas) } if !claudeRequestSupportsEffort(body, nil) { @@ -1194,9 +1193,8 @@ func applyClaudeHeadersWithNativeProfile( } if preserveCallerFingerprint { // Caller-owned mode preserves both header and body-lifted betas verbatim. - // The explicit speed=fast request still needs its protocol beta unless a - // direct Messages caller owns the complete beta set. - if !messagesPassthrough && strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "speed").String()), "fast") { + // The explicit speed=fast request still needs its protocol beta. + if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "speed").String()), "fast") { appendBeta(claudeFastModeBeta) } for _, beta := range extraBetas { @@ -1230,14 +1228,6 @@ func applyClaudeHeadersWithNativeProfile( } } applyBetaHeader := func() { - if messagesPassthrough { - if strings.TrimSpace(baseBetas) == "" { - r.Header.Del("Anthropic-Beta") - } else { - r.Header.Set("Anthropic-Beta", baseBetas) - } - return - } // Enforce strict native Claude Code 2.1.280 model & turn beta gating: if !claudeRequestSupportsEffort(body, nil) { baseBetas = withoutClaudeBeta(baseBetas, claudeEffortBeta) diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 69f52db7c..3df9645bd 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -48,8 +48,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A reporter.SetUpstreamModel(upstreamModel) } from := opts.SourceFormat - ctx = withClaudeInboundFormat(ctx, from.String()) - ctx = withClaudeDirectMessagesPassthrough(ctx, from == sdktranslator.FormatClaude && isAnthropicUpstreamBase(baseURL)) responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("claude") var replayScope claudeThinkingReplayScope @@ -95,7 +93,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) // based on client type and configuration. - wirePolicy, wireSettings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) + _, wireSettings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) bodyBeforeCloaking := body isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(bodyBeforeCloaking) var cloaked bool @@ -211,21 +209,20 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body = normalizeClaudeSamplingForUpstream(body, confirmedClaudeCode) // Default cache_control for translated entrypoints (Responses/Chat/Gemini) and other - // non-native callers. Confirmed native Claude Code and direct Messages callers own - // their marker placement and must not be rewritten. Cloaked requests always run - // section-independent ensure so cloaking's first-user marker cannot suppress - // system/latest-user breakpoints. - messagesPassthrough := !confirmedClaudeCode && claudeInboundMessagesPassthrough(ctx) && wirePolicy.OAuth && !wirePolicy.CloakConfigured - cpaOwnsCacheControl := !messagesPassthrough && shouldEnsureCacheControl(body, cloaked, confirmedClaudeCode) + // non-native callers. Confirmed native Claude Code owns its marker placement and must + // not be rewritten. Cloaked requests always run section-independent ensure so cloaking's + // first-user marker cannot suppress system/latest-user breakpoints. + // cloaked and confirmedClaudeCode are mutually exclusive: resolveClaudeWirePolicy + // forces Cloak off for a confirmed native client. + cpaOwnsCacheControl := shouldEnsureCacheControl(body, cloaked, confirmedClaudeCode) if cpaOwnsCacheControl { body = ensureCacheControl(body) } // Enforce Anthropic's cache_control block limit (max 4 breakpoints per request). - // Direct Messages callers retain their caller-owned cache layout. - if !messagesPassthrough { - body = enforceCacheControlLimit(body, 4) - } + // Cloaking and ensureCacheControl may push the total over 4 when the client + // already sends multiple cache_control blocks. + body = enforceCacheControlLimit(body, 4) // Native selects the 1h cache pool only for OAuth credentials and pairs it with // extended-cache-ttl-2025-04-11, which claudeCodeCLIBetas emits on exactly the @@ -242,17 +239,15 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // probes omit both 1h cache and extended-cache-ttl. isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body) subagent1h := isSubagent && helps.ClaudeSubagentRequests1h(incomingHeaders, body) - if !messagesPassthrough { - if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && (!isSubagent || subagent1h) && !isProbeOrHelper { - body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) - } else if isProbeOrHelper || (isSubagent && !subagent1h) { - body = stripClaudeCacheControlTTL(body) - } - - // Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05. - body = normalizeCacheControlTTL(body) + if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && (!isSubagent || subagent1h) && !isProbeOrHelper { + body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) + } else if isProbeOrHelper || (isSubagent && !subagent1h) { + body = stripClaudeCacheControlTTL(body) } + // Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05. + body = normalizeCacheControlTTL(body) + // Extract betas from body and convert to header var extraBetas []string extraBetas, body = extractAndRemoveBetas(body) @@ -276,7 +271,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } cchBilling := "" if cchSigning { - if !messagesPassthrough && (!claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream)) { + if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) { cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) } bodyForUpstream, err = finalizeAnthropicMessagesBodyCCH(bodyForUpstream, cchBilling) diff --git a/internal/runtime/executor/claude_issue_6120_test.go b/internal/runtime/executor/claude_issue_6120_test.go new file mode 100644 index 000000000..829d3d45e --- /dev/null +++ b/internal/runtime/executor/claude_issue_6120_test.go @@ -0,0 +1,142 @@ +package executor + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + 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" +) + +// TestClaudeExecutor_DirectMessagesOAuthDefaultsToCloak_Issue6120 verifies Option A for Issue #6120: +// Direct Anthropic Messages requests using Claude OAuth credentials without explicit cloak configuration +// must be cloaked by default to prevent Anthropic 429/400 rejections and credential cooldown. +func TestClaudeExecutor_DirectMessagesOAuthDefaultsToCloak_Issue6120(t *testing.T) { + ctx := directClaudeMessagesContext() + payload := []byte(`{"model":"claude-opus-5-5","system":[{"type":"text","text":"caller system"}],"messages":[{"role":"user","content":"hello"}]}`) + auth := directClaudeOAuthAuth() + + // 1. Verify applyCloaking cloaks by default on direct Messages for unconfirmed OAuth caller + gotPayload, cloaked, err := applyCloaking(ctx, &config.Config{}, auth, payload, "sk-ant-oat-direct-messages-test", false, true) + if err != nil { + t.Fatalf("applyCloaking() error = %v", err) + } + if !cloaked { + t.Fatal("applyCloaking() cloaked = false, want true (Issue #6120: unconfirmed OAuth direct Messages callers must be cloaked by default)") + } + if !gjson.GetBytes(gotPayload, "system.#(text==\"You are Claude Code, Anthropic's official CLI for Claude.\")").Exists() { + t.Fatal("expected cloaked payload to contain Claude Code CLI identity system block") + } + + // 2. Verify ClaudeExecutor.Execute applies CLI fingerprint and cloaked headers upstream + var seenHeaders http.Header + var seenBody []byte + transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + seenHeaders = req.Header.Clone() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"msg_test","type":"message","model":"claude-opus-5-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`)), + Request: req, + }, nil + }) + + execCtx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) + incoming := http.Header{ + "User-Agent": {"pi (linux; x64)"}, + "Anthropic-Beta": {"caller-beta-2099-01-01"}, + } + authWithURL := directClaudeOAuthAuth() + authWithURL.Attributes["base_url"] = "https://api.anthropic.com" + + _, errExec := NewClaudeExecutor(&config.Config{}).Execute(execCtx, authWithURL, cliproxyexecutor.Request{ + Model: "claude-opus-5-5", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: incoming, + }) + if errExec != nil { + t.Fatalf("Execute() error = %v", errExec) + } + + if ua := seenHeaders.Get("User-Agent"); ua == incoming.Get("User-Agent") || !strings.Contains(ua, "claude-cli") { + t.Fatalf("User-Agent = %q, want cloaked CLI User-Agent instead of raw third-party caller User-Agent", ua) + } + if xApp := helps.HeaderValueCaseInsensitive(seenHeaders, "X-App"); xApp != "cli" { + t.Fatalf("X-App = %q, want %q", xApp, "cli") + } + betas := helps.HeaderValueCaseInsensitive(seenHeaders, "Anthropic-Beta") + if !strings.Contains(betas, "claude-code-20250219") || !strings.Contains(betas, "oauth-2025-04-20") { + t.Fatalf("Anthropic-Beta = %q, want claude-code-20250219 and oauth-2025-04-20 for cloaked OAuth", betas) + } + if !gjson.GetBytes(seenBody, "system.#(text==\"You are Claude Code, Anthropic's official CLI for Claude.\")").Exists() { + t.Fatal("expected upstream body to contain Claude Code CLI identity system block") + } +} + +// TestClaudeExecutor_DirectMessagesOAuthDefaultsToCloakStream_Issue6120 verifies Option A for streaming: +// Direct Anthropic Messages streaming requests using Claude OAuth credentials without explicit cloak configuration +// must also be cloaked by default upstream. +func TestClaudeExecutor_DirectMessagesOAuthDefaultsToCloakStream_Issue6120(t *testing.T) { + var seenHeaders http.Header + var seenBody []byte + transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + seenHeaders = req.Header.Clone() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-opus-5-5\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + )), + Request: req, + }, nil + }) + + execCtx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) + incoming := http.Header{ + "User-Agent": {"pi (linux; x64)"}, + "Anthropic-Beta": {"caller-beta-2099-01-01"}, + } + authWithURL := directClaudeOAuthAuth() + authWithURL.Attributes["base_url"] = "https://api.anthropic.com" + payload := []byte(`{"model":"claude-opus-5-5","system":[{"type":"text","text":"caller system"}],"messages":[{"role":"user","content":"hello"}]}`) + + streamResp, errStream := NewClaudeExecutor(&config.Config{}).ExecuteStream(execCtx, authWithURL, cliproxyexecutor.Request{ + Model: "claude-opus-5-5", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: incoming, + }) + if errStream != nil { + t.Fatalf("ExecuteStream() error = %v", errStream) + } + for chunk := range streamResp.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + } + + if ua := seenHeaders.Get("User-Agent"); ua == incoming.Get("User-Agent") || !strings.Contains(ua, "claude-cli") { + t.Fatalf("streaming User-Agent = %q, want cloaked CLI User-Agent", ua) + } + if xApp := helps.HeaderValueCaseInsensitive(seenHeaders, "X-App"); xApp != "cli" { + t.Fatalf("streaming X-App = %q, want %q", xApp, "cli") + } + betas := helps.HeaderValueCaseInsensitive(seenHeaders, "Anthropic-Beta") + if !strings.Contains(betas, "claude-code-20250219") || !strings.Contains(betas, "oauth-2025-04-20") { + t.Fatalf("streaming Anthropic-Beta = %q, want claude-code-20250219 and oauth-2025-04-20", betas) + } + if !gjson.GetBytes(seenBody, "system.#(text==\"You are Claude Code, Anthropic's official CLI for Claude.\")").Exists() { + t.Fatal("expected streaming upstream body to contain Claude Code CLI identity system block") + } +} diff --git a/internal/runtime/executor/claude_messages_passthrough_test.go b/internal/runtime/executor/claude_messages_passthrough_test.go index 238a405fc..31919b128 100644 --- a/internal/runtime/executor/claude_messages_passthrough_test.go +++ b/internal/runtime/executor/claude_messages_passthrough_test.go @@ -17,8 +17,7 @@ import ( ) func directClaudeMessagesContext() context.Context { - ctx := withClaudeInboundFormat(context.Background(), "claude") - return withClaudeDirectMessagesPassthrough(ctx, true) + return context.Background() } func directClaudeOAuthAuth() *cliproxyauth.Auth { @@ -33,12 +32,14 @@ func directClaudeOAuthAuth() *cliproxyauth.Auth { func TestApplyClaudeCloak_DirectMessagesPreservesCallerBody(t *testing.T) { payload := []byte(`{"model":"claude-opus-5-5","system":[{"type":"text","text":"caller system"}],"thinking":{"type":"adaptive","display":"summarized"},"tools":[{"name":"caller_tool"}],"messages":[{"role":"user","content":"hello"}]}`) - got, cloaked, err := applyCloaking(directClaudeMessagesContext(), &config.Config{}, directClaudeOAuthAuth(), payload, "sk-ant-oat-direct-messages-test", false, true) + authNever := directClaudeOAuthAuth() + authNever.Attributes["cloak_mode"] = "never" + got, cloaked, err := applyCloaking(directClaudeMessagesContext(), &config.Config{}, authNever, payload, "sk-ant-oat-direct-messages-test", false, true) if err != nil { t.Fatalf("applyCloaking() error = %v", err) } if cloaked { - t.Fatal("applyCloaking() cloaked = true, want direct Messages passthrough") + t.Fatal("applyCloaking() cloaked = true, want direct Messages passthrough when cloak_mode is never") } if !bytes.Equal(got, payload) { t.Fatalf("direct Messages body changed: got %s, want %s", got, payload) @@ -57,17 +58,17 @@ func TestApplyClaudeHeaders_DirectMessagesPreservesCallerFingerprint(t *testing. } body := []byte(`{"model":"claude-opus-5-5","thinking":{"type":"adaptive","display":"summarized"}}`) - if err := applyClaudeHeaders(req, directClaudeOAuthAuth(), "sk-ant-oat-direct-messages-test", false, nil, body, &config.Config{}, incoming, false); err != nil { + if err := applyClaudeHeaders(req, directClaudeAPIKeyAuth(), "sk-ant-api03-direct-messages-test", false, nil, body, &config.Config{}, incoming, false); err != nil { t.Fatalf("applyClaudeHeaders() error = %v", err) } if got := req.Header.Get("User-Agent"); got != incoming.Get("User-Agent") { t.Fatalf("User-Agent = %q, want caller value %q", got, incoming.Get("User-Agent")) } - if got := req.Header.Get("Anthropic-Beta"); got != "oauth-2025-04-20,"+incoming.Get("Anthropic-Beta") { - t.Fatalf("Anthropic-Beta = %q, want OAuth credential beta with caller value %q", got, "oauth-2025-04-20,"+incoming.Get("Anthropic-Beta")) + if got := req.Header.Get("Anthropic-Beta"); got != incoming.Get("Anthropic-Beta") { + t.Fatalf("Anthropic-Beta = %q, want caller value %q", got, incoming.Get("Anthropic-Beta")) } if got := req.Header.Get("X-App"); got != "" { - t.Fatalf("X-App = %q, want absent in direct Messages passthrough", got) + t.Fatalf("X-App = %q, want absent in direct Messages caller-owned API key mode", got) } } @@ -89,9 +90,9 @@ func TestClaudeExecutor_DirectMessagesOfficialUpstreamPreservesCallerShape(t *te "User-Agent": {"pi (darwin; arm64)"}, "Anthropic-Beta": {"caller-beta-2099-01-01"}, } - auth := directClaudeOAuthAuth() + auth := directClaudeAPIKeyAuth() auth.Attributes["base_url"] = "https://api.anthropic.com" - payload := []byte(`{"model":"claude-opus-5-5","system":[{"type":"text","text":"caller system"}],"thinking":{"type":"adaptive","display":"summarized"},"tools":[{"name":"caller_tool","description":"caller tool","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"hello"}],"max_tokens":32}`) + payload := []byte(`{"model":"claude-opus-5-5","system":[{"type":"text","text":"caller system","cache_control":{"type":"ephemeral"}}],"thinking":{"type":"adaptive","display":"summarized"},"tools":[{"name":"caller_tool","description":"caller tool","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"hello"}],"max_tokens":32}`) _, err := NewClaudeExecutor(&config.Config{}).Execute(ctx, auth, cliproxyexecutor.Request{Model: "claude-opus-5-5", Payload: payload}, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatClaude, @@ -103,8 +104,8 @@ func TestClaudeExecutor_DirectMessagesOfficialUpstreamPreservesCallerShape(t *te if got := seenHeaders.Get("User-Agent"); got != incoming.Get("User-Agent") { t.Fatalf("User-Agent = %q, want %q; headers=%v", got, incoming.Get("User-Agent"), seenHeaders) } - if got := helps.HeaderValueCaseInsensitive(seenHeaders, "Anthropic-Beta"); got != "oauth-2025-04-20,"+incoming.Get("Anthropic-Beta") { - t.Fatalf("Anthropic-Beta = %q, want %q; headers=%v", got, "oauth-2025-04-20,"+incoming.Get("Anthropic-Beta"), seenHeaders) + if got := helps.HeaderValueCaseInsensitive(seenHeaders, "Anthropic-Beta"); got != incoming.Get("Anthropic-Beta") { + t.Fatalf("Anthropic-Beta = %q, want %q; headers=%v", got, incoming.Get("Anthropic-Beta"), seenHeaders) } if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 1 { t.Fatalf("system block count = %d, want the caller block only", got) @@ -162,11 +163,13 @@ func TestApplyClaudeHeaders_DirectMessagesOAuthInjectsOAuthBetaWhenEmpty(t *test } body := []byte(`{"model":"claude-opus-5-5","thinking":{"type":"adaptive","display":"summarized"}}`) - if err := applyClaudeHeaders(req, directClaudeOAuthAuth(), "sk-ant-oat-direct-messages-test", false, nil, body, &config.Config{}, incoming, false); err != nil { + auth := directClaudeOAuthAuth() + if err := applyClaudeHeaders(req, auth, "sk-ant-oat-direct-messages-test", false, nil, body, &config.Config{}, incoming, false); err != nil { t.Fatalf("applyClaudeHeaders() error = %v", err) } - if got := req.Header.Get("Anthropic-Beta"); got != "oauth-2025-04-20" { - t.Fatalf("Anthropic-Beta = %q, want oauth-2025-04-20 for empty caller betas on OAuth", got) + betas := req.Header.Get("Anthropic-Beta") + if !strings.Contains(betas, "oauth-2025-04-20") || !strings.Contains(betas, "claude-code-20250219") { + t.Fatalf("Anthropic-Beta = %q, want oauth-2025-04-20 and claude-code-20250219 for OAuth", betas) } } diff --git a/internal/runtime/executor/helps/claude_client_detection.go b/internal/runtime/executor/helps/claude_client_detection.go index 21e9706e3..dacb1ac08 100644 --- a/internal/runtime/executor/helps/claude_client_detection.go +++ b/internal/runtime/executor/helps/claude_client_detection.go @@ -75,6 +75,7 @@ const ( claudeCodeHelperShapeNone claudeCodeHelperShape = iota claudeCodeHelperShapeMinimal claudeCodeHelperShapeStructured + claudeCodeHelperShapeTitle280 claudeCodeHelperModel = "claude-haiku-4-5-20251001" ) @@ -108,7 +109,7 @@ var measuredClaudeCodeHelperBetaProfiles = map[string]claudeCodeHelperShape{ "server-side-fallback-2026-06-01", "fallback-credit-2026-06-01", "cache-diagnosis-2026-04-07", - ): claudeCodeHelperShapeStructured, + ): claudeCodeHelperShapeTitle280, } // ClaudeCodeRequestDetection records the strong signals and first-party @@ -327,12 +328,17 @@ func measuredClaudeCodeHelperSessionMatches(headers http.Header, payload []byte) 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"} + structuredTitle280Keys := []string{"model", "max_tokens", "messages", "metadata", "output_config"} shape := claudeCodeHelperShapeNone + isTitle280 := false switch { case claudeJSONObjectHasKeys(payload, minimalKeys): shape = claudeCodeHelperShapeMinimal case claudeJSONObjectHasKeys(payload, structuredKeys): shape = claudeCodeHelperShapeStructured + case claudeJSONObjectHasKeys(payload, structuredTitle280Keys): + shape = claudeCodeHelperShapeTitle280 + isTitle280 = true default: return claudeCodeHelperShapeNone } @@ -359,6 +365,22 @@ func measuredClaudeCodeHelperBodyShape(payload []byte) claudeCodeHelperShape { return shape } + if isTitle280 { + outputConfig := gjson.GetBytes(payload, "output_config") + format := outputConfig.Get("format") + schema := format.Get("schema") + if maxTokens.Raw != "80" || + message.Get("content").Type != gjson.String || + !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"}) || + schema.Get("type").String() != "object" { + return claudeCodeHelperShapeNone + } + return shape + } + content := message.Get("content") if !content.IsArray() || len(content.Array()) != 1 { return claudeCodeHelperShapeNone diff --git a/internal/runtime/executor/helps/claude_client_detection_test.go b/internal/runtime/executor/helps/claude_client_detection_test.go index 15a3c9cec..d4ee6bee6 100644 --- a/internal/runtime/executor/helps/claude_client_detection_test.go +++ b/internal/runtime/executor/helps/claude_client_detection_test.go @@ -65,6 +65,11 @@ func measuredClaudeCodeStructuredHelperPayload() []byte { 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.258; 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 measuredClaudeCodeTitle280HelperPayload() []byte { + encodedUserID, _ := json.Marshal(validClaudeCodeMetadataUserID) + return []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":80,"messages":[{"role":"user","content":"generate title"}],"metadata":{"user_id":` + string(encodedUserID) + `},"output_config":{"format":{"type":"json_schema","schema":{"type":"object"}}}}`) +} + func TestDetectClaudeCodeRequestRequiresAllFourMessageSignals(t *testing.T) { payload := claudeCodeDetectionPayload(validClaudeCodeMetadataUserID) detection := DetectClaudeCodeRequest(confirmedClaudeCodeHeaders(), payload, false) @@ -218,7 +223,7 @@ func TestDetectClaudeCodeRequestRecognizesMeasuredHaikuHelpers(t *testing.T) { { name: "structured title helper 2.1.280", beta: claudeCodeHelperBetaProfile(true, "structured-outputs-2025-12-15", "server-side-fallback-2026-06-01", "fallback-credit-2026-06-01", "cache-diagnosis-2026-04-07"), - payload: measuredClaudeCodeStructuredHelperPayload(), + payload: measuredClaudeCodeTitle280HelperPayload(), }, } for _, test := range tests { @@ -256,6 +261,36 @@ func TestDetectClaudeCodeRequestRejectsExtendedHaikuHelperBetas(t *testing.T) { } } +func TestDetectClaudeCodeRequestRejectsCrossMismatchedHaikuHelperBetaAndBody(t *testing.T) { + title280Beta := claudeCodeHelperBetaProfile(true, + "structured-outputs-2025-12-15", + "server-side-fallback-2026-06-01", + "fallback-credit-2026-06-01", + "cache-diagnosis-2026-04-07", + ) + legacyStructuredBeta := claudeCodeHelperBetaProfile(true, "structured-outputs-2025-12-15") + + // 1. Title 2.1.280 beta with 2.1.258 structured stream payload must be rejected + det1 := DetectClaudeCodeRequest( + measuredClaudeCodeHelperHeaders(title280Beta), + measuredClaudeCodeStructuredHelperPayload(), + false, + ) + if det1.Confirmed || det1.HelperProfile { + t.Fatalf("det1 = %#v, want 2.1.280 beta with legacy structured payload rejected", det1) + } + + // 2. Legacy structured beta with 2.1.280 title non-stream payload must be rejected + det2 := DetectClaudeCodeRequest( + measuredClaudeCodeHelperHeaders(legacyStructuredBeta), + measuredClaudeCodeTitle280HelperPayload(), + false, + ) + if det2.Confirmed || det2.HelperProfile { + t.Fatalf("det2 = %#v, want legacy beta with 2.1.280 title payload rejected", det2) + } +} + func TestDetectClaudeCodeRequestRejectsMalformedStructuredHaikuHelpers(t *testing.T) { basePayload := string(measuredClaudeCodeStructuredHelperPayload()) beta := claudeCodeHelperBetaProfile(true, "structured-outputs-2025-12-15")