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
This commit is contained in:
Luis Pater
2026-09-25 12:38:18 +08:00
parent 5530cbd2a5
commit 4332048dda
9 changed files with 262 additions and 115 deletions

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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",

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")
}
}

View File

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

View File

@@ -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

View File

@@ -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")