From d7052c96af7862d84ade738ec9b6ce7ac4807b98 Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 4 Sep 2026 11:48:47 +0800 Subject: [PATCH] feat(claude): add 2.1.258 dynamic beta headers, model fallbacks, and paired cache TTL - Introduce 2.1.258 dynamic betas: thinking-display-updates-2026-08-18, server-side-fallback-2026-06-01, and fallback-credit-2026-03-24. - Prune effort-2025-11-24 on Haiku models, probes, and when thinking is disabled. - Prune thinking-display-updates on disabled thinking and probe/helper turns. - Identify Fable 5.1 / Mythos 5.1 models with boundary checks and inject default fallbacks and adaptive thinking display. - Strictly pair 1h cache control with extended-cache-ttl-2025-04-11, stripping TTL on probes and subagents. --- .../executor/claude_executor_cloaking.go | 61 +++- .../executor/claude_executor_execute.go | 7 +- .../executor/claude_executor_request.go | 157 +++++++--- .../executor/claude_executor_stream.go | 7 +- .../runtime/executor/claude_executor_test.go | 278 +++++++++++++++++- 5 files changed, 469 insertions(+), 41 deletions(-) diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index fca58ba6..fd86ffa1 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -255,6 +255,22 @@ func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, cch return checkSystemInstructionsWithSigningModeAt(payload, strictMode, cchSigning, version, entrypoint, workload, time.Now(), false, "", "") } +// isClaudeFable51Model reports whether the model is specifically Fable 5.1 / Mythos 5.1, +// matching native Claude Code 2.1.258 family/major/minor checks (AFo = {major:5, minor:1}). +func isClaudeFable51Model(model string) bool { + m := strings.ToLower(strings.TrimSpace(model)) + for _, target := range []string{"fable-5-1", "fable-5.1", "mythos-5-1", "mythos-5.1"} { + idx := strings.Index(m, target) + if idx != -1 { + nextIdx := idx + len(target) + if nextIdx >= len(m) || m[nextIdx] < '0' || m[nextIdx] > '9' { + return true + } + } + } + return false +} + func checkSystemInstructionsWithSigningModeAt( payload []byte, strictMode bool, @@ -1075,10 +1091,11 @@ func applyCloaking( billingVersion := helps.DefaultClaudeVersion(cfg) workload := getWorkloadFromContext(ctx) + isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(payload) isSubagent := false prevReq := "" promptID := "" - if !helps.IsClaudeProbeOrHelperRequest(payload) { + if !isProbeOrHelper { incomingHeaders := resolveIncomingClaudeHeaders(ctx, helps.IncomingHeadersFromContext(ctx)) isSubagent = helps.IsClaudeSubagentRequest(incomingHeaders, payload) existingPrevReq, existingPromptID := helps.ExtractClaudeBillingTags(payload) @@ -1128,6 +1145,22 @@ func applyCloaking( promptID, ) + // Fable 5.1 / Mythos 5.1 native profile: emit fallbacks and adaptive thinking display + if isClaudeFable51Model(gjson.GetBytes(payload, "model").String()) { + if !gjson.GetBytes(payload, "fallbacks").Exists() { + payload, _ = sjson.SetRawBytes(payload, "fallbacks", []byte(`[{"model":"claude-opus-5"}]`)) + } + if gjson.GetBytes(payload, "thinking.type").String() == "adaptive" && !gjson.GetBytes(payload, "thinking.display").Exists() { + payload, _ = sjson.SetBytes(payload, "thinking.display", "updates") + } + } + + // Probes and subagents never use 1h cache in native Claude Code; ensure any + // caller-supplied 1h ttl is stripped to match extended-cache-ttl beta suppression. + if isSubagent || isProbeOrHelper { + payload = stripClaudeCacheControlTTL(payload) + } + // Claude-Code-CLI fingerprint identity (real OAuth or fingerprint-profile=claude-code-cli) // is applied later through the shared ApplyClaudeCredentialMetadata path. // Other non-OAuth cloaking keeps the legacy per-request fake user_id. @@ -1266,6 +1299,32 @@ func upgradeClaudeCacheControlTTL(payload []byte, ttl string) []byte { return payload } +// stripClaudeCacheControlTTL removes any ttl field from cache_control blocks in payload, +// downgrading {"type":"ephemeral","ttl":"..."} to {"type":"ephemeral"}. +// This ensures that when extended-cache-ttl-2025-04-11 is stripped (e.g. on probes, +// subagents, or non-OAuth credentials), the body does not retain a ttl field that would +// trigger Anthropic 400 errors or fingerprint mismatch. +func stripClaudeCacheControlTTL(payload []byte) []byte { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return payload + } + + strip := func(path string, block gjson.Result) { + cacheControl := block.Get("cache_control") + if !cacheControl.IsObject() || !cacheControl.Get("ttl").Exists() { + return + } + updated, errDel := sjson.DeleteBytes(payload, path+".cache_control.ttl") + if errDel != nil { + return + } + payload = updated + } + + forEachClaudeCacheControlBlock(payload, strip) + return payload +} + // forEachClaudeCacheControlBlock walks every block that can carry cache_control // in Anthropic's evaluation order: tools, then system, then messages. func forEachClaudeCacheControlBlock(payload []byte, visit func(path string, block gjson.Result)) { diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 69d368ed..3186d62e 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -163,8 +163,13 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Only a ttl the caller wrote out explicitly survives, because // upgradeClaudeCacheControlTTL skips any block that already has one. // claude-code-cli fingerprint profiles emit extended-cache-ttl and must use the same 1h pool. - if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI { + // In native Claude Code 2.1.258, 1h cache and extended-cache-ttl are restricted to main + // interaction queries (repl_main_thread*); subagents, side queries, and probes omit both. + isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body) + if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && !isSubagent && !isProbeOrHelper { body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) + } else if isSubagent || isProbeOrHelper { + body = stripClaudeCacheControlTTL(body) } // Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05. diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index aef014ad..76a5067b 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -34,24 +34,25 @@ import ( ) const ( - claudeTokenCountingBeta = "token-counting-2024-11-01" - claudeFastModeBeta = "fast-mode-2026-02-01" - claudeOAuthBeta = "oauth-2025-04-20" - claudeCodeBeta = "claude-code-20250219" - claudeContext1MBeta = "context-1m-2025-08-07" - claudeMidConvSystemBeta = "mid-conversation-system-2026-04-07" - claudeAdvisorToolBeta = "advisor-tool-2026-03-01" - claudeAdvancedToolUseBeta = "advanced-tool-use-2025-11-20" - claudeEffortBeta = "effort-2025-11-24" - claudeServerSideFallbackBeta = "server-side-fallback-2026-06-01" - claudeFallbackCreditBeta = "fallback-credit-2026-06-01" - claudeStructuredOutputsBeta = "structured-outputs-2025-12-15" - claudeExtendedCacheTTLBeta = "extended-cache-ttl-2025-04-11" - claudeCacheDiagnosisBeta = "cache-diagnosis-2026-04-07" - claudeRedactThinkingBeta = "redact-thinking-2026-02-12" + claudeTokenCountingBeta = "token-counting-2024-11-01" + claudeFastModeBeta = "fast-mode-2026-02-01" + claudeOAuthBeta = "oauth-2025-04-20" + claudeCodeBeta = "claude-code-20250219" + claudeContext1MBeta = "context-1m-2025-08-07" + claudeMidConvSystemBeta = "mid-conversation-system-2026-04-07" + claudeAdvisorToolBeta = "advisor-tool-2026-03-01" + claudeAdvancedToolUseBeta = "advanced-tool-use-2025-11-20" + claudeEffortBeta = "effort-2025-11-24" + claudeServerSideFallbackBeta = "server-side-fallback-2026-06-01" + claudeFallbackCreditBeta = "fallback-credit-2026-06-01" + claudeStructuredOutputsBeta = "structured-outputs-2025-12-15" + claudeThinkingDisplayUpdatesBeta = "thinking-display-updates-2026-08-18" + claudeExtendedCacheTTLBeta = "extended-cache-ttl-2025-04-11" + claudeCacheDiagnosisBeta = "cache-diagnosis-2026-04-07" + claudeRedactThinkingBeta = "redact-thinking-2026-02-12" ) -// claudeCodeCLIConstantBetas are the betas Claude Code 2.1.220 sends on every +// claudeCodeCLIConstantBetas are the betas Claude Code sends on every // /v1/messages request from the "cli" entrypoint, in wire order, excluding the // leading claude-code-20250219. // @@ -76,12 +77,11 @@ var claudeCodeTrailingBetas = []string{ } // claudeCodeCLIBetas assembles the Anthropic-Beta baseline the way Claude Code -// 2.1.220 does: the list is per-request, not a fixed string. requested holds the +// 2.1.258 does: the list is per-request, not a fixed string. requested holds the // betas the caller asked for, which decide the capability flags below. // -// Verified against api.anthropic.com with isolated 2.1.220 profiles on both -// API-key and OAuth paths. A 2026-08-03 A/B capture with two distinct OAuth -// accounts confirmed the current tool beta and OAuth trailer below. +// Verified against api.anthropic.com with native 2.1.258 captures on interactive, +// non-interactive, subagent, and multi-model paths (Sonnet, Opus, Fable, Haiku). // The full observed order is: // // 1 claude-code-20250219 @@ -95,17 +95,19 @@ var claudeCodeTrailingBetas = []string{ // 9 mid-conversation-system-2026-04-07 models accepting a role=system turn // 10 advisor-tool-2026-03-01 requests declaring advisor tools or requesting advisor beta // 11 advanced-tool-use-2025-11-20 requests with tools -// 12 effort-2025-11-24 -// 13 server-side-fallback-2026-06-01 -// 14 fallback-credit-2026-06-01 -// 15 fast-mode-2026-02-01 speed:fast requests only -// 16 extended-cache-ttl-2025-04-11 OAuth credentials only -// 17 cache-diagnosis-2026-04-07 requests with diagnostics only +// 12 effort-2025-11-24 effort-supporting models with active thinking +// 13 server-side-fallback-2026-06-01 requests with fallbacks or requested +// 14 fallback-credit-2026-06-01 OAuth credentials +// 15 structured-outputs-2025-12-15 structured output requests +// 16 thinking-display-updates-2026-08-18 requests with thinking.display=updates +// 17 fast-mode-2026-02-01 speed:fast requests only +// 18 extended-cache-ttl-2025-04-11 OAuth credentials (omitted on subagent & probe) +// 19 cache-diagnosis-2026-04-07 requests with diagnostics only // // An empty body keeps the optimistic role=system default, matching the cloaking // policy for unknown and future model IDs. func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) string { - betas := make([]string, 0, len(claudeCodeCLIConstantBetas)+len(claudeCodeTrailingBetas)+7) + betas := make([]string, 0, len(claudeCodeCLIConstantBetas)+len(claudeCodeTrailingBetas)+8) betas = append(betas, claudeCodeBeta) if oauthToken { betas = append(betas, claudeOAuthBeta) @@ -129,19 +131,32 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { betas = append(betas, claudeAdvancedToolUseBeta) } - betas = append(betas, claudeEffortBeta) - if oauthToken && !requested[claudeFallbackCreditBeta] { + if claudeRequestSupportsEffort(body, requested) { + betas = append(betas, claudeEffortBeta) + } + isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(body) + if !isProbeOrHelper && (requested[claudeServerSideFallbackBeta] || gjson.GetBytes(body, "fallbacks").Exists()) { + betas = append(betas, claudeServerSideFallbackBeta) + } + if requested[claudeFallbackCreditBeta] || oauthToken { betas = append(betas, claudeFallbackCreditBeta) } for _, beta := range claudeCodeTrailingBetas { + if beta == claudeServerSideFallbackBeta || beta == claudeFallbackCreditBeta { + continue + } if requested[beta] { betas = append(betas, beta) } } + thinkingType := gjson.GetBytes(body, "thinking.type").String() + if !isProbeOrHelper && thinkingType != "disabled" && (requested[claudeThinkingDisplayUpdatesBeta] || claudeThinkingDisplayUpdates(body)) { + betas = append(betas, claudeThinkingDisplayUpdatesBeta) + } if claudeRequestUsesFastMode(body, requested) { betas = append(betas, claudeFastModeBeta) } - if oauthToken { + if oauthToken && !helps.IsClaudeSubagentRequest(nil, body) && !isProbeOrHelper { betas = append(betas, claudeExtendedCacheTTLBeta) } if diagnostics := gjson.GetBytes(body, "diagnostics"); diagnostics.IsObject() { @@ -150,6 +165,35 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) return strings.Join(betas, ",") } +func isClaudeHaikuModel(model string) bool { + return strings.Contains(strings.ToLower(model), "haiku") +} + +func claudeRequestSupportsEffort(body []byte, requested map[string]bool) bool { + if len(body) > 0 { + if helps.IsClaudeProbeOrHelperRequest(body) { + return false + } + model := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "model").String())) + if isClaudeHaikuModel(model) { + return false + } + thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) + if thinkingType == "disabled" { + return false + } + } + if requested[claudeEffortBeta] { + return true + } + return true +} + +func claudeThinkingDisplayUpdates(body []byte) bool { + display := gjson.GetBytes(body, "thinking.display") + return display.Type == gjson.String && strings.EqualFold(strings.TrimSpace(display.String()), "updates") +} + // claudeBodyHasAdvisorTool reports whether the request body declares an // advisor server tool. func claudeBodyHasAdvisorTool(body []byte) bool { @@ -250,7 +294,7 @@ func withClaudeCountTokensOAuthBeta(betas string) string { // be described accurately. // // Betas already present are left exactly where the caller put them. -func withClaudeOAuthCredentialBetas(betas string) string { +func withClaudeOAuthCredentialBetas(betas string, includeExtendedCacheTTL bool) string { parts := make([]string, 0, 16) seen := make(map[string]bool) for _, beta := range strings.Split(betas, ",") { @@ -269,12 +313,24 @@ func withClaudeOAuthCredentialBetas(betas string) string { copy(parts[insertAt+1:], parts[insertAt:]) parts[insertAt] = claudeOAuthBeta } - if !seen[claudeExtendedCacheTTLBeta] { + if includeExtendedCacheTTL && !seen[claudeExtendedCacheTTLBeta] { parts = append(parts, claudeExtendedCacheTTLBeta) } return strings.Join(parts, ",") } +func withoutClaudeBeta(betas, removeBeta string) string { + parts := strings.Split(betas, ",") + res := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" && p != removeBeta { + res = append(res, p) + } + } + return strings.Join(res, ",") +} + // withClaudeAdvisorToolBeta ensures advisor-tool-2026-03-01 is present when // the body declares an advisor server tool, placed at the observed wire position // before advanced-tool-use-2025-11-20 or effort-2025-11-24. @@ -784,7 +840,7 @@ func applyClaudeHeadersWithNativeProfile( } } - incomingBetas := strings.TrimSpace(strings.Join(incomingHeaders.Values("Anthropic-Beta"), ",")) + incomingBetas := strings.TrimSpace(strings.Join(helps.HeaderValuesCaseInsensitive(incomingHeaders, "Anthropic-Beta"), ",")) countTokens := r.URL != nil && strings.HasSuffix(r.URL.Path, "/count_tokens") requestedMap := claudeRequestedBetas(incomingBetas, extraBetas) advisorNeeded := requestedMap[claudeAdvisorToolBeta] || claudeBodyHasAdvisorTool(body) @@ -806,17 +862,24 @@ func applyClaudeHeadersWithNativeProfile( } // Measured Haiku helper requests already carry the exact credential // beta profile and intentionally omit extended-cache-ttl. + // Native Claude Code subagents and probes also omit extended-cache-ttl. if useOAuthBetas && !helperProfile { if countTokens { baseBetas = withClaudeCountTokensOAuthBeta(baseBetas) } else { - baseBetas = withClaudeOAuthCredentialBetas(baseBetas) + isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body) + isProbe := helps.IsClaudeProbeOrHelperRequest(body) + includeExtendedCacheTTL := !isSubagent && !isProbe + baseBetas = withClaudeOAuthCredentialBetas(baseBetas, includeExtendedCacheTTL) } } } if preserveCallerFingerprint && advisorNeeded { baseBetas = withClaudeAdvisorToolBeta(baseBetas) } + if !claudeRequestSupportsEffort(body, nil) { + baseBetas = withoutClaudeBeta(baseBetas, claudeEffortBeta) + } existingSet := make(map[string]bool) for _, beta := range strings.Split(baseBetas, ",") { if beta = strings.TrimSpace(beta); beta != "" { @@ -860,6 +923,28 @@ func applyClaudeHeadersWithNativeProfile( } } applyBetaHeader := func() { + // Enforce strict native Claude Code 2.1.258 model & turn beta gating: + if !claudeRequestSupportsEffort(body, nil) { + baseBetas = withoutClaudeBeta(baseBetas, claudeEffortBeta) + } + reqProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(body) + if reqProbeOrHelper { + baseBetas = withoutClaudeBeta(baseBetas, claudeServerSideFallbackBeta) + baseBetas = withoutClaudeBeta(baseBetas, claudeThinkingDisplayUpdatesBeta) + baseBetas = withoutClaudeBeta(baseBetas, claudeExtendedCacheTTLBeta) + } + reqThinkingType := gjson.GetBytes(body, "thinking.type").String() + if reqThinkingType == "disabled" { + baseBetas = withoutClaudeBeta(baseBetas, claudeThinkingDisplayUpdatesBeta) + } + if helps.IsClaudeSubagentRequest(nil, body) { + baseBetas = withoutClaudeBeta(baseBetas, claudeExtendedCacheTTLBeta) + } + reqModel := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "model").String())) + if isClaudeHaikuModel(reqModel) && !gjson.GetBytes(body, "fallbacks").Exists() { + baseBetas = withoutClaudeBeta(baseBetas, claudeServerSideFallbackBeta) + } + if strings.TrimSpace(baseBetas) == "" { r.Header.Del("Anthropic-Beta") return @@ -932,7 +1017,7 @@ func applyClaudeHeadersWithNativeProfile( identityHeader("X-Stainless-Lang", "js") // Native async SDK helpers add this header independently of body.stream. // Preserve it only after the complete native-client detector succeeds. - if confirmedClaudeCode && incomingHeaders.Get("X-Stainless-Async") == "async" { + if confirmedClaudeCode && helps.HeaderValueCaseInsensitive(incomingHeaders, "X-Stainless-Async") == "async" { r.Header.Set("X-Stainless-Async", "async") } // Claude Code omits X-Stainless-Timeout on count_tokens; only a confirmed @@ -940,7 +1025,7 @@ func applyClaudeHeadersWithNativeProfile( if !countTokens { identityHeader("X-Stainless-Timeout", hdrDefault(hd.Timeout, "600")) } else if confirmedClaudeCode { - if incomingTimeout := incomingHeaders.Get("X-Stainless-Timeout"); incomingTimeout != "" { + if incomingTimeout := helps.HeaderValueCaseInsensitive(incomingHeaders, "X-Stainless-Timeout"); incomingTimeout != "" { r.Header.Set("X-Stainless-Timeout", incomingTimeout) } } diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index cf9ccd6d..1161c913 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -164,8 +164,13 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Only a ttl the caller wrote out explicitly survives, because // upgradeClaudeCacheControlTTL skips any block that already has one. // claude-code-cli fingerprint profiles emit extended-cache-ttl and must use the same 1h pool. - if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI { + // In native Claude Code 2.1.258, 1h cache and extended-cache-ttl are restricted to main + // interaction queries (repl_main_thread*); subagents, side queries, and probes omit both. + isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body) + if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && !isSubagent && !isProbeOrHelper { body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) + } else if isSubagent || isProbeOrHelper { + body = stripClaudeCacheControlTTL(body) } // Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05. diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 2cb19ebf..335cb9a9 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -5542,9 +5542,9 @@ func TestClaudeCodeCLIBetas_MatchesObservedClientMatrix(t *testing.T) { want: constants + ",effort-2025-11-24", }, { - name: "claude-haiku-4-5-20251001 stays on the reminder path", + name: "claude-haiku-4-5-20251001 stays on the reminder path and omits effort", body: `{"model":"claude-haiku-4-5-20251001"}`, - want: constants + ",effort-2025-11-24", + want: constants, }, { name: "legacy model with tools adds advanced tool use only", @@ -5607,6 +5607,61 @@ func TestClaudeCodeCLIBetas_MatchesObservedClientMatrix(t *testing.T) { body: `{"model":"claude-opus-5","tools":[{"type":"advisor_20260301","name":"advisor"}]}`, want: constants + ",mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24", }, + { + name: "thinking display updates emits thinking-display-updates beta and drops redact-thinking", + body: `{"model":"claude-fable-5-1","thinking":{"type":"adaptive","display":"updates"}}`, + want: "claude-code-20250219,interleaved-thinking-2025-05-14," + + "thinking-token-count-2026-05-13,context-management-2025-06-27," + + "prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07," + + "effort-2025-11-24,thinking-display-updates-2026-08-18", + }, + { + name: "body with fallbacks automatically adds server-side-fallback beta", + body: `{"model":"claude-fable-5-1","fallbacks":[{"model":"claude-opus-5"}]}`, + want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24,server-side-fallback-2026-06-01", + }, + { + name: "subagent request omits extended-cache-ttl beta", + body: `{"model":"claude-sonnet-5","system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.258.0ab; cc_is_subagent=true;"}]}`, + oauth: true, + want: "claude-code-20250219,oauth-2025-04-20," + + "interleaved-thinking-2025-05-14,redact-thinking-2026-02-12," + + "thinking-token-count-2026-05-13,context-management-2025-06-27," + + "prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07," + + "effort-2025-11-24,fallback-credit-2026-06-01", + }, + { + name: "probe request max_tokens=1 omits effort and extended-cache-ttl betas", + body: `{"model":"claude-sonnet-5","max_tokens":1}`, + oauth: true, + want: "claude-code-20250219,oauth-2025-04-20," + + "interleaved-thinking-2025-05-14,redact-thinking-2026-02-12," + + "thinking-token-count-2026-05-13,context-management-2025-06-27," + + "prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07," + + "fallback-credit-2026-06-01", + }, + { + name: "haiku model omits effort beta even if requested", + body: `{"model":"claude-haiku-4-5-20251001"}`, + requested: map[string]bool{"effort-2025-11-24": true}, + oauth: true, + want: "claude-code-20250219,oauth-2025-04-20," + + "interleaved-thinking-2025-05-14,redact-thinking-2026-02-12," + + "thinking-token-count-2026-05-13,context-management-2025-06-27," + + "prompt-caching-scope-2026-01-05," + + "fallback-credit-2026-06-01,extended-cache-ttl-2025-04-11", + }, + { + name: "disabled thinking omits effort beta even if requested", + body: `{"model":"claude-sonnet-5","thinking":{"type":"disabled"}}`, + requested: map[string]bool{"effort-2025-11-24": true}, + oauth: true, + want: "claude-code-20250219,oauth-2025-04-20," + + "interleaved-thinking-2025-05-14,redact-thinking-2026-02-12," + + "thinking-token-count-2026-05-13,context-management-2025-06-27," + + "prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07," + + "fallback-credit-2026-06-01,extended-cache-ttl-2025-04-11", + }, } for _, tt := range tests { @@ -6513,3 +6568,222 @@ func TestClaudeExecutor_PreservesNativeAgentAndEnvironmentHeaders(t *testing.T) }) } } + +func TestIsClaudeFable51Model_DigitBoundary(t *testing.T) { + valid := []string{ + "claude-fable-5-1", + "claude-fable-5.1", + "claude-fable-5-1-20260901", + "claude-mythos-5-1", + "claude-mythos-5.1", + "claude-mythos-5-1-preview", + } + for _, m := range valid { + if !isClaudeFable51Model(m) { + t.Errorf("expected %q to be recognized as Fable 5.1", m) + } + } + + invalid := []string{ + "claude-fable-5-10", + "claude-fable-5.10", + "claude-mythos-5-10", + "claude-sonnet-5", + "claude-opus-5", + "claude-haiku-4-5", + } + for _, m := range invalid { + if isClaudeFable51Model(m) { + t.Errorf("expected %q NOT to be recognized as Fable 5.1", m) + } + } +} + +func TestClaudeExecutor_ProbeStripsCaller1hTTLAndBetas(t *testing.T) { + var seenHeaders http.Header + var seenBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenHeaders = r.Header.Clone() + seenBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-probe-ttl-test", + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-probe-ttl-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-probe-ttl-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + // Probe request with caller-supplied 1h TTL and forbidden betas + payload := []byte(`{ + "model": "claude-sonnet-5", + "max_tokens": 1, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "quota", "cache_control": {"type": "ephemeral", "ttl": "1h"}} + ] + }] + }`) + incomingHeaders := http.Header{} + incomingHeaders.Set("Anthropic-Beta", "extended-cache-ttl-2025-04-11,server-side-fallback-2026-06-01,thinking-display-updates-2026-08-18,effort-2025-11-24") + + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: incomingHeaders, + }) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // 1. Verify body cache_control does not have ttl: "1h" + rawBody := string(seenBody) + if strings.Contains(rawBody, `"ttl":"1h"`) || strings.Contains(rawBody, `"ttl": "1h"`) { + t.Fatalf("probe body must have ttl stripped, got: %s", rawBody) + } + + // 2. Verify forbidden betas are stripped from header + betas := seenHeaders.Get("Anthropic-Beta") + for _, forbidden := range []string{ + "extended-cache-ttl-2025-04-11", + "server-side-fallback-2026-06-01", + "thinking-display-updates-2026-08-18", + "effort-2025-11-24", + } { + if strings.Contains(betas, forbidden) { + t.Errorf("probe Anthropic-Beta must not contain %s, got: %s", forbidden, betas) + } + } +} + +func TestClaudeExecutor_SubagentStripsCaller1hTTLAndExtendedCacheBeta(t *testing.T) { + var seenHeaders http.Header + var seenBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenHeaders = r.Header.Clone() + seenBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-subagent-ttl-test", + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-subagent-ttl-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-subagent-ttl-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{ + "model": "claude-sonnet-5", + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "subagent work", "cache_control": {"type": "ephemeral", "ttl": "1h"}} + ] + }] + }`) + incomingHeaders := http.Header{} + incomingHeaders.Set("X-Claude-Code-Agent-Id", "agent-sub-123") + incomingHeaders.Set("Anthropic-Beta", "extended-cache-ttl-2025-04-11") + + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: incomingHeaders, + }) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // 1. Verify body cache_control does not have ttl: "1h" + rawBody := string(seenBody) + if strings.Contains(rawBody, `"ttl":"1h"`) || strings.Contains(rawBody, `"ttl": "1h"`) { + t.Fatalf("subagent body must have ttl stripped, got: %s", rawBody) + } + + // 2. Verify extended-cache-ttl beta is stripped from header + betas := seenHeaders.Get("Anthropic-Beta") + if strings.Contains(betas, "extended-cache-ttl-2025-04-11") { + t.Errorf("subagent Anthropic-Beta must not contain extended-cache-ttl, got: %s", betas) + } +} + +func TestClaudeExecutor_DisabledThinkingStripsDisplayBeta(t *testing.T) { + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-disabled-thinking-beta-test", + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-disabled-thinking-beta-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-disabled-thinking-beta-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{ + "model": "claude-sonnet-5", + "thinking": {"type": "disabled"}, + "messages": [{ + "role": "user", + "content": "hello" + }] + }`) + incomingHeaders := http.Header{} + incomingHeaders.Set("Anthropic-Beta", "thinking-display-updates-2026-08-18,effort-2025-11-24") + + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: incomingHeaders, + }) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + betas := seenHeaders.Get("Anthropic-Beta") + if strings.Contains(betas, "thinking-display-updates-2026-08-18") { + t.Errorf("disabled thinking must not send thinking-display-updates beta, got: %s", betas) + } + if strings.Contains(betas, "effort-2025-11-24") { + t.Errorf("disabled thinking must not send effort beta, got: %s", betas) + } +} -- 2.51.2