mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #7638 from Achordchan/fix/anthropic-structured-output-beta
fix(anthropic): preserve requested structured outputs beta
This commit is contained in:
@@ -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(如果未含)
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user