mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-08 08:40:44 +08:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user