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
This commit is contained in:
Luis Pater
2026-09-11 23:44:21 +08:00
parent 456d4c371b
commit d9b8fdb77f
2 changed files with 151 additions and 4 deletions

View File

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

View File

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