mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
fix(gateway): strip Anthropic fallbacks unless server-side-fallback beta is present
Clients now send body.fallbacks; upstream rejects it without anthropic-beta: server-side-fallback-2026-07-01. Mirror the context_management sanitize so OAuth mimic / default API-key beta paths no longer 400 with "fallbacks: Extra inputs are not permitted".
This commit is contained in:
@@ -25,6 +25,17 @@ const (
|
||||
BetaRedactThinking = "redact-thinking-2026-02-12"
|
||||
BetaContextManagement = "context-management-2025-06-27"
|
||||
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
|
||||
|
||||
// server-side refusal fallback beta 字段族(beta Messages API 专有)。
|
||||
// 客户端(Claude Code / SDK / OpenCode 等)会默认透传 body.fallbacks /
|
||||
// body.fallback_credit_token,上游仅在 anthropic-beta 携带对应 token 时接受;
|
||||
// 缺 token 时 Pydantic 拒收:"fallbacks: Extra inputs are not permitted"。
|
||||
// 仅用于 sanitize 的条件判断(strip-or-keep),禁止加入
|
||||
// FullClaudeCodeMimicryBetas / DefaultBetaHeader / APIKeyBetaHeader /
|
||||
// Bedrock 白名单:server-side fallback 会换模型、改计费,不能默认打开。
|
||||
BetaServerSideFallback = "server-side-fallback-2026-07-01"
|
||||
BetaFallbackCredit = "fallback-credit-2026-07-01"
|
||||
BetaFallbackCreditLegacy = "fallback-credit-2026-06-01"
|
||||
)
|
||||
|
||||
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -638,10 +639,26 @@ func filterBedrockBetaTokens(tokens []string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// sanitizeBedrockFieldsForBetaTokens 与直连路径的 sanitizeAnthropicBodyForBetaTokens
|
||||
// 对称:按最终 Bedrock beta tokens 决定是否保留 body 中的 beta 字段。
|
||||
// - context_management 缺 context-management beta → strip
|
||||
// - fallbacks 缺 server-side-fallback beta → strip
|
||||
// - fallback_credit_token 缺 server-side-fallback / 任一 fallback-credit beta → strip
|
||||
//
|
||||
// 注意:fallback beta token 均不在 bedrockSupportedBetaTokens 白名单内
|
||||
// (会被 filterBedrockBetaTokens 过滤掉),因此条件 strip 实际总会剥除——这是预期:
|
||||
// Bedrock Invoke 不支持这些 beta 字段。
|
||||
func sanitizeBedrockFieldsForBetaTokens(body []byte, betaTokens []string) []byte {
|
||||
if !containsBedrockBetaToken(betaTokens, bedrockContextManagementBetaToken) && gjson.GetBytes(body, "context_management").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "context_management")
|
||||
}
|
||||
if !containsBedrockBetaToken(betaTokens, claude.BetaServerSideFallback) && gjson.GetBytes(body, "fallbacks").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallbacks")
|
||||
}
|
||||
if !containsAnyBedrockBetaToken(betaTokens, claude.BetaServerSideFallback, claude.BetaFallbackCredit, claude.BetaFallbackCreditLegacy) &&
|
||||
gjson.GetBytes(body, "fallback_credit_token").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallback_credit_token")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -654,6 +671,16 @@ func containsBedrockBetaToken(tokens []string, target string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// containsAnyBedrockBetaToken 判断 tokens 是否包含 targets 中的任意一个 token。
|
||||
func containsAnyBedrockBetaToken(tokens []string, targets ...string) bool {
|
||||
for _, target := range targets {
|
||||
if containsBedrockBetaToken(tokens, target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bedrockToolUseIDRe 匹配 Bedrock 允许的 tool_use ID 字符(字母、数字、下划线、连字符)
|
||||
var bedrockToolUseIDRe = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
||||
|
||||
@@ -760,6 +787,8 @@ const defaultCCMaxTokens = 81920
|
||||
// - 移除 service_tier(Anthropic API 专有,Bedrock 不支持)
|
||||
// - 移除 interface_geo(Anthropic API 专有,Bedrock 不支持)
|
||||
// - 移除 context_management(Anthropic API 专有,Bedrock 不支持,CC v2.1.87+ 默认携带)
|
||||
// - 无条件移除 fallbacks / fallback_credit_token(server-side refusal fallback,
|
||||
// Anthropic 直连 beta API 专有;Bedrock Invoke 无对应 beta,永不支持)
|
||||
// - 注入 max_tokens 默认值 81920(CC 可能省略,Bedrock 要求必须提供)
|
||||
// - 注入 anthropic_version(CC 通过 HTTP 头发送,Bedrock 需要放在请求体中)
|
||||
func sanitizeBedrockCCFields(body []byte) []byte {
|
||||
@@ -772,6 +801,12 @@ func sanitizeBedrockCCFields(body []byte) []byte {
|
||||
if gjson.GetBytes(body, "context_management").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "context_management")
|
||||
}
|
||||
if gjson.GetBytes(body, "fallbacks").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallbacks")
|
||||
}
|
||||
if gjson.GetBytes(body, "fallback_credit_token").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallback_credit_token")
|
||||
}
|
||||
if !gjson.GetBytes(body, "max_tokens").Exists() {
|
||||
body, _ = sjson.SetBytes(body, "max_tokens", defaultCCMaxTokens)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 背景
|
||||
// ============================================================================
|
||||
//
|
||||
// Anthropic 上游对 body.fallbacks / body.fallback_credit_token 字段实施
|
||||
// Pydantic extra='forbid' 校验:当且仅当 anthropic-beta header 含
|
||||
// server-side-fallback-2026-07-01(fallback_credit_token 额外接受
|
||||
// fallback-credit-2026-07-01 / fallback-credit-2026-06-01)时接受。
|
||||
// 否则报:
|
||||
// "fallbacks: Extra inputs are not permitted"
|
||||
//
|
||||
// fallbacks 是 beta Messages API 的 server-side refusal fallback 字段;本仓
|
||||
// 不写入该字段,全部来自客户端(Claude Code / SDK / OpenCode 等)透传。
|
||||
// OAuth mimic 用 FullClaudeCodeMimicryBetas 覆盖客户端 beta(不含 fallback
|
||||
// beta),因此必须在出口按最终 beta header 条件 strip,与 context_management
|
||||
// 的对称约束同构。策略是"剥字段,不注入 beta":fallback 会换模型、改计费,
|
||||
// 不允许当默认打开。
|
||||
//
|
||||
// 本文件覆盖:
|
||||
// 1) sanitizeAnthropicBodyForBetaTokens 对 fallbacks / fallback_credit_token
|
||||
// 的条件 strip(以及与 context_management 的组合行为)
|
||||
// 2) buildUpstreamRequest OAuth mimic / API-key passthrough 端到端
|
||||
// 3) Bedrock 路径的对称 strip(PrepareBedrockRequestBodyWithTokens /
|
||||
// sanitizeBedrockCCFields)
|
||||
|
||||
// ============================================================================
|
||||
// sanitizeAnthropicBodyForBetaTokens — fallbacks / fallback_credit_token
|
||||
// ============================================================================
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_NoFallbackFieldsNoChange(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(body), string(out))
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbacksKeptWhenBetaPresent(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-opus-4-7","fallbacks":"default","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
|
||||
"claude-code-20250219,oauth-2025-04-20,server-side-fallback-2026-07-01")
|
||||
require.False(t, changed, "客户端 header 已带 server-side-fallback beta → 字段保留(不过度删除)")
|
||||
require.True(t, gjson.GetBytes(out, "fallbacks").Exists())
|
||||
require.Equal(t, "default", gjson.GetBytes(out, "fallbacks").String())
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbacksStrippedWhenBetaMissing(t *testing.T) {
|
||||
// 客户端透传的两种形态:字符串 "default" 与模型数组
|
||||
for name, fallbacks := range map[string]string{
|
||||
"string_default": `"fallbacks":"default"`,
|
||||
"model_array": `"fallbacks":["claude-opus-4-6","claude-sonnet-4-6"]`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5",` + fallbacks + `,"messages":[]}`)
|
||||
// 模拟 OAuth mimic / 默认 API-key beta:只有 oauth/interleaved,无 fallback beta
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
|
||||
"oauth-2025-04-20,interleaved-thinking-2025-05-14")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallbacks").Exists(),
|
||||
"header 不含 server-side-fallback beta 时必须 strip fallbacks,否则上游 400")
|
||||
require.True(t, gjson.GetBytes(out, "messages").Exists(), "strip 不得误伤其他字段")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbacksStrippedWhenHeaderEmpty(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallbacks").Exists())
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbackCreditTokenStrippedWhenCreditBetaMissing(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,interleaved-thinking-2025-05-14")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallback_credit_token").Exists(),
|
||||
"缺 credit/fallback beta 时必须 strip fallback_credit_token")
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbackCreditTokenKeptWithAnyAcceptedBeta(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
// 三个 beta token 任意一个在 header 中都必须保留字段
|
||||
for _, beta := range []string{
|
||||
claude.BetaServerSideFallback,
|
||||
claude.BetaFallbackCredit,
|
||||
claude.BetaFallbackCreditLegacy,
|
||||
} {
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,"+beta)
|
||||
require.Falsef(t, changed, "header 含 %s 时 fallback_credit_token 必须保留", beta)
|
||||
require.Truef(t, gjson.GetBytes(out, "fallback_credit_token").Exists(),
|
||||
"header 含 %s 时 fallback_credit_token 必须保留", beta)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 组合场景:只带 context-management beta → 剥 fallbacks,保留 context_management
|
||||
// (守住"早退导致 fallbacks 漏洗"与"过度删除 context_management"两个方向的回归)
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_StripsFallbacksKeepsContextManagement(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"fallbacks":"default","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "context-management-2025-06-27")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallbacks").Exists(),
|
||||
"header 只有 context-management beta → fallbacks 必须 strip")
|
||||
require.True(t, gjson.GetBytes(out, "context_management").Exists(),
|
||||
"context-management beta 在 header 中 → context_management 不得被误删")
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_KeepsBothWhenBothBetasPresent(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"fallbacks":"default","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
|
||||
"context-management-2025-06-27,server-side-fallback-2026-07-01")
|
||||
require.False(t, changed, "两个 beta 都在 header 中 → 所有字段保留")
|
||||
require.True(t, gjson.GetBytes(out, "context_management").Exists())
|
||||
require.True(t, gjson.GetBytes(out, "fallbacks").Exists())
|
||||
require.True(t, gjson.GetBytes(out, "fallback_credit_token").Exists())
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_EmptyBodyUnchanged(t *testing.T) {
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens([]byte{}, "server-side-fallback-2026-07-01")
|
||||
require.False(t, changed)
|
||||
require.Empty(t, out)
|
||||
|
||||
out, changed = sanitizeAnthropicBodyForBetaTokens(nil, "server-side-fallback-2026-07-01")
|
||||
require.False(t, changed)
|
||||
require.Empty(t, out)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// buildUpstreamRequest 端到端
|
||||
// 挡住未来某人忘调 sanitize / 将 sanitize 挪到 CCH 之后 等 regression。
|
||||
// ============================================================================
|
||||
|
||||
// OAuth mimic:FullClaudeCodeMimicryBetas 不含 fallback beta → body.fallbacks
|
||||
// 必须被 strip,且 outgoing anthropic-beta 不得注入 server-side-fallback beta
|
||||
// (剥字段,不注入 beta)。
|
||||
func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsFallbacksEndToEnd(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
account := &Account{ID: 601, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{"access_token": "oauth-tok"},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
// 客户端默认透传 "fallbacks":"default"(Claude Code / SDK / OpenCode 等)
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
req, _, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, account, body,
|
||||
"oauth-tok", "oauth", "claude-haiku-4-5", false, true, // mimicClaudeCode=true
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
outBody := readUpstreamBodyForTest(t, req)
|
||||
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
|
||||
|
||||
require.False(t, gjson.GetBytes(outBody, "fallbacks").Exists(),
|
||||
"OAuth mimic 端到端:mimic beta 集合不含 fallback beta → outgoing body 必须没有 fallbacks,"+
|
||||
"否则上游报 fallbacks: Extra inputs are not permitted")
|
||||
require.False(t, anthropicBetaTokensContains(outBeta, claude.BetaServerSideFallback),
|
||||
"修复策略是剥字段而非注入 beta:outgoing anthropic-beta 不得含 server-side-fallback beta")
|
||||
require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
|
||||
"mimic beta 集合本身不受影响")
|
||||
}
|
||||
|
||||
// API-key passthrough + 客户端 header 未带 fallback beta → strip
|
||||
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_StripsFallbacksWhenClientHeaderMissingBeta(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
// 客户端仅带 oauth beta,不带 server-side-fallback-2026-07-01
|
||||
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20")
|
||||
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "fallbacks").Exists(),
|
||||
"API-key passthrough + 客户端未带 fallback beta → strip body 字段")
|
||||
}
|
||||
|
||||
// API-key passthrough + 客户端 header 带 fallback beta → 保留(不过度删除)
|
||||
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_PreservesFallbacksWhenClientHeaderHasBeta(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,server-side-fallback-2026-07-01")
|
||||
|
||||
// 模型数组形态:有 beta 时必须原样保留
|
||||
body := []byte(`{"model":"claude-opus-4-7","fallbacks":["claude-opus-4-6","claude-sonnet-4-6"],"messages":[]}`)
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
outBody := readUpstreamBodyForTest(t, req)
|
||||
require.True(t, gjson.GetBytes(outBody, "fallbacks").Exists(),
|
||||
"客户端 header 带 server-side-fallback beta → 字段保留(不过度删除)")
|
||||
fallbacks := gjson.GetBytes(outBody, "fallbacks").Array()
|
||||
require.Len(t, fallbacks, 2)
|
||||
require.Equal(t, "claude-opus-4-6", fallbacks[0].String())
|
||||
require.Equal(t, "claude-sonnet-4-6", fallbacks[1].String())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bedrock 对称 strip
|
||||
// ============================================================================
|
||||
|
||||
// fallback beta token 不在 bedrockSupportedBetaTokens 白名单内(会被
|
||||
// filterBedrockBetaTokens 过滤),因此条件 strip 实际总会剥除——这是预期。
|
||||
func TestPrepareBedrockRequestBodyWithTokens_FallbacksRequireSupportedBeta(t *testing.T) {
|
||||
modelID := "us.anthropic.claude-opus-4-6-v1"
|
||||
|
||||
t.Run("strips fallbacks when final tokens omit server-side-fallback beta", func(t *testing.T) {
|
||||
input := `{
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"max_tokens":100,
|
||||
"fallbacks":"default",
|
||||
"fallback_credit_token":"tok_123"
|
||||
}`
|
||||
betaTokens := []string{"context-1m-2025-08-07"}
|
||||
|
||||
result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, betaTokens, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
|
||||
assert.Equal(t, "hi", gjson.GetBytes(result, "messages.0.content").String())
|
||||
assert.Equal(t, int64(100), gjson.GetBytes(result, "max_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("strips fallbacks even when client passes server-side-fallback token (not whitelisted)", func(t *testing.T) {
|
||||
input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100,"fallbacks":"default"}`
|
||||
|
||||
result, err := PrepareBedrockRequestBodyWithTokens(
|
||||
[]byte(input), modelID, []string{claude.BetaServerSideFallback}, false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists(),
|
||||
"Bedrock 白名单不含 fallback beta → 条件 strip 总会剥除(预期)")
|
||||
for _, name := range bedrockAnthropicBetaNames(result) {
|
||||
assert.NotEqual(t, claude.BetaServerSideFallback, name,
|
||||
"fallback beta 不得进入 Bedrock anthropic_beta 白名单输出")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leaves body without fallback fields otherwise intact", func(t *testing.T) {
|
||||
input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`
|
||||
|
||||
result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "context_management").Exists())
|
||||
assert.Equal(t, "hi", gjson.GetBytes(result, "messages.0.content").String())
|
||||
})
|
||||
}
|
||||
|
||||
// sanitizeBedrockCCFields:fallbacks / fallback_credit_token 是 Anthropic 直连
|
||||
// beta API 专有字段,Bedrock 无对应 beta → 无条件剥除。
|
||||
func TestSanitizeBedrockCCFields_StripsFallbacksUnconditionally(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-opus-4-6","context_management":{"edits":[]},"fallbacks":"default","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
result := sanitizeBedrockCCFields(body)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "context_management").Exists())
|
||||
assert.True(t, gjson.GetBytes(result, "messages").Exists())
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -898,9 +899,22 @@ const anthropicBetaContextManagementToken = "context-management-2025-06-27"
|
||||
// - 若两侧不一致上游 Pydantic schema 拒收:
|
||||
// "context_management: Extra inputs are not permitted"
|
||||
//
|
||||
// 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的
|
||||
// context_management 字段:缺 beta token → strip。这将限制完全建立在
|
||||
// "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。
|
||||
// fallbacks 场景(与 context_management 同构):
|
||||
// - `fallbacks` / `fallback_credit_token` 是 beta Messages API 的
|
||||
// server-side refusal fallback 字段;标准 Messages schema 没有它们,
|
||||
// 客户端(Claude Code / SDK / OpenCode 等)最近开始默认透传
|
||||
// `"fallbacks":"default"`(或模型列表)
|
||||
// - 上游接受的前提是 anthropic-beta 含 `server-side-fallback-2026-07-01`
|
||||
// (fallback_credit_token 额外接受 credit beta,见下)
|
||||
// - 缺 token 时上游 Pydantic extra='forbid' 拒收:
|
||||
// "fallbacks: Extra inputs are not permitted"
|
||||
// - 本仓不写入该字段,全部来自客户端透传;OAuth mimic 用
|
||||
// FullClaudeCodeMimicryBetas 覆盖客户端 beta(该列表不含 fallback beta),
|
||||
// 若不 strip,body 字段与 header 不对称 → 所有模型 400
|
||||
//
|
||||
// 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的上述字段:
|
||||
// 缺对应 beta token → strip;客户端 header 已带对应 beta → 保留(不过度删除)。
|
||||
// 这将限制完全建立在 "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。
|
||||
//
|
||||
// 调用约束:必须在 CCH 签名之前调用,否则签名 hash 与最终 body
|
||||
// 不一致,上游会以 third-party 拒收。
|
||||
@@ -911,23 +925,58 @@ func sanitizeAnthropicBodyForBetaTokens(body []byte, anthropicBetaHeader string)
|
||||
if len(body) == 0 {
|
||||
return body, false
|
||||
}
|
||||
if !gjson.GetBytes(body, "context_management").Exists() {
|
||||
|
||||
changed := false
|
||||
|
||||
// context_management:需要 context-management beta。
|
||||
if b, deleted := stripAnthropicBodyFieldUnlessBeta(
|
||||
body, "context_management", anthropicBetaHeader, anthropicBetaContextManagementToken,
|
||||
); deleted {
|
||||
body, changed = b, true
|
||||
}
|
||||
|
||||
// fallbacks:server-side refusal fallback,仅接受 server-side-fallback beta。
|
||||
if b, deleted := stripAnthropicBodyFieldUnlessBeta(
|
||||
body, "fallbacks", anthropicBetaHeader, claude.BetaServerSideFallback,
|
||||
); deleted {
|
||||
body, changed = b, true
|
||||
}
|
||||
|
||||
// fallback_credit_token:server-side-fallback 或(新旧任一)fallback-credit beta
|
||||
// 任意一个即可保留。
|
||||
if b, deleted := stripAnthropicBodyFieldUnlessBeta(
|
||||
body, "fallback_credit_token", anthropicBetaHeader,
|
||||
claude.BetaServerSideFallback, claude.BetaFallbackCredit, claude.BetaFallbackCreditLegacy,
|
||||
); deleted {
|
||||
body, changed = b, true
|
||||
}
|
||||
|
||||
return body, changed
|
||||
}
|
||||
|
||||
// stripAnthropicBodyFieldUnlessBeta 当 field 存在且 anthropic-beta header 不含
|
||||
// requiredTokens 中**任何一个** token 时删除该字段(保留条件:含任一 required token)。
|
||||
// 单 token 调用即「缺该 beta 则 strip」。返回 (newBody, deleted)。
|
||||
func stripAnthropicBodyFieldUnlessBeta(body []byte, field, anthropicBetaHeader string, requiredTokens ...string) ([]byte, bool) {
|
||||
if !gjson.GetBytes(body, field).Exists() {
|
||||
return body, false
|
||||
}
|
||||
if anthropicBetaTokensContains(anthropicBetaHeader, anthropicBetaContextManagementToken) {
|
||||
return body, false
|
||||
for _, token := range requiredTokens {
|
||||
if anthropicBetaTokensContains(anthropicBetaHeader, token) {
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
if b, err := sjson.DeleteBytes(body, "context_management"); err == nil {
|
||||
return b, true
|
||||
} else {
|
||||
b, err := sjson.DeleteBytes(body, field)
|
||||
if err != nil {
|
||||
// 不应发生:gjson 刚验证过字段存在 + body 是合法 JSON。如果 sjson 仍报错,
|
||||
// 调用方会拿到 (body, false),但此前 computeFinalAnthropicBeta 已按“strip 后”
|
||||
// 计算了 finalBeta——两侧会不一致。记录 warning 最小限度提醒运维。
|
||||
// 调用方会拿到原 body(视为未删除),但此前 computeFinalAnthropicBeta 可能已按
|
||||
// "strip 后" 计算了 finalBeta——两侧会不一致。记录 warning 最小限度提醒运维。
|
||||
logger.LegacyPrintf("service.gateway",
|
||||
"[CtxMgmtSanitize] sjson.DeleteBytes failed unexpectedly: %v (body len=%d). "+
|
||||
"body and final anthropic-beta header may be out of sync.", err, len(body))
|
||||
"[BetaFieldSanitize] sjson.DeleteBytes(%s) failed unexpectedly: %v (body len=%d). "+
|
||||
"body and final anthropic-beta header may be out of sync.", field, err, len(body))
|
||||
return body, false
|
||||
}
|
||||
return body, false
|
||||
return b, true
|
||||
}
|
||||
|
||||
// anthropicBetaTokensContains 检测逗号分隔的 anthropic-beta header 是否含指定 token。
|
||||
|
||||
Reference in New Issue
Block a user