From 852f0f824fd7e7f038ac8a3649701bafbbf600b1 Mon Sep 17 00:00:00 2001 From: Achord Chan Date: Sat, 3 Oct 2026 10:34:06 +0800 Subject: [PATCH] fix(anthropic): preserve requested tool change beta headers --- backend/internal/pkg/claude/constants.go | 3 + .../service/gateway_tool_changes_beta_test.go | 63 +++++++++++++++++++ .../service/gateway_upstream_request.go | 20 +++--- 3 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 backend/internal/service/gateway_tool_changes_beta_test.go diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 6c2aeebba3..5e0f9fc276 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -20,6 +20,9 @@ const ( BetaFastMode = "fast-mode-2026-02-01" // Legacy structured output compatibility; forwarded only when explicitly requested. BetaStructuredOutputs = "structured-outputs-2025-11-13" + // Tool-change compatibility tokens; never enabled without an explicit client request. + BetaMidConversationToolChanges = "mid-conversation-tool-changes-2026-07-01" + BetaInlineTools = "inline-tools-2026-09-15" // 新增(对齐官方 CLI 2.1.9x 以来的流量) BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" diff --git a/backend/internal/service/gateway_tool_changes_beta_test.go b/backend/internal/service/gateway_tool_changes_beta_test.go new file mode 100644 index 0000000000..a7ffd6a79e --- /dev/null +++ b/backend/internal/service/gateway_tool_changes_beta_test.go @@ -0,0 +1,63 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBuildUpstreamRequestToolChangeBetas(t *testing.T) { + const mid = "mid-conversation-tool-changes-2026-07-01" + const inline = "inline-tools-2026-09-15" + for _, tc := range []struct { + name, header string + drop map[string]struct{} + wantMid, wantInline bool + }{ + {"both", mid + "," + inline, nil, true, true}, + {"mid only", mid, nil, true, false}, + {"inline only", inline, nil, false, true}, + {"absent", "", nil, false, false}, + {"unknown", inline + "-other", nil, false, false}, + {"duplicate", inline + ", " + inline + "," + mid, nil, true, true}, + {"filter inline", mid + "," + inline, map[string]struct{}{inline: {}}, true, false}, + {"filter mid", mid + "," + inline, map[string]struct{}{mid: {}}, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("anthropic-beta", tc.header+",unrelated-beta") + if tc.drop != nil { + c.Set(betaPolicyFilterSetKey, tc.drop) + } + account := &Account{ID: 701, Platform: PlatformAnthropic, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "test-token"}, Status: StatusActive, Schedulable: true} + body := []byte(`{"model":"claude-opus-5-5","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"tool_addition","tool":{"type":"tool_reference","tool_name":"lookup"}},{"type":"text","text":"Use lookup"}]}]}`) + svc := &GatewayService{cfg: &config.Config{}} + req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, + "test-token", "oauth", "claude-opus-5-5", false, true) + require.NoError(t, err) + out := readUpstreamBodyForTest(t, req) + require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(out, "messages").Raw) + header := getHeaderRaw(req.Header, "anthropic-beta") + require.Equal(t, tc.wantMid, containsBetaToken(header, mid)) + require.Equal(t, tc.wantInline, containsBetaToken(header, inline)) + require.False(t, containsBetaToken(header, "unrelated-beta")) + require.False(t, containsBetaToken(header, inline+"-other")) + require.True(t, containsBetaToken(header, "oauth-2025-04-20")) + if tc.wantInline { + require.Equal(t, 1, strings.Count(header, inline)) + } + }) + } +} diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index b6e91a52a8..06ee09822c 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -545,14 +545,20 @@ func (s *GatewayService) computeFinalAnthropicBeta( if tokenType == "oauth" { if mimicClaudeCode { - // Keep the default beta set, with a narrow opt-in for legacy structured - // output requests. Unknown client betas remain excluded and policy drops - // still take precedence over this compatibility token. - incomingBeta := "" - if containsBetaToken(clientBeta, claude.BetaStructuredOutputs) { - incomingBeta = claude.BetaStructuredOutputs + // Preserve explicitly requested compatibility tokens so the forwarded + // body and headers agree. Unknown betas remain excluded; policy drops + // still take precedence. Missing companion tokens are not inferred. + var incomingBetas []string + for _, token := range []string{ + claude.BetaStructuredOutputs, + claude.BetaMidConversationToolChanges, + claude.BetaInlineTools, + } { + if containsBetaToken(clientBeta, token) { + incomingBetas = append(incomingBetas, token) + } } - return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), incomingBeta, effectiveDropSet), true + return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), strings.Join(incomingBetas, ","), effectiveDropSet), true } // 真 Claude Code 客户端透传路径 return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true