Merge pull request #7638 from Achordchan/fix/anthropic-structured-output-beta

fix(anthropic): preserve requested structured outputs beta
This commit is contained in:
Wesley Liddick
2026-09-28 10:52:52 +08:00
committed by GitHub
3 changed files with 87 additions and 6 deletions
+2
View File
@@ -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"
@@ -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"))
})
}
}
@@ -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(如果未含)
//