From 68c2f5ae6d4db1c7288d2772298839309af6252b Mon Sep 17 00:00:00 2001 From: Achord Chan Date: Mon, 28 Sep 2026 10:10:47 +0800 Subject: [PATCH] fix(anthropic): preserve requested structured outputs beta --- backend/internal/pkg/claude/constants.go | 2 + .../gateway_structured_outputs_beta_test.go | 74 +++++++++++++++++++ .../service/gateway_upstream_request.go | 17 +++-- 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 backend/internal/service/gateway_structured_outputs_beta_test.go diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index c6b13f4ed1..9cdcfc16b8 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -18,6 +18,8 @@ const ( BetaTokenCounting = "token-counting-2024-11-01" BetaContext1M = "context-1m-2025-08-07" BetaFastMode = "fast-mode-2026-02-01" + // Legacy structured output compatibility; forwarded only when explicitly requested. + BetaStructuredOutputs = "structured-outputs-2025-11-13" // 新增(对齐官方 CLI 2.1.9x 以来的流量) BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" diff --git a/backend/internal/service/gateway_structured_outputs_beta_test.go b/backend/internal/service/gateway_structured_outputs_beta_test.go new file mode 100644 index 0000000000..720fe407f1 --- /dev/null +++ b/backend/internal/service/gateway_structured_outputs_beta_test.go @@ -0,0 +1,74 @@ +//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 TestStructuredOutputsBetaOAuthMimic(t *testing.T) { + const beta = "structured-outputs-2025-11-13" + for _, tc := range []struct { + name string + header string + drop map[string]struct{} + want bool + }{ + {"explicit", beta, nil, true}, + {"mixed and duplicate", "custom-beta, " + beta + "," + beta, nil, true}, + {"absent", "custom-beta", nil, false}, + {"similar token", beta + "-other", nil, false}, + {"filtered", beta, map[string]struct{}{beta: {}}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newTestGatewayServiceForBeta(false) + header := http.Header{} + header.Set("anthropic-beta", tc.header) + body := []byte(`{"output_format":{"type":"json_schema","schema":{"type":"object"}}}`) + got, set := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-5", header, body, tc.drop) + require.True(t, set) + require.Equal(t, tc.want, containsBetaToken(got, beta)) + require.False(t, containsBetaToken(got, "custom-beta")) + require.True(t, containsBetaToken(got, "oauth-2025-04-20")) + if tc.want { + require.Equal(t, 1, strings.Count(got, beta)) + } + }) + } +} + +func TestBuildUpstreamRequestStructuredOutputsBeta(t *testing.T) { + const beta = "structured-outputs-2025-11-13" + for _, filtered := range []bool{false, true} { + t.Run(map[bool]string{false: "preserved", true: "policy filtered"}[filtered], 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", beta+",custom-beta") + if filtered { + c.Set(betaPolicyFilterSetKey, map[string]struct{}{beta: {}}) + } + account := &Account{ID: 701, Platform: PlatformAnthropic, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "test-token"}, Status: StatusActive, Schedulable: true} + body := []byte(`{"model":"claude-sonnet-5","max_tokens":1024,"output_format":{"type":"json_schema","schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"],"additionalProperties":false}},"messages":[{"role":"user","content":"Return JSON"}]}`) + svc := &GatewayService{cfg: &config.Config{}} + req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, + "test-token", "oauth", "claude-sonnet-5", false, true) + require.NoError(t, err) + out := readUpstreamBodyForTest(t, req) + require.JSONEq(t, gjson.GetBytes(body, "output_format").Raw, gjson.GetBytes(out, "output_format").Raw) + header := getHeaderRaw(req.Header, "anthropic-beta") + require.Equal(t, !filtered, containsBetaToken(header, beta)) + require.False(t, containsBetaToken(header, "custom-beta")) + }) + } +} diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index ff2438a615..ff88e71003 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -97,8 +97,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)=== // // 顺序约束: - // 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径会忽略客户端 beta, - // 与原“OAuth + mimicClaudeCode 跳过白名单透传”行为对齐) + // 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径仅保留明确支持的 + // 客户端兼容性 beta,其余使用固定列表) // 2) 按 finalBeta 做能力维度 body sanitize(如 context-management beta 缺失 → // strip body.context_management,与 Bedrock 路径对称) // 3) CCH 签名(必须使用 strip 后的 body,否则 hash 与最终 body 不一致 → @@ -509,9 +509,14 @@ func (s *GatewayService) computeFinalAnthropicBeta( if tokenType == "oauth" { if mimicClaudeCode { - // mimic 路径跳过白名单透传,incomingBeta 始终为空;所有模型都必须 - // 携带完整 Claude Code beta 集合,避免 Haiku 被识别为第三方客户端。 - return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), "", effectiveDropSet), true + // 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 + } + return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), incomingBeta, effectiveDropSet), true } // 真 Claude Code 客户端透传路径 return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true @@ -536,7 +541,7 @@ func (s *GatewayService) computeFinalAnthropicBeta( // 两条特殊规则: // // - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting; -// count_tokens 另外保留客户端 beta,而 messages mimic 会忽略客户端 beta。 +// count_tokens 另外保留客户端 beta,而 messages mimic 仅保留明确支持的兼容性 token。 // - OAuth 透传 + 客户端未传 anthropic-beta:补齐 CountTokensBetaHeader // - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting(如果未含) //