gateway_fallbacks_sanitize_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. // ============================================================================
  16. // 背景
  17. // ============================================================================
  18. //
  19. // Anthropic 上游对 body.fallbacks / body.fallback_credit_token 字段实施
  20. // Pydantic extra='forbid' 校验:当且仅当 anthropic-beta header 含
  21. // server-side-fallback-2026-07-01(fallback_credit_token 额外接受
  22. // fallback-credit-2026-07-01 / fallback-credit-2026-06-01)时接受。
  23. // 否则报:
  24. // "fallbacks: Extra inputs are not permitted"
  25. //
  26. // fallbacks 是 beta Messages API 的 server-side refusal fallback 字段;本仓
  27. // 不写入该字段,全部来自客户端(Claude Code / SDK / OpenCode 等)透传。
  28. // OAuth mimic 用 FullClaudeCodeMimicryBetas 覆盖客户端 beta(不含 fallback
  29. // beta),因此必须在出口按最终 beta header 条件 strip,与 context_management
  30. // 的对称约束同构。策略是"剥字段,不注入 beta":fallback 会换模型、改计费,
  31. // 不允许当默认打开。
  32. //
  33. // 本文件覆盖:
  34. // 1) sanitizeAnthropicBodyForBetaTokens 对 fallbacks / fallback_credit_token
  35. // 的条件 strip(以及与 context_management 的组合行为)
  36. // 2) buildUpstreamRequest OAuth mimic / API-key passthrough 端到端
  37. // 3) Bedrock 路径的对称 strip(PrepareBedrockRequestBodyWithTokens /
  38. // sanitizeBedrockCCFields)
  39. // ============================================================================
  40. // sanitizeAnthropicBodyForBetaTokens — fallbacks / fallback_credit_token
  41. // ============================================================================
  42. func TestSanitizeAnthropicBodyForBetaTokens_NoFallbackFieldsNoChange(t *testing.T) {
  43. body := []byte(`{"model":"claude-haiku-4-5","messages":[]}`)
  44. out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
  45. require.False(t, changed)
  46. require.Equal(t, string(body), string(out))
  47. }
  48. func TestSanitizeAnthropicBodyForBetaTokens_FallbacksKeptWhenBetaPresent(t *testing.T) {
  49. body := []byte(`{"model":"claude-opus-4-7","fallbacks":"default","messages":[]}`)
  50. out, changed := sanitizeAnthropicBodyForBetaTokens(body,
  51. "claude-code-20250219,oauth-2025-04-20,server-side-fallback-2026-07-01")
  52. require.False(t, changed, "客户端 header 已带 server-side-fallback beta → 字段保留(不过度删除)")
  53. require.True(t, gjson.GetBytes(out, "fallbacks").Exists())
  54. require.Equal(t, "default", gjson.GetBytes(out, "fallbacks").String())
  55. }
  56. func TestSanitizeAnthropicBodyForBetaTokens_FallbacksStrippedWhenBetaMissing(t *testing.T) {
  57. // 客户端透传的两种形态:字符串 "default" 与模型数组
  58. for name, fallbacks := range map[string]string{
  59. "string_default": `"fallbacks":"default"`,
  60. "model_array": `"fallbacks":["claude-opus-4-6","claude-sonnet-4-6"]`,
  61. } {
  62. t.Run(name, func(t *testing.T) {
  63. body := []byte(`{"model":"claude-haiku-4-5",` + fallbacks + `,"messages":[]}`)
  64. // 模拟 OAuth mimic / 默认 API-key beta:只有 oauth/interleaved,无 fallback beta
  65. out, changed := sanitizeAnthropicBodyForBetaTokens(body,
  66. "oauth-2025-04-20,interleaved-thinking-2025-05-14")
  67. require.True(t, changed)
  68. require.False(t, gjson.GetBytes(out, "fallbacks").Exists(),
  69. "header 不含 server-side-fallback beta 时必须 strip fallbacks,否则上游 400")
  70. require.True(t, gjson.GetBytes(out, "messages").Exists(), "strip 不得误伤其他字段")
  71. })
  72. }
  73. }
  74. func TestSanitizeAnthropicBodyForBetaTokens_FallbacksStrippedWhenHeaderEmpty(t *testing.T) {
  75. body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
  76. out, changed := sanitizeAnthropicBodyForBetaTokens(body, "")
  77. require.True(t, changed)
  78. require.False(t, gjson.GetBytes(out, "fallbacks").Exists())
  79. }
  80. func TestSanitizeAnthropicBodyForBetaTokens_FallbackCreditTokenStrippedWhenCreditBetaMissing(t *testing.T) {
  81. body := []byte(`{"model":"claude-haiku-4-5","fallback_credit_token":"tok_123","messages":[]}`)
  82. out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,interleaved-thinking-2025-05-14")
  83. require.True(t, changed)
  84. require.False(t, gjson.GetBytes(out, "fallback_credit_token").Exists(),
  85. "缺 credit/fallback beta 时必须 strip fallback_credit_token")
  86. }
  87. func TestSanitizeAnthropicBodyForBetaTokens_FallbackCreditTokenKeptWithAnyAcceptedBeta(t *testing.T) {
  88. body := []byte(`{"model":"claude-haiku-4-5","fallback_credit_token":"tok_123","messages":[]}`)
  89. // 三个 beta token 任意一个在 header 中都必须保留字段
  90. for _, beta := range []string{
  91. claude.BetaServerSideFallback,
  92. claude.BetaFallbackCredit,
  93. claude.BetaFallbackCreditLegacy,
  94. } {
  95. out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,"+beta)
  96. require.Falsef(t, changed, "header 含 %s 时 fallback_credit_token 必须保留", beta)
  97. require.Truef(t, gjson.GetBytes(out, "fallback_credit_token").Exists(),
  98. "header 含 %s 时 fallback_credit_token 必须保留", beta)
  99. }
  100. }
  101. // ★ 组合场景:只带 context-management beta → 剥 fallbacks,保留 context_management
  102. // (守住"早退导致 fallbacks 漏洗"与"过度删除 context_management"两个方向的回归)
  103. func TestSanitizeAnthropicBodyForBetaTokens_StripsFallbacksKeepsContextManagement(t *testing.T) {
  104. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"fallbacks":"default","messages":[]}`)
  105. out, changed := sanitizeAnthropicBodyForBetaTokens(body, "context-management-2025-06-27")
  106. require.True(t, changed)
  107. require.False(t, gjson.GetBytes(out, "fallbacks").Exists(),
  108. "header 只有 context-management beta → fallbacks 必须 strip")
  109. require.True(t, gjson.GetBytes(out, "context_management").Exists(),
  110. "context-management beta 在 header 中 → context_management 不得被误删")
  111. }
  112. func TestSanitizeAnthropicBodyForBetaTokens_KeepsBothWhenBothBetasPresent(t *testing.T) {
  113. body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"fallbacks":"default","fallback_credit_token":"tok_123","messages":[]}`)
  114. out, changed := sanitizeAnthropicBodyForBetaTokens(body,
  115. "context-management-2025-06-27,server-side-fallback-2026-07-01")
  116. require.False(t, changed, "两个 beta 都在 header 中 → 所有字段保留")
  117. require.True(t, gjson.GetBytes(out, "context_management").Exists())
  118. require.True(t, gjson.GetBytes(out, "fallbacks").Exists())
  119. require.True(t, gjson.GetBytes(out, "fallback_credit_token").Exists())
  120. }
  121. func TestSanitizeAnthropicBodyForBetaTokens_EmptyBodyUnchanged(t *testing.T) {
  122. out, changed := sanitizeAnthropicBodyForBetaTokens([]byte{}, "server-side-fallback-2026-07-01")
  123. require.False(t, changed)
  124. require.Empty(t, out)
  125. out, changed = sanitizeAnthropicBodyForBetaTokens(nil, "server-side-fallback-2026-07-01")
  126. require.False(t, changed)
  127. require.Empty(t, out)
  128. }
  129. // ============================================================================
  130. // buildUpstreamRequest 端到端
  131. // 挡住未来某人忘调 sanitize / 将 sanitize 挪到 CCH 之后 等 regression。
  132. // ============================================================================
  133. // OAuth mimic:FullClaudeCodeMimicryBetas 不含 fallback beta → body.fallbacks
  134. // 必须被 strip,且 outgoing anthropic-beta 不得注入 server-side-fallback beta
  135. // (剥字段,不注入 beta)。
  136. func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsFallbacksEndToEnd(t *testing.T) {
  137. gin.SetMode(gin.TestMode)
  138. rec := httptest.NewRecorder()
  139. c, _ := gin.CreateTestContext(rec)
  140. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  141. account := &Account{ID: 601, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
  142. Credentials: map[string]any{"access_token": "oauth-tok"},
  143. Status: StatusActive,
  144. Schedulable: true,
  145. }
  146. // 客户端默认透传 "fallbacks":"default"(Claude Code / SDK / OpenCode 等)
  147. body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
  148. svc := &GatewayService{cfg: &config.Config{}}
  149. req, _, err := svc.buildUpstreamRequest(
  150. context.Background(), c, account, body,
  151. "oauth-tok", "oauth", "claude-haiku-4-5", false, true, // mimicClaudeCode=true
  152. )
  153. require.NoError(t, err)
  154. outBody := readUpstreamBodyForTest(t, req)
  155. outBeta := getHeaderRaw(req.Header, "anthropic-beta")
  156. require.False(t, gjson.GetBytes(outBody, "fallbacks").Exists(),
  157. "OAuth mimic 端到端:mimic beta 集合不含 fallback beta → outgoing body 必须没有 fallbacks,"+
  158. "否则上游报 fallbacks: Extra inputs are not permitted")
  159. require.False(t, anthropicBetaTokensContains(outBeta, claude.BetaServerSideFallback),
  160. "修复策略是剥字段而非注入 beta:outgoing anthropic-beta 不得含 server-side-fallback beta")
  161. require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
  162. "mimic beta 集合本身不受影响")
  163. }
  164. // API-key passthrough + 客户端 header 未带 fallback beta → strip
  165. func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_StripsFallbacksWhenClientHeaderMissingBeta(t *testing.T) {
  166. gin.SetMode(gin.TestMode)
  167. rec := httptest.NewRecorder()
  168. c, _ := gin.CreateTestContext(rec)
  169. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  170. // 客户端仅带 oauth beta,不带 server-side-fallback-2026-07-01
  171. c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20")
  172. body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
  173. svc := &GatewayService{cfg: &config.Config{}}
  174. req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
  175. context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
  176. )
  177. require.NoError(t, err)
  178. require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "fallbacks").Exists(),
  179. "API-key passthrough + 客户端未带 fallback beta → strip body 字段")
  180. }
  181. // API-key passthrough + 客户端 header 带 fallback beta → 保留(不过度删除)
  182. func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_PreservesFallbacksWhenClientHeaderHasBeta(t *testing.T) {
  183. gin.SetMode(gin.TestMode)
  184. rec := httptest.NewRecorder()
  185. c, _ := gin.CreateTestContext(rec)
  186. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  187. c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,server-side-fallback-2026-07-01")
  188. // 模型数组形态:有 beta 时必须原样保留
  189. body := []byte(`{"model":"claude-opus-4-7","fallbacks":["claude-opus-4-6","claude-sonnet-4-6"],"messages":[]}`)
  190. svc := &GatewayService{cfg: &config.Config{}}
  191. req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
  192. context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
  193. )
  194. require.NoError(t, err)
  195. outBody := readUpstreamBodyForTest(t, req)
  196. require.True(t, gjson.GetBytes(outBody, "fallbacks").Exists(),
  197. "客户端 header 带 server-side-fallback beta → 字段保留(不过度删除)")
  198. fallbacks := gjson.GetBytes(outBody, "fallbacks").Array()
  199. require.Len(t, fallbacks, 2)
  200. require.Equal(t, "claude-opus-4-6", fallbacks[0].String())
  201. require.Equal(t, "claude-sonnet-4-6", fallbacks[1].String())
  202. }
  203. // ============================================================================
  204. // Bedrock 对称 strip
  205. // ============================================================================
  206. // fallback beta token 不在 bedrockSupportedBetaTokens 白名单内(会被
  207. // filterBedrockBetaTokens 过滤),因此条件 strip 实际总会剥除——这是预期。
  208. func TestPrepareBedrockRequestBodyWithTokens_FallbacksRequireSupportedBeta(t *testing.T) {
  209. modelID := "us.anthropic.claude-opus-4-6-v1"
  210. t.Run("strips fallbacks when final tokens omit server-side-fallback beta", func(t *testing.T) {
  211. input := `{
  212. "messages":[{"role":"user","content":"hi"}],
  213. "max_tokens":100,
  214. "fallbacks":"default",
  215. "fallback_credit_token":"tok_123"
  216. }`
  217. betaTokens := []string{"context-1m-2025-08-07"}
  218. result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, betaTokens, false)
  219. require.NoError(t, err)
  220. assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
  221. assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
  222. assert.Equal(t, "hi", gjson.GetBytes(result, "messages.0.content").String())
  223. assert.Equal(t, int64(100), gjson.GetBytes(result, "max_tokens").Int())
  224. })
  225. t.Run("strips fallbacks even when client passes server-side-fallback token (not whitelisted)", func(t *testing.T) {
  226. input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100,"fallbacks":"default"}`
  227. result, err := PrepareBedrockRequestBodyWithTokens(
  228. []byte(input), modelID, []string{claude.BetaServerSideFallback}, false,
  229. )
  230. require.NoError(t, err)
  231. assert.False(t, gjson.GetBytes(result, "fallbacks").Exists(),
  232. "Bedrock 白名单不含 fallback beta → 条件 strip 总会剥除(预期)")
  233. for _, name := range bedrockAnthropicBetaNames(result) {
  234. assert.NotEqual(t, claude.BetaServerSideFallback, name,
  235. "fallback beta 不得进入 Bedrock anthropic_beta 白名单输出")
  236. }
  237. })
  238. t.Run("leaves body without fallback fields otherwise intact", func(t *testing.T) {
  239. input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`
  240. result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, nil, false)
  241. require.NoError(t, err)
  242. assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
  243. assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
  244. assert.False(t, gjson.GetBytes(result, "context_management").Exists())
  245. assert.Equal(t, "hi", gjson.GetBytes(result, "messages.0.content").String())
  246. })
  247. }
  248. // sanitizeBedrockCCFields:fallbacks / fallback_credit_token 是 Anthropic 直连
  249. // beta API 专有字段,Bedrock 无对应 beta → 无条件剥除。
  250. func TestSanitizeBedrockCCFields_StripsFallbacksUnconditionally(t *testing.T) {
  251. body := []byte(`{"model":"claude-opus-4-6","context_management":{"edits":[]},"fallbacks":"default","fallback_credit_token":"tok_123","messages":[]}`)
  252. result := sanitizeBedrockCCFields(body)
  253. assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
  254. assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
  255. assert.False(t, gjson.GetBytes(result, "context_management").Exists())
  256. assert.True(t, gjson.GetBytes(result, "messages").Exists())
  257. }