From d9b8fdb77fb6bc2f3218ea0bd5b7b7364e755807 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 11 Sep 2026 23:44:21 +0800 Subject: [PATCH] fix(claude): forward unmanaged caller betas on direct anthropic requests - Define a managed Claude beta set to distinguish proxy-governed betas from caller extensions. - Forward unmanaged caller betas on direct Anthropic endpoints to support newer client features. Closes: #5738 --- .../claude_executor_beta_passthrough_test.go | 99 +++++++++++++++++++ .../executor/claude_executor_request.go | 56 ++++++++++- 2 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 internal/runtime/executor/claude_executor_beta_passthrough_test.go diff --git a/internal/runtime/executor/claude_executor_beta_passthrough_test.go b/internal/runtime/executor/claude_executor_beta_passthrough_test.go new file mode 100644 index 000000000..04bd35dbd --- /dev/null +++ b/internal/runtime/executor/claude_executor_beta_passthrough_test.go @@ -0,0 +1,99 @@ +package executor + +import ( + "net/http" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" +) + +// fixtureAuth builds a cloaked direct-Anthropic API-key auth for header tests. +// The key value is a placeholder that never reaches a real upstream. +func fixtureAuth() *cliproxyauth.Auth { + attrs := map[string]string{} + attrs[cliproxyauth.AttributeAPIKey] = strings.Join([]string{"test", "fixture", "key"}, "-") + attrs["fingerprint_profile"] = "claude-code-cli" + return &cliproxyauth.Auth{Attributes: attrs} +} + +// A caller the cloak does not recognize (a Claude Code newer than the pinned +// profile) keeps betas the proxy does not manage: dropping them fails whole +// turns whose features need the missing authorization, e.g. per-turn effort +// directives with per-turn-control-2026-07-01 (#5738). +func TestApplyClaudeHeaders_ForwardsUnmanagedCallerBetas(t *testing.T) { + t.Parallel() + + auth := fixtureAuth() + req, errReq := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", nil) + if errReq != nil { + t.Fatalf("NewRequest() error = %v", errReq) + } + incoming := http.Header{} + incoming.Set("Anthropic-Beta", "per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01") + if errHeaders := applyClaudeHeaders(req, auth, auth.Attributes[cliproxyauth.AttributeAPIKey], false, nil, []byte(`{"model":"claude-fable-5-1"}`), &config.Config{}, incoming, false); errHeaders != nil { + t.Fatalf("applyClaudeHeaders() error = %v", errHeaders) + } + betas := req.Header.Get("Anthropic-Beta") + for _, want := range []string{"per-turn-control-2026-07-01", "mid-conversation-tool-changes-2026-07-01"} { + if !strings.Contains(betas, want) { + t.Fatalf("Anthropic-Beta = %q, want unmanaged caller beta %q forwarded", betas, want) + } + } +} + +// Managed betas stay governed by the assembled baseline on direct Anthropic: +// one whose gating excludes the request (effort on a Haiku model) is not +// reinstated just because the caller asked for it. +func TestApplyClaudeHeaders_StillGatesManagedCallerBetas(t *testing.T) { + t.Parallel() + + auth := fixtureAuth() + req, errReq := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", nil) + if errReq != nil { + t.Fatalf("NewRequest() error = %v", errReq) + } + incoming := http.Header{} + incoming.Set("Anthropic-Beta", "effort-2025-11-24") + if errHeaders := applyClaudeHeaders(req, auth, auth.Attributes[cliproxyauth.AttributeAPIKey], false, nil, []byte(`{"model":"claude-haiku-4-5"}`), &config.Config{}, incoming, false); errHeaders != nil { + t.Fatalf("applyClaudeHeaders() error = %v", errHeaders) + } + if betas := req.Header.Get("Anthropic-Beta"); strings.Contains(betas, "effort-2025-11-24") { + t.Fatalf("Anthropic-Beta = %q, want gated effort beta kept off the Haiku request", betas) + } +} + +// TestApplyClaudeHeaders_ForwardsUnmanagedCallerBetas_OAuth verifies the exact +// scenario reported in #5738: an OAuth credential with an unconfirmed client +// sending a per-turn effort directive turn. +func TestApplyClaudeHeaders_ForwardsUnmanagedCallerBetas_OAuth(t *testing.T) { + t.Parallel() + + auth := &cliproxyauth.Auth{ + ID: "claude-oauth-fixture", + Metadata: map[string]any{"access_token": "sk-ant-oat-fixture"}, + } + req, errReq := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", nil) + if errReq != nil { + t.Fatalf("NewRequest() error = %v", errReq) + } + incoming := http.Header{} + incoming.Set("Anthropic-Beta", "per-turn-control-2026-07-01,mid-conversation-system-2026-04-07") + body := []byte(`{ + "model": "claude-fable-5-1", + "max_tokens": 16, + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "system", "content": [], "output_config": {"effort": "low"}}, + {"role": "user", "content": "say ok"} + ] + }`) + if errHeaders := applyClaudeHeaders(req, auth, "sk-ant-oat-fixture", false, nil, body, &config.Config{}, incoming, false); errHeaders != nil { + t.Fatalf("applyClaudeHeaders() error = %v", errHeaders) + } + betas := req.Header.Get("Anthropic-Beta") + if !strings.Contains(betas, "per-turn-control-2026-07-01") { + t.Fatalf("Anthropic-Beta = %q, want unmanaged caller beta %q forwarded on OAuth", betas, "per-turn-control-2026-07-01") + } +} diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 17bf9a42e..88554b334 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -77,6 +77,43 @@ var claudeCodeTrailingBetas = []string{ claudeStructuredOutputsBeta, } +// claudeManagedBetaSet holds every beta the proxy itself assembles or gates. +// Caller betas outside this set are unknown to the pinned Claude Code profile — +// newer client releases ship betas past it — and are forwarded verbatim so +// their features keep working (#5738). +var claudeManagedBetaSet = func() map[string]bool { + managed := []string{ + claudeTokenCountingBeta, + claudeFastModeBeta, + claudeOAuthBeta, + claudeCodeBeta, + claudeContext1MBeta, + claudeMidConvSystemBeta, + claudeAdvisorToolBeta, + claudeAdvancedToolUseBeta, + claudeEffortBeta, + claudeServerSideFallbackBeta, + claudeFallbackCreditBeta, + claudeStructuredOutputsBeta, + claudeThinkingDisplayUpdatesBeta, + claudeExtendedCacheTTLBeta, + claudeCacheDiagnosisBeta, + claudeRedactThinkingBeta, + claudeAFKModeBeta, + } + managed = append(managed, claudeCodeCLIConstantBetas...) + managed = append(managed, claudeCodeTrailingBetas...) + set := make(map[string]bool, len(managed)) + for _, beta := range managed { + set[beta] = true + } + return set +}() + +func isManagedClaudeBeta(beta string) bool { + return claudeManagedBetaSet[strings.TrimSpace(beta)] +} + // claudeCodeCLIBetas assembles the Anthropic-Beta baseline the way Claude Code // 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. @@ -962,11 +999,22 @@ func applyClaudeHeadersWithNativeProfile( appendBeta(beta) } } else { - // On direct Anthropic an unconfirmed CLI-profile caller's own betas are - // dropped: appending them to the measured baseline produces a shape real - // Claude Code never sends. Custom gateways keep caller extensions. - if !confirmedClaudeCode && incomingBetas != "" && !isAnthropicBase { + // On direct Anthropic an unconfirmed CLI-profile caller's managed betas + // are dropped: appending them to the measured baseline produces a shape + // real Claude Code never sends. Caller betas the proxy does not manage + // are newer-client features the pinned profile predates; dropping them + // fails those requests outright (per-turn effort directives need + // per-turn-control-2026-07-01), so they are forwarded (#5738). Custom + // gateways keep all caller extensions. + if !confirmedClaudeCode && incomingBetas != "" { for _, beta := range strings.Split(incomingBetas, ",") { + beta = strings.TrimSpace(beta) + if beta == "" { + continue + } + if isManagedClaudeBeta(beta) && isAnthropicBase { + continue + } appendBeta(beta) } }