gateway_beta_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. package service
  2. import (
  3. "testing"
  4. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  5. "github.com/stretchr/testify/require"
  6. )
  7. func TestMergeAnthropicBeta(t *testing.T) {
  8. got := mergeAnthropicBeta(
  9. []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"},
  10. "foo, oauth-2025-04-20,bar, foo",
  11. )
  12. require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,foo,bar", got)
  13. }
  14. func TestMergeAnthropicBeta_EmptyIncoming(t *testing.T) {
  15. got := mergeAnthropicBeta(
  16. []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"},
  17. "",
  18. )
  19. require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14", got)
  20. }
  21. func TestStripBetaTokens(t *testing.T) {
  22. tests := []struct {
  23. name string
  24. header string
  25. tokens []string
  26. want string
  27. }{
  28. {
  29. name: "single token in middle",
  30. header: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14",
  31. tokens: []string{"context-1m-2025-08-07"},
  32. want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
  33. },
  34. {
  35. name: "single token at start",
  36. header: "context-1m-2025-08-07,oauth-2025-04-20,interleaved-thinking-2025-05-14",
  37. tokens: []string{"context-1m-2025-08-07"},
  38. want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
  39. },
  40. {
  41. name: "single token at end",
  42. header: "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-1m-2025-08-07",
  43. tokens: []string{"context-1m-2025-08-07"},
  44. want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
  45. },
  46. {
  47. name: "token not present",
  48. header: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
  49. tokens: []string{"context-1m-2025-08-07"},
  50. want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
  51. },
  52. {
  53. name: "empty header",
  54. header: "",
  55. tokens: []string{"context-1m-2025-08-07"},
  56. want: "",
  57. },
  58. {
  59. name: "with spaces",
  60. header: "oauth-2025-04-20, context-1m-2025-08-07 , interleaved-thinking-2025-05-14",
  61. tokens: []string{"context-1m-2025-08-07"},
  62. want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
  63. },
  64. {
  65. name: "only token",
  66. header: "context-1m-2025-08-07",
  67. tokens: []string{"context-1m-2025-08-07"},
  68. want: "",
  69. },
  70. {
  71. name: "nil tokens",
  72. header: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
  73. tokens: nil,
  74. want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
  75. },
  76. {
  77. name: "multiple tokens removed",
  78. header: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,fast-mode-2026-02-01",
  79. tokens: []string{"context-1m-2025-08-07", "fast-mode-2026-02-01"},
  80. want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
  81. },
  82. {
  83. name: "DroppedBetas is empty (filtering moved to configurable beta policy)",
  84. header: "oauth-2025-04-20,context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14",
  85. tokens: claude.DroppedBetas,
  86. want: "oauth-2025-04-20,context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14",
  87. },
  88. }
  89. for _, tt := range tests {
  90. t.Run(tt.name, func(t *testing.T) {
  91. got := stripBetaTokens(tt.header, tt.tokens)
  92. require.Equal(t, tt.want, got)
  93. })
  94. }
  95. }
  96. func TestMergeAnthropicBetaDropping_Context1M(t *testing.T) {
  97. required := []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"}
  98. incoming := "context-1m-2025-08-07,foo-beta,oauth-2025-04-20"
  99. drop := map[string]struct{}{"context-1m-2025-08-07": {}}
  100. got := mergeAnthropicBetaDropping(required, incoming, drop)
  101. require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,foo-beta", got)
  102. require.NotContains(t, got, "context-1m-2025-08-07")
  103. }
  104. func TestMergeAnthropicBetaDropping_DroppedBetas(t *testing.T) {
  105. required := []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"}
  106. incoming := "context-1m-2025-08-07,fast-mode-2026-02-01,foo-beta,oauth-2025-04-20"
  107. // DroppedBetas is now empty — filtering moved to configurable beta policy.
  108. // Without a policy filter set, nothing gets dropped from the static set.
  109. drop := droppedBetaSet()
  110. got := mergeAnthropicBetaDropping(required, incoming, drop)
  111. require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-1m-2025-08-07,fast-mode-2026-02-01,foo-beta", got)
  112. require.Contains(t, got, "context-1m-2025-08-07")
  113. require.Contains(t, got, "fast-mode-2026-02-01")
  114. }
  115. func TestFullClaudeCodeMimicryBetas_DoesNotDefaultRedactThinking(t *testing.T) {
  116. required := claude.FullClaudeCodeMimicryBetas()
  117. require.NotContains(t, required, claude.BetaRedactThinking)
  118. require.Contains(t, required, claude.BetaClaudeCode)
  119. require.Contains(t, required, claude.BetaOAuth)
  120. require.Contains(t, required, claude.BetaInterleavedThinking)
  121. }
  122. func TestMergeAnthropicBetaDropping_PreservesIncomingRedactThinking(t *testing.T) {
  123. required := claude.FullClaudeCodeMimicryBetas()
  124. incoming := claude.BetaRedactThinking
  125. got := mergeAnthropicBetaDropping(required, incoming, droppedBetaSet())
  126. require.Contains(t, got, claude.BetaRedactThinking)
  127. }
  128. func TestDroppedBetaSet(t *testing.T) {
  129. // Base set contains DroppedBetas (now empty — filtering moved to configurable beta policy)
  130. base := droppedBetaSet()
  131. require.Len(t, base, len(claude.DroppedBetas))
  132. // With extra tokens
  133. extended := droppedBetaSet(claude.BetaClaudeCode)
  134. require.Contains(t, extended, claude.BetaClaudeCode)
  135. require.Len(t, extended, len(claude.DroppedBetas)+1)
  136. }
  137. func TestBuildBetaTokenSet(t *testing.T) {
  138. got := buildBetaTokenSet([]string{"foo", "", "bar", "foo"})
  139. require.Len(t, got, 2)
  140. require.Contains(t, got, "foo")
  141. require.Contains(t, got, "bar")
  142. require.NotContains(t, got, "")
  143. empty := buildBetaTokenSet(nil)
  144. require.Empty(t, empty)
  145. }
  146. func TestContainsBetaToken(t *testing.T) {
  147. tests := []struct {
  148. name string
  149. header string
  150. token string
  151. want bool
  152. }{
  153. {"present in middle", "oauth-2025-04-20,fast-mode-2026-02-01,interleaved-thinking-2025-05-14", "fast-mode-2026-02-01", true},
  154. {"present at start", "fast-mode-2026-02-01,oauth-2025-04-20", "fast-mode-2026-02-01", true},
  155. {"present at end", "oauth-2025-04-20,fast-mode-2026-02-01", "fast-mode-2026-02-01", true},
  156. {"only token", "fast-mode-2026-02-01", "fast-mode-2026-02-01", true},
  157. {"not present", "oauth-2025-04-20,interleaved-thinking-2025-05-14", "fast-mode-2026-02-01", false},
  158. {"with spaces", "oauth-2025-04-20, fast-mode-2026-02-01 , interleaved-thinking-2025-05-14", "fast-mode-2026-02-01", true},
  159. {"empty header", "", "fast-mode-2026-02-01", false},
  160. {"empty token", "fast-mode-2026-02-01", "", false},
  161. {"partial match", "fast-mode-2026-02-01-extra", "fast-mode-2026-02-01", false},
  162. }
  163. for _, tt := range tests {
  164. t.Run(tt.name, func(t *testing.T) {
  165. got := containsBetaToken(tt.header, tt.token)
  166. require.Equal(t, tt.want, got)
  167. })
  168. }
  169. }
  170. func TestStripBetaTokensWithSet_EmptyDropSet(t *testing.T) {
  171. header := "oauth-2025-04-20,interleaved-thinking-2025-05-14"
  172. got := stripBetaTokensWithSet(header, map[string]struct{}{})
  173. require.Equal(t, header, got)
  174. }
  175. func TestIsCountTokensUnsupported404(t *testing.T) {
  176. tests := []struct {
  177. name string
  178. statusCode int
  179. body string
  180. want bool
  181. }{
  182. {
  183. name: "exact endpoint not found",
  184. statusCode: 404,
  185. body: `{"error":{"message":"Not found: /v1/messages/count_tokens","type":"not_found_error"}}`,
  186. want: true,
  187. },
  188. {
  189. name: "contains count_tokens and not found",
  190. statusCode: 404,
  191. body: `{"error":{"message":"count_tokens route not found","type":"not_found_error"}}`,
  192. want: true,
  193. },
  194. {
  195. name: "generic 404",
  196. statusCode: 404,
  197. body: `{"error":{"message":"resource not found","type":"not_found_error"}}`,
  198. want: false,
  199. },
  200. {
  201. name: "404 with empty error message",
  202. statusCode: 404,
  203. body: `{"error":{"message":"","type":"not_found_error"}}`,
  204. want: false,
  205. },
  206. {
  207. name: "non-404 status",
  208. statusCode: 400,
  209. body: `{"error":{"message":"Not found: /v1/messages/count_tokens","type":"invalid_request_error"}}`,
  210. want: false,
  211. },
  212. }
  213. for _, tt := range tests {
  214. t.Run(tt.name, func(t *testing.T) {
  215. got := isCountTokensUnsupported404(tt.statusCode, []byte(tt.body))
  216. require.Equal(t, tt.want, got)
  217. })
  218. }
  219. }
  220. // TestDefaultBetaPolicy_Context1M_Sonnet5Whitelist 验证默认策略下 context-1m-2025-08-07 的分模型行为:
  221. // - claude-sonnet-5 及后续版本:pass(放行),保留 1M 上下文能力
  222. // - 其他 sonnet 版本(4.x 及以下)、opus、haiku:filter(过滤),因为上游不支持
  223. func TestDefaultBetaPolicy_Context1M_Sonnet5Whitelist(t *testing.T) {
  224. settings := DefaultBetaPolicySettings()
  225. // 找到 context-1m-2025-08-07 规则
  226. var rule *BetaPolicyRule
  227. for i := range settings.Rules {
  228. if settings.Rules[i].BetaToken == "context-1m-2025-08-07" {
  229. rule = &settings.Rules[i]
  230. break
  231. }
  232. }
  233. require.NotNil(t, rule, "default policy must include context-1m-2025-08-07 rule")
  234. require.Equal(t, BetaPolicyActionPass, rule.Action, "primary action for whitelisted models is pass")
  235. require.Equal(t, BetaPolicyActionFilter, rule.FallbackAction, "non-whitelisted models must be filtered")
  236. require.NotEmpty(t, rule.ModelWhitelist, "context-1m must be scoped to sonnet-5+ via whitelist")
  237. // 表驱动:模型 → 期望 action
  238. // 覆盖每种上游路径下的模型 ID 变形:直连 Anthropic API、Vertex AI("@YYYYMMDD" 后缀)、
  239. // AWS Bedrock 跨区域推理(us./eu./apac./jp./au./us-gov./global./anthropic. 前缀)。
  240. cases := []struct {
  241. model string
  242. wantAction string
  243. desc string
  244. }{
  245. // —— 直连 Anthropic API —— sonnet-5 系列应放行
  246. {"claude-sonnet-5", BetaPolicyActionPass, "sonnet-5 canonical"},
  247. {"claude-sonnet-5-20260701", BetaPolicyActionPass, "sonnet-5 dated variant matches wildcard"},
  248. {"claude-sonnet-5-thinking", BetaPolicyActionPass, "sonnet-5 thinking variant matches wildcard"},
  249. // —— Vertex AI 归一化后的 sonnet-5 —— 也应放行
  250. {"claude-sonnet-5@20260701", BetaPolicyActionPass, "sonnet-5 Vertex-normalized dated form"},
  251. // —— AWS Bedrock 各跨区域前缀 sonnet-5 —— 也应放行
  252. {"us.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock us. sonnet-5"},
  253. {"eu.anthropic.claude-sonnet-5-20260701-v1:0", BetaPolicyActionPass, "bedrock eu. sonnet-5 dated"},
  254. {"apac.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock apac. sonnet-5"},
  255. {"jp.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock jp. sonnet-5"},
  256. {"au.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock au. sonnet-5"},
  257. {"us-gov.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock us-gov. sonnet-5"},
  258. {"global.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock global. sonnet-5"},
  259. {"anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock no-region sonnet-5"},
  260. // —— sonnet-4.x 及以下必须过滤 ——
  261. {"claude-sonnet-4-6", BetaPolicyActionFilter, "sonnet-4.6 must be filtered"},
  262. {"claude-sonnet-4-5-20250929", BetaPolicyActionFilter, "sonnet-4.5 dated must be filtered"},
  263. {"claude-sonnet-4", BetaPolicyActionFilter, "sonnet-4 must be filtered"},
  264. {"claude-sonnet-4-5@20250929", BetaPolicyActionFilter, "sonnet-4.5 Vertex format must be filtered"},
  265. {"us.anthropic.claude-sonnet-4-6", BetaPolicyActionFilter, "bedrock us. sonnet-4.6 must be filtered"},
  266. {"us.anthropic.claude-sonnet-4-5-20250929-v1:0", BetaPolicyActionFilter, "bedrock us. sonnet-4.5 must be filtered"},
  267. // —— Opus / Haiku 必须过滤(无 1M) ——
  268. {"claude-opus-4-8", BetaPolicyActionFilter, "opus must be filtered"},
  269. {"claude-opus-4-7", BetaPolicyActionFilter, "opus 4.7 must be filtered"},
  270. {"us.anthropic.claude-opus-4-8-v1", BetaPolicyActionFilter, "bedrock opus 4.8 must be filtered"},
  271. {"claude-haiku-4-5", BetaPolicyActionFilter, "haiku must be filtered"},
  272. {"us.anthropic.claude-haiku-4-5-20251001-v1:0", BetaPolicyActionFilter, "bedrock haiku must be filtered"},
  273. {"claude-3-5-sonnet-20241022", BetaPolicyActionFilter, "legacy sonnet 3.5 must be filtered"},
  274. // —— 特殊边界:不应把 "claude-sonnet-50" / "claude-sonnet-5.1" 之类意外命名误放行 ——
  275. {"claude-sonnet-50", BetaPolicyActionFilter, "must not over-match a hypothetical sonnet-50"},
  276. }
  277. for _, tc := range cases {
  278. t.Run(tc.model, func(t *testing.T) {
  279. action, _ := resolveRuleAction(*rule, tc.model)
  280. require.Equal(t, tc.wantAction, action, tc.desc)
  281. })
  282. }
  283. }