mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-22 15:44:58 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
142
internal/runtime/executor/claude_issue_6120_test.go
Normal file
142
internal/runtime/executor/claude_issue_6120_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user