gateway_context_management_test.go 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. "github.com/tidwall/gjson"
  16. )
  17. // ============================================================================
  18. // 背景
  19. // ============================================================================
  20. //
  21. // Anthropic 上游对 body.context_management 字段实施 Pydantic schema 校验:
  22. // 当且仅当 anthropic-beta header 含 context-management-2025-06-27 时接受。
  23. // 否则报:
  24. // "context_management: Extra inputs are not permitted"
  25. //
  26. // 本仓采用能力维度对称约束(与 Bedrock 路径的 sanitizeBedrockFieldsForBetaTokens
  27. // 对称):在所有 Anthropic 直连出口,按最终 anthropic-beta header 是否含上述 token
  28. // 决定 body 是否保留同名字段。
  29. //
  30. // 本文件覆盖:
  31. // 1) sanitizeAnthropicBodyForBetaTokens 纯函数
  32. // 2) anthropicBetaTokensContains 解析辅助函数
  33. // 3) computeFinalAnthropicBeta / computeFinalCountTokensAnthropicBeta 各路径
  34. // 4) normalizeClaudeOAuthRequestBody 的 context_management 补齐行为(不再按 model 短路)
  35. // ============================================================================
  36. // anthropicBetaTokensContains
  37. // ============================================================================
  38. func TestAnthropicBetaTokensContains_EmptyInputs(t *testing.T) {
  39. require.False(t, anthropicBetaTokensContains("", "context-management-2025-06-27"))
  40. require.False(t, anthropicBetaTokensContains("oauth-2025-04-20", ""))
  41. }
  42. func TestAnthropicBetaTokensContains_SingleToken(t *testing.T) {
  43. require.True(t, anthropicBetaTokensContains("context-management-2025-06-27", "context-management-2025-06-27"))
  44. }
  45. func TestAnthropicBetaTokensContains_MultiTokenComma(t *testing.T) {
  46. header := "oauth-2025-04-20,context-management-2025-06-27,interleaved-thinking-2025-05-14"
  47. require.True(t, anthropicBetaTokensContains(header, "context-management-2025-06-27"))
  48. require.True(t, anthropicBetaTokensContains(header, "oauth-2025-04-20"))
  49. require.False(t, anthropicBetaTokensContains(header, "fast-mode-2026-02-01"))
  50. }
  51. func TestAnthropicBetaTokensContains_ToleratesWhitespace(t *testing.T) {
  52. header := "oauth-2025-04-20 , context-management-2025-06-27 , interleaved-thinking-2025-05-14"
  53. require.True(t, anthropicBetaTokensContains(header, "context-management-2025-06-27"))
  54. }
  55. func TestAnthropicBetaTokensContains_SubstringNotMatched(t *testing.T) {
  56. // 严格 token 比较,不应被子串误匹配
  57. require.False(t, anthropicBetaTokensContains("context-management-2025-06-27-rev2", "context-management-2025-06-27"),
  58. "必须按 token 边界匹配,不允许 prefix 子串误命中")
  59. }
  60. // ============================================================================
  61. // sanitizeAnthropicBodyForBetaTokens
  62. // ============================================================================
  63. func TestSanitizeAnthropicBodyForBetaTokens_NoFieldNoChange(t *testing.T) {
  64. body := []byte(`{"model":"claude-haiku-4-5","messages":[]}`)
  65. out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
  66. require.False(t, changed)
  67. require.Equal(t, string(body), string(out))
  68. }
  69. func TestSanitizeAnthropicBodyForBetaTokens_FieldKeptWhenBetaPresent(t *testing.T) {
  70. body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
  71. out, changed := sanitizeAnthropicBodyForBetaTokens(body,
  72. "oauth-2025-04-20,context-management-2025-06-27,interleaved-thinking-2025-05-14")
  73. require.False(t, changed)
  74. require.True(t, gjson.GetBytes(out, "context_management").Exists())
  75. require.Equal(t, "clear_thinking_20251015",
  76. gjson.GetBytes(out, "context_management.edits.0.type").String())
  77. }
  78. func TestSanitizeAnthropicBodyForBetaTokens_FieldStrippedWhenBetaMissing(t *testing.T) {
  79. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
  80. out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,interleaved-thinking-2025-05-14")
  81. require.True(t, changed)
  82. require.False(t, gjson.GetBytes(out, "context_management").Exists(),
  83. "header 不含 context-management beta 时必须 strip 同名字段")
  84. }
  85. func TestSanitizeAnthropicBodyForBetaTokens_FieldStrippedWhenBetaEmpty(t *testing.T) {
  86. body := []byte(`{"context_management":{"edits":[]},"messages":[]}`)
  87. out, changed := sanitizeAnthropicBodyForBetaTokens(body, "")
  88. require.True(t, changed)
  89. require.False(t, gjson.GetBytes(out, "context_management").Exists())
  90. }
  91. func TestSanitizeAnthropicBodyForBetaTokens_EmptyBody(t *testing.T) {
  92. out, changed := sanitizeAnthropicBodyForBetaTokens([]byte{}, "")
  93. require.False(t, changed)
  94. require.Empty(t, out)
  95. out, changed = sanitizeAnthropicBodyForBetaTokens(nil, "")
  96. require.False(t, changed)
  97. require.Empty(t, out)
  98. }
  99. func TestSanitizeAnthropicBodyForBetaTokens_ThinkingBlockBindingKeptWhenBetaPresent(t *testing.T) {
  100. body := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}},"messages":[]}`)
  101. out, changed := sanitizeAnthropicBodyForBetaTokens(body, claude.BetaThinkingBindingControls)
  102. require.False(t, changed)
  103. require.Equal(t, "drop_block",
  104. gjson.GetBytes(out, "thinking.block_binding.prefix_mismatch_behavior").String())
  105. }
  106. func TestSanitizeAnthropicBodyForBetaTokens_ThinkingBlockBindingStrippedWhenBetaMissing(t *testing.T) {
  107. body := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}},"messages":[]}`)
  108. out, changed := sanitizeAnthropicBodyForBetaTokens(body, claude.BetaContextManagement)
  109. require.True(t, changed)
  110. require.False(t, gjson.GetBytes(out, "thinking.block_binding").Exists())
  111. require.Equal(t, "adaptive", gjson.GetBytes(out, "thinking.type").String())
  112. require.Equal(t, "summarized", gjson.GetBytes(out, "thinking.display").String())
  113. }
  114. // ★ 关键回归断言:能力维度 sanitize 解决了 "真 CC + haiku" 路径的过度删除问题。
  115. // 真实 Claude Code CLI 2.1.87+ 客户端 header 含 context-management beta;
  116. // 即使 model 是 haiku,sanitize 也不应剥离功能字段。
  117. func TestSanitizeAnthropicBodyForBetaTokens_HaikuRealCCClientPreservesField(t *testing.T) {
  118. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[]}`)
  119. // 真 Claude Code CLI 2.1.87+ 客户端 header 含 context-management beta
  120. clientBeta := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27"
  121. out, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta)
  122. require.False(t, changed,
  123. "真 CC 客户端 header 含 context-management beta 时,haiku body 字段必须保留(功能不丢)")
  124. require.True(t, gjson.GetBytes(out, "context_management").Exists())
  125. }
  126. // ============================================================================
  127. // computeFinalAnthropicBeta — 关键路径
  128. // ============================================================================
  129. func newTestGatewayServiceForBeta(injectBetaForAPIKey bool) *GatewayService {
  130. cfg := &config.Config{}
  131. cfg.Gateway.InjectBetaForAPIKey = injectBetaForAPIKey
  132. return &GatewayService{cfg: cfg}
  133. }
  134. func TestComputeFinalAnthropicBeta_OAuthMimic_NonHaiku_IncludesContextManagement(t *testing.T) {
  135. s := newTestGatewayServiceForBeta(false)
  136. final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", http.Header{}, []byte(`{}`), nil)
  137. require.True(t, ok)
  138. require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
  139. "OAuth mimic non-haiku 必须注入完整 CC mimicry beta,含 context-management-2025-06-27")
  140. require.True(t, anthropicBetaTokensContains(final, claude.BetaOAuth))
  141. require.True(t, anthropicBetaTokensContains(final, claude.BetaClaudeCode))
  142. require.True(t, anthropicBetaTokensContains(final, claude.BetaThinkingBindingControls),
  143. "OAuth mimic 必须注入 thinking block binding 所需的 beta")
  144. }
  145. func TestComputeFinalAnthropicBeta_OAuthMimic_Haiku_IncludesFullClaudeCodeBetas(t *testing.T) {
  146. s := newTestGatewayServiceForBeta(false)
  147. final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil)
  148. require.True(t, ok)
  149. require.Equal(t, strings.Join(claude.FullClaudeCodeMimicryBetas(), ","), final)
  150. for _, beta := range claude.FullClaudeCodeMimicryBetas() {
  151. require.Truef(t, anthropicBetaTokensContains(final, beta),
  152. "OAuth mimic Haiku 必须包含完整 Claude Code beta 集合,缺少 %s", beta)
  153. }
  154. }
  155. func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBeta(t *testing.T) {
  156. // mimic 路径下原代码白名单透传被跳过,client beta 应被忽略
  157. s := newTestGatewayServiceForBeta(false)
  158. hdr := http.Header{}
  159. hdr.Set("anthropic-beta", "custom-experimental-beta")
  160. final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
  161. require.True(t, ok)
  162. require.False(t, strings.Contains(final, "custom-experimental-beta"),
  163. "mimic 路径必须忽略客户端 anthropic-beta header")
  164. }
  165. func TestComputeFinalAnthropicBeta_OAuthTransparent_NonHaiku_PreservesClientContextManagement(t *testing.T) {
  166. // 真 CC 客户端透传:客户端 header 中的 context-management beta 必须保留
  167. s := newTestGatewayServiceForBeta(false)
  168. hdr := http.Header{}
  169. hdr.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27")
  170. final, ok := s.computeFinalAnthropicBeta("oauth", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
  171. require.True(t, ok)
  172. require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement))
  173. }
  174. func TestComputeFinalAnthropicBeta_OAuthTransparent_Haiku_RealCCPreservesContextManagement(t *testing.T) {
  175. // haiku 透传 + 客户端带 context-management beta → 必须保留
  176. // (能力维度核心场景:避免 model-name 误删客户端透传的功能 beta)
  177. s := newTestGatewayServiceForBeta(false)
  178. hdr := http.Header{}
  179. hdr.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27,interleaved-thinking-2025-05-14")
  180. final, ok := s.computeFinalAnthropicBeta("oauth", false, "claude-haiku-4-5", hdr, []byte(`{}`), nil)
  181. require.True(t, ok)
  182. require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
  183. "真 CC + haiku + 客户端带 context-management beta → 透传必须保留")
  184. }
  185. func TestComputeFinalAnthropicBeta_APIKey_PassesClientBetaThroughDropSet(t *testing.T) {
  186. s := newTestGatewayServiceForBeta(false)
  187. hdr := http.Header{}
  188. hdr.Set("anthropic-beta", "oauth-2025-04-20,custom-beta")
  189. final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
  190. require.True(t, ok)
  191. require.True(t, anthropicBetaTokensContains(final, "oauth-2025-04-20"))
  192. require.True(t, anthropicBetaTokensContains(final, "custom-beta"))
  193. }
  194. func TestComputeFinalAnthropicBeta_APIKey_NoClientBetaInjectOff_ShouldNotSet(t *testing.T) {
  195. s := newTestGatewayServiceForBeta(false)
  196. final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-sonnet-4-6", http.Header{}, []byte(`{}`), nil)
  197. require.False(t, ok, "API-key + 客户端未传 + InjectBetaForAPIKey 关 → 不应主动设置 anthropic-beta")
  198. require.Equal(t, "", final)
  199. }
  200. func TestComputeFinalAnthropicBeta_APIKeyHaiku_StillUsesAPIKeyBetas(t *testing.T) {
  201. s := newTestGatewayServiceForBeta(true)
  202. body := []byte(`{"model":"claude-haiku-4-5","thinking":{"type":"enabled"},"messages":[]}`)
  203. final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-haiku-4-5", http.Header{}, body, nil)
  204. require.True(t, ok)
  205. require.Equal(t, claude.APIKeyHaikuBetaHeader, final)
  206. require.False(t, anthropicBetaTokensContains(final, claude.BetaOAuth))
  207. require.False(t, anthropicBetaTokensContains(final, claude.BetaClaudeCode))
  208. }
  209. // ============================================================================
  210. // computeFinalCountTokensAnthropicBeta
  211. // ============================================================================
  212. func TestComputeFinalCountTokensAnthropicBeta_OAuthMimic_AlwaysIncludesContextManagement(t *testing.T) {
  213. // count_tokens mimic 继续注入完整 mimicry beta,并额外携带 token-counting beta。
  214. s := newTestGatewayServiceForBeta(false)
  215. final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil)
  216. require.True(t, ok)
  217. require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
  218. "count_tokens + mimic Haiku 必须保留 context-management beta")
  219. require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting),
  220. "count_tokens 路径必须含 token-counting beta")
  221. }
  222. // 重构等价性回归:
  223. // 原 main buildCountTokensRequest 在 count_tokens mimic 分支上不跳过白名单透传
  224. // (与 messages mimic 不同),incomingBeta 取自客户端透传。重构后必须从 clientHeaders
  225. // 拿同一个值并 merge,否则会丢失客户端 beta。
  226. func TestComputeFinalCountTokensAnthropicBeta_OAuthMimic_PreservesClientBeta(t *testing.T) {
  227. s := newTestGatewayServiceForBeta(false)
  228. hdr := http.Header{}
  229. hdr.Set("anthropic-beta", "custom-experimental-beta,context-1m-2025-08-07")
  230. final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", hdr, []byte(`{}`), nil)
  231. require.True(t, ok)
  232. require.True(t, anthropicBetaTokensContains(final, "custom-experimental-beta"),
  233. "count_tokens mimic 不同于 messages mimic:原代码会保留客户端透传的 beta")
  234. require.True(t, anthropicBetaTokensContains(final, "context-1m-2025-08-07"),
  235. "客户端透传的其他 beta token 同样需要保留")
  236. require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
  237. "同时 FullClaudeCodeMimicryBetas 不打折扣")
  238. require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting),
  239. "同时补齐 token-counting beta")
  240. }
  241. // messages mimic 路径反向验证:原代码会跳过白名单透传,
  242. // 客户端 beta 不会进入 mimic 计算。重构后 messages computeFinalAnthropicBeta
  243. // mimic 分支依然不该使用 clientBeta。
  244. func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBetaExplicit(t *testing.T) {
  245. s := newTestGatewayServiceForBeta(false)
  246. hdr := http.Header{}
  247. hdr.Set("anthropic-beta", "custom-experimental-beta")
  248. final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
  249. require.True(t, ok)
  250. require.False(t, anthropicBetaTokensContains(final, "custom-experimental-beta"),
  251. "messages mimic 原代码跳过白名单透传 → 客户端 beta 不进入计算。"+
  252. "与 count_tokens mimic 是不同的设计,不能合并为同一函数。")
  253. }
  254. func TestComputeFinalCountTokensAnthropicBeta_OAuthTransparent_NoClientBetaInjectsDefault(t *testing.T) {
  255. // 真 CC 客户端透传 + 客户端未传 anthropic-beta → 用 CountTokensBetaHeader 兜底
  256. s := newTestGatewayServiceForBeta(false)
  257. final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", false, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil)
  258. require.True(t, ok)
  259. require.Equal(t, claude.CountTokensBetaHeader, final)
  260. // CountTokensBetaHeader 不含 context-management beta
  261. require.False(t, anthropicBetaTokensContains(final, claude.BetaContextManagement))
  262. }
  263. func TestComputeFinalCountTokensAnthropicBeta_OAuthTransparent_AppendsBetaTokenCounting(t *testing.T) {
  264. s := newTestGatewayServiceForBeta(false)
  265. hdr := http.Header{}
  266. hdr.Set("anthropic-beta", "oauth-2025-04-20,context-management-2025-06-27")
  267. final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
  268. require.True(t, ok)
  269. require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting),
  270. "客户端未带 token-counting beta 时必须补齐")
  271. require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
  272. "客户端带的 context-management beta 必须保留")
  273. }
  274. // ============================================================================
  275. // normalizeClaudeOAuthRequestBody — 回归:context_management 补齐恢复原行为
  276. // ============================================================================
  277. //
  278. // 重构后该函数不再按 model 名短路:thinking=enabled/adaptive 时补齐 context_management,
  279. // 与 model 无关。strip 责任移交 sanitizeAnthropicBodyForBetaTokens(在
  280. // buildUpstreamRequest 层按最终 beta header 执行)。
  281. func TestNormalizeClaudeOAuthRequestBody_InjectsContextManagement_ThinkingEnabled(t *testing.T) {
  282. body := []byte(`{"model":"claude-sonnet-4-6","thinking":{"type":"enabled","budget_tokens":1000},"messages":[]}`)
  283. out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{})
  284. require.True(t, gjson.GetBytes(out, "context_management").Exists())
  285. require.Equal(t, "clear_thinking_20251015",
  286. gjson.GetBytes(out, "context_management.edits.0.type").String())
  287. }
  288. func TestNormalizeClaudeOAuthRequestBody_InjectsContextManagement_ThinkingAdaptive(t *testing.T) {
  289. body := []byte(`{"model":"claude-opus-4-7","thinking":{"type":"adaptive"},"messages":[]}`)
  290. out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-4-7", claudeOAuthNormalizeOptions{})
  291. require.True(t, gjson.GetBytes(out, "context_management").Exists())
  292. }
  293. func TestNormalizeClaudeOAuthRequestBody_HaikuStillInjects_StripDeferredToSanitize(t *testing.T) {
  294. // Haiku + thinking=enabled:normalize 阶段仍按 CLI mimicry 行为补齐字段;
  295. // 最终是否保留仍由 beta 能力对称的 sanitize 统一决定。
  296. body := []byte(`{"model":"claude-haiku-4-5","thinking":{"type":"enabled","budget_tokens":1000},"messages":[]}`)
  297. out, _ := normalizeClaudeOAuthRequestBody(body, "claude-haiku-4-5", claudeOAuthNormalizeOptions{})
  298. require.True(t, gjson.GetBytes(out, "context_management").Exists(),
  299. "normalize 不再按 model 名短路;strip 责任移交 sanitize 层")
  300. }
  301. func TestNormalizeClaudeOAuthRequestBody_PreservesClientContextManagement(t *testing.T) {
  302. body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"custom_strategy"}]},"thinking":{"type":"enabled","budget_tokens":1000},"messages":[]}`)
  303. out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-4-7", claudeOAuthNormalizeOptions{})
  304. require.Equal(t, "custom_strategy",
  305. gjson.GetBytes(out, "context_management.edits.0.type").String(),
  306. "客户端透传的 context_management 内容必须原样保留")
  307. }
  308. func TestNormalizeClaudeOAuthRequestBody_NoThinking_NoInject(t *testing.T) {
  309. body := []byte(`{"model":"claude-sonnet-4-6","messages":[]}`)
  310. out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{})
  311. require.False(t, gjson.GetBytes(out, "context_management").Exists())
  312. }
  313. func TestNormalizeClaudeOAuthRequestBody_HaikuShortModelStillNormalizesToDatedID(t *testing.T) {
  314. body := []byte(`{"model":"claude-haiku-4-5","messages":[]}`)
  315. out, modelID := normalizeClaudeOAuthRequestBody(body, "claude-haiku-4-5", claudeOAuthNormalizeOptions{})
  316. require.Equal(t, "claude-haiku-4-5-20251001", modelID)
  317. require.Equal(t, "claude-haiku-4-5-20251001", gjson.GetBytes(out, "model").String())
  318. }
  319. func TestApplyClaudeCodeOAuthMimicryToBody_HaikuRewritesSystem(t *testing.T) {
  320. account := &Account{ID: 405, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
  321. body := []byte(`{"model":"claude-haiku-4-5","system":"Pi project instructions","messages":[{"role":"user","content":"hello"}]}`)
  322. svc := &GatewayService{cfg: &config.Config{}}
  323. out := svc.applyClaudeCodeOAuthMimicryToBody(
  324. context.Background(), nil, account, body, "Pi project instructions", "claude-haiku-4-5",
  325. )
  326. system := gjson.GetBytes(out, "system").Array()
  327. require.Len(t, system, 3)
  328. require.Contains(t, system[0].Get("text").String(), "x-anthropic-billing-header:")
  329. require.Equal(t, claudeCodeSystemPrompt, system[1].Get("text").String())
  330. require.Contains(t, gjson.GetBytes(out, "messages.0.content.0.text").String(), "Pi project instructions")
  331. require.Equal(t, "claude-haiku-4-5-20251001", gjson.GetBytes(out, "model").String())
  332. }
  333. func TestApplyClaudeCodeOAuthMimicryToBody_FableOmitsRefusedExpansion(t *testing.T) {
  334. account := &Account{ID: 406, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
  335. body := []byte(`{"model":"claude-fable-5","system":"Project instructions","messages":[{"role":"user","content":"hello"}]}`)
  336. svc := &GatewayService{cfg: &config.Config{}}
  337. out := svc.applyClaudeCodeOAuthMimicryToBody(
  338. context.Background(), nil, account, body, "Project instructions", "claude-fable-5",
  339. )
  340. system := gjson.GetBytes(out, "system").Array()
  341. require.Len(t, system, 2)
  342. require.Contains(t, system[0].Get("text").String(), "x-anthropic-billing-header:")
  343. require.Equal(t, claudeCodeSystemPrompt, system[1].Get("text").String())
  344. require.NotContains(t, string(out), claudeCodeSystemPromptExpansion)
  345. require.Contains(t, gjson.GetBytes(out, "messages.0.content.0.text").String(), "Project instructions")
  346. require.Equal(t, "Understood. I will follow these instructions.", gjson.GetBytes(out, "messages.1.content.0.text").String())
  347. require.Equal(t, "hello", gjson.GetBytes(out, "messages.2.content").String())
  348. }
  349. // ============================================================================
  350. // passthrough 集成测试:buildUpstreamRequest-
  351. // AnthropicAPIKeyPassthrough 与 buildCountTokensRequestAnthropicAPIKeyPassthrough
  352. // 路径上 sanitize 是否生效。
  353. // ============================================================================
  354. // passthrough 集成测试不设 base_url,避开 validateUpstreamBaseURL 对 cfg.Security 的依赖。
  355. // targetURL 会走默认 claudeAPIURL,sanitize 逻辑与 baseURL 是否存在无关。
  356. func newAnthropicAPIKeyPassthroughAccountForBetaTest() *Account {
  357. return &Account{
  358. ID: 501,
  359. Name: "anthropic-apikey-passthrough-ctxmgmt-test",
  360. Platform: PlatformAnthropic,
  361. Type: AccountTypeAPIKey,
  362. Credentials: map[string]any{
  363. "api_key": "upstream-key",
  364. },
  365. Extra: map[string]any{"anthropic_passthrough": true},
  366. Status: StatusActive,
  367. Schedulable: true,
  368. }
  369. }
  370. func readUpstreamBodyForTest(t *testing.T, req *http.Request) []byte {
  371. t.Helper()
  372. require.NotNil(t, req.Body)
  373. b, err := io.ReadAll(req.Body)
  374. require.NoError(t, err)
  375. return b
  376. }
  377. func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_StripsContextManagementWhenClientHeaderMissingBeta(t *testing.T) {
  378. gin.SetMode(gin.TestMode)
  379. rec := httptest.NewRecorder()
  380. c, _ := gin.CreateTestContext(rec)
  381. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  382. // 客户端仅带 oauth beta,不带 context-management-2025-06-27
  383. c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20")
  384. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
  385. svc := &GatewayService{cfg: &config.Config{}}
  386. req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
  387. context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
  388. )
  389. require.NoError(t, err)
  390. require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
  391. "API-key passthrough + 客户端未带 context-management beta → strip body 字段")
  392. }
  393. func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_PreservesContextManagementWhenClientHeaderHasBeta(t *testing.T) {
  394. gin.SetMode(gin.TestMode)
  395. rec := httptest.NewRecorder()
  396. c, _ := gin.CreateTestContext(rec)
  397. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  398. c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,context-management-2025-06-27")
  399. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
  400. svc := &GatewayService{cfg: &config.Config{}}
  401. req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
  402. context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
  403. )
  404. require.NoError(t, err)
  405. require.True(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
  406. "API-key passthrough + 客户端带 context-management beta → 字段保留(不过度删除)")
  407. }
  408. func TestBuildCountTokensRequestAnthropicAPIKeyPassthrough_StripsContextManagementWhenClientHeaderMissingBeta(t *testing.T) {
  409. gin.SetMode(gin.TestMode)
  410. rec := httptest.NewRecorder()
  411. c, _ := gin.CreateTestContext(rec)
  412. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  413. c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,token-counting-2024-11-01")
  414. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[]},"messages":[]}`)
  415. svc := &GatewayService{cfg: &config.Config{}}
  416. req, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(
  417. context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
  418. )
  419. require.NoError(t, err)
  420. require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
  421. "count_tokens passthrough + 客户端未带 context-management beta → strip")
  422. }
  423. // ============================================================================
  424. // 集成测试:buildUpstreamRequest
  425. // 全路径验证上游 outgoing body 与 anthropic-beta header 严格对称。
  426. // 这个测试能挡住未来某人忘调 sanitize / 将 sanitize 挪到 CCH 之后 等 regression。
  427. // ============================================================================
  428. func TestBuildUpstreamRequest_OAuthMimicHaiku_PreservesContextManagementEndToEnd(t *testing.T) {
  429. gin.SetMode(gin.TestMode)
  430. rec := httptest.NewRecorder()
  431. c, _ := gin.CreateTestContext(rec)
  432. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  433. account := &Account{ID: 401, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
  434. Credentials: map[string]any{"access_token": "oauth-tok"},
  435. Status: StatusActive,
  436. Schedulable: true,
  437. }
  438. // Haiku + mimic CC 使用完整 beta,其中包含 context-management;body 必须对称保留。
  439. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
  440. svc := &GatewayService{cfg: &config.Config{}}
  441. req, _, err := svc.buildUpstreamRequest(
  442. context.Background(), c, account, body,
  443. "oauth-tok", "oauth", "claude-haiku-4-5", false, true, // mimicClaudeCode=true
  444. )
  445. require.NoError(t, err)
  446. outBody := readUpstreamBodyForTest(t, req)
  447. outBeta := getHeaderRaw(req.Header, "anthropic-beta")
  448. require.True(t, gjson.GetBytes(outBody, "context_management").Exists(),
  449. "OAuth mimic + Haiku 端到端:outgoing body 必须保留 context_management")
  450. require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
  451. "对称约束:outgoing anthropic-beta header 必须包含 context-management beta")
  452. require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaClaudeCode),
  453. "Haiku mimic 必须携带 claude-code beta")
  454. }
  455. func TestBuildUpstreamRequest_APIKeyHaiku_RemainsUnmimicked(t *testing.T) {
  456. gin.SetMode(gin.TestMode)
  457. rec := httptest.NewRecorder()
  458. c, _ := gin.CreateTestContext(rec)
  459. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  460. account := &Account{
  461. ID: 404, Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
  462. Credentials: map[string]any{"api_key": "sk-ant-xxx"},
  463. Status: StatusActive, Schedulable: true,
  464. }
  465. body := []byte(`{"model":"claude-haiku-4-5","system":"API-key client system","thinking":{"type":"enabled"},"messages":[]}`)
  466. svc := newTestGatewayServiceForBeta(true)
  467. req, _, err := svc.buildUpstreamRequest(
  468. context.Background(), c, account, body,
  469. "sk-ant-xxx", "apikey", "claude-haiku-4-5", false, false,
  470. )
  471. require.NoError(t, err)
  472. outBody := readUpstreamBodyForTest(t, req)
  473. require.Equal(t, "API-key client system", gjson.GetBytes(outBody, "system").String())
  474. require.Equal(t, claude.APIKeyHaikuBetaHeader, getHeaderRaw(req.Header, "anthropic-beta"))
  475. require.False(t, anthropicBetaTokensContains(getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth))
  476. require.NotContains(t, string(outBody), "x-anthropic-billing-header:")
  477. }
  478. func TestBuildUpstreamRequest_OAuthMimicNonHaiku_PreservesContextManagementEndToEnd(t *testing.T) {
  479. gin.SetMode(gin.TestMode)
  480. rec := httptest.NewRecorder()
  481. c, _ := gin.CreateTestContext(rec)
  482. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  483. account := &Account{ID: 402, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
  484. Credentials: map[string]any{"access_token": "oauth-tok"},
  485. Status: StatusActive,
  486. Schedulable: true,
  487. }
  488. // sonnet + mimic CC → final beta = FullClaudeCodeMimicryBetas(含 context-management)→
  489. // body 保留。
  490. body := []byte(`{"model":"claude-sonnet-4-6","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
  491. svc := &GatewayService{cfg: &config.Config{}}
  492. req, _, err := svc.buildUpstreamRequest(
  493. context.Background(), c, account, body,
  494. "oauth-tok", "oauth", "claude-sonnet-4-6", false, true,
  495. )
  496. require.NoError(t, err)
  497. outBody := readUpstreamBodyForTest(t, req)
  498. outBeta := getHeaderRaw(req.Header, "anthropic-beta")
  499. require.True(t, gjson.GetBytes(outBody, "context_management").Exists(),
  500. "OAuth mimic + non-haiku:outgoing body 必须保留 context_management。")
  501. require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
  502. "对称约束:outgoing anthropic-beta header 同时含 context-management beta")
  503. }
  504. func TestBuildUpstreamRequest_OAuthTransparentHaikuWithRealCCBeta_PreservesField(t *testing.T) {
  505. // 端到端验证:真 CC 客户端 + haiku + 客户端 header 带 context-management beta
  506. // → final beta 透传 → 不应该过度删除 body 字段
  507. gin.SetMode(gin.TestMode)
  508. rec := httptest.NewRecorder()
  509. c, _ := gin.CreateTestContext(rec)
  510. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  511. c.Request.Header.Set("Anthropic-Beta",
  512. "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27")
  513. account := &Account{ID: 403, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
  514. Credentials: map[string]any{"access_token": "oauth-tok"},
  515. Status: StatusActive, Schedulable: true,
  516. }
  517. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[]}`)
  518. svc := &GatewayService{cfg: &config.Config{}}
  519. req, _, err := svc.buildUpstreamRequest(
  520. context.Background(), c, account, body,
  521. "oauth-tok", "oauth", "claude-haiku-4-5", false, false, // mimicClaudeCode=false(真 CC)
  522. )
  523. require.NoError(t, err)
  524. outBody := readUpstreamBodyForTest(t, req)
  525. outBeta := getHeaderRaw(req.Header, "anthropic-beta")
  526. require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
  527. "真 CC 透传路径:客户端 header 中的 context-management beta 必须保留")
  528. require.True(t, gjson.GetBytes(outBody, "context_management").Exists(),
  529. "回归保护:真 CC + haiku + 客户端带 beta token 时,clear_thinking_20251015 功能不能静默失效")
  530. }
  531. // count_tokens 主路径 E2E 集成测试
  532. func TestBuildCountTokensRequest_OAuthMimicHaiku_PreservesContextManagementEndToEnd(t *testing.T) {
  533. // count_tokens 继续注入 BetaContextManagement 和 BetaTokenCounting;
  534. // sanitize 看到最终 beta header 含 context-management beta 后保留字段。
  535. gin.SetMode(gin.TestMode)
  536. rec := httptest.NewRecorder()
  537. c, _ := gin.CreateTestContext(rec)
  538. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  539. account := &Account{ID: 411, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
  540. Credentials: map[string]any{"access_token": "oauth-tok"},
  541. Status: StatusActive, Schedulable: true,
  542. }
  543. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
  544. svc := &GatewayService{cfg: &config.Config{}}
  545. req, _, err := svc.buildCountTokensRequest(
  546. context.Background(), c, account, body,
  547. "oauth-tok", "oauth", "claude-haiku-4-5", true, // mimicClaudeCode=true
  548. )
  549. require.NoError(t, err)
  550. outBody := readUpstreamBodyForTest(t, req)
  551. outBeta := getHeaderRaw(req.Header, "anthropic-beta")
  552. require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
  553. "count_tokens mimic 始终注入 context-management beta")
  554. require.True(t, gjson.GetBytes(outBody, "context_management").Exists(),
  555. "对称约束:final beta 含 token 时 body 字段保留")
  556. require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaTokenCounting),
  557. "count_tokens 路径必须含 token-counting beta")
  558. }
  559. func TestBuildCountTokensRequest_OAuthMimic_DropsInjectedMaxTokens(t *testing.T) {
  560. // OAuth mimicry injects max_tokens=128000 for normal messages requests. It is
  561. // invalid for Anthropic's count_tokens endpoint and must be stripped on wire.
  562. gin.SetMode(gin.TestMode)
  563. rec := httptest.NewRecorder()
  564. c, _ := gin.CreateTestContext(rec)
  565. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  566. account := &Account{ID: 413, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
  567. Credentials: map[string]any{"access_token": "oauth-tok"},
  568. Status: StatusActive, Schedulable: true,
  569. }
  570. normalized, _ := normalizeClaudeOAuthRequestBody(
  571. []byte(`{"model":"claude-sonnet-4-5","messages":[]}`),
  572. "claude-sonnet-4-5", claudeOAuthNormalizeOptions{},
  573. )
  574. require.Equal(t, int64(128000), gjson.GetBytes(normalized, "max_tokens").Int(),
  575. "precondition: OAuth mimicry injects the Claude Code default")
  576. svc := &GatewayService{cfg: &config.Config{}}
  577. req, _, err := svc.buildCountTokensRequest(
  578. context.Background(), c, account, normalized,
  579. "oauth-tok", "oauth", "claude-sonnet-4-5", true,
  580. )
  581. require.NoError(t, err)
  582. require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "max_tokens").Exists(),
  583. "count_tokens wire body must not contain max_tokens")
  584. }
  585. func TestBuildCountTokensRequest_APIKeyHaiku_StripsContextManagementEndToEnd(t *testing.T) {
  586. // API-key + haiku + 客户端 header 不带 context-management beta → final beta 不含 → strip
  587. gin.SetMode(gin.TestMode)
  588. rec := httptest.NewRecorder()
  589. c, _ := gin.CreateTestContext(rec)
  590. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  591. c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
  592. account := &Account{ID: 412, Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
  593. Credentials: map[string]any{"api_key": "sk-ant-xxx"},
  594. Status: StatusActive, Schedulable: true,
  595. }
  596. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[]},"messages":[]}`)
  597. svc := &GatewayService{cfg: &config.Config{}}
  598. req, _, err := svc.buildCountTokensRequest(
  599. context.Background(), c, account, body,
  600. "sk-ant-xxx", "apikey", "claude-haiku-4-5", false,
  601. )
  602. require.NoError(t, err)
  603. outBody := readUpstreamBodyForTest(t, req)
  604. require.False(t, gjson.GetBytes(outBody, "context_management").Exists(),
  605. "count_tokens API-key + 客户端未带 beta token → body strip")
  606. }
  607. func TestBuildCountTokensRequest_StripsCacheControlOnlyFromLiteralDeferredTools(t *testing.T) {
  608. gin.SetMode(gin.TestMode)
  609. body := []byte(`{"model":"claude-haiku-4-5","messages":[],"tools":[{"name":"deferred","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral"}},{"name":"ordinary","custom":{"defer_loading":false},"cache_control":{"type":"ephemeral"}},{"name":"string","custom":{"defer_loading":"true"},"cache_control":{"type":"ephemeral"}},{"name":"number","custom":{"defer_loading":1},"cache_control":{"type":"ephemeral"}},{"name":"object","custom":{"defer_loading":{}},"cache_control":{"type":"ephemeral"}}]}`)
  610. tests := []struct {
  611. name string
  612. account *Account
  613. token string
  614. tokenType string
  615. }{
  616. {
  617. name: "generic API key",
  618. account: &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey},
  619. token: "sk-ant-test",
  620. tokenType: "apikey",
  621. },
  622. {
  623. name: "recognized Claude Code OAuth without mimicry",
  624. account: &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth},
  625. token: "oauth-token",
  626. tokenType: "oauth",
  627. },
  628. }
  629. for _, tt := range tests {
  630. t.Run(tt.name, func(t *testing.T) {
  631. rec := httptest.NewRecorder()
  632. c, _ := gin.CreateTestContext(rec)
  633. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  634. svc := &GatewayService{cfg: &config.Config{}}
  635. req, wireBody, err := svc.buildCountTokensRequest(
  636. context.Background(), c, tt.account, body,
  637. tt.token, tt.tokenType, "claude-haiku-4-5", false,
  638. )
  639. require.NoError(t, err)
  640. require.False(t, gjson.GetBytes(wireBody, "tools.0.cache_control").Exists())
  641. for idx := 1; idx < 5; idx++ {
  642. require.Equal(t, "ephemeral", gjson.GetBytes(wireBody, fmt.Sprintf("tools.%d.cache_control.type", idx)).String())
  643. }
  644. require.JSONEq(t, string(wireBody), string(readUpstreamBodyForTest(t, req)))
  645. })
  646. }
  647. }
  648. // count_tokens passthrough preserve 测试
  649. func TestBuildCountTokensRequestAnthropicAPIKeyPassthrough_PreservesContextManagementWhenClientHeaderHasBeta(t *testing.T) {
  650. gin.SetMode(gin.TestMode)
  651. rec := httptest.NewRecorder()
  652. c, _ := gin.CreateTestContext(rec)
  653. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  654. c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,context-management-2025-06-27,token-counting-2024-11-01")
  655. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
  656. svc := &GatewayService{cfg: &config.Config{}}
  657. req, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(
  658. context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
  659. )
  660. require.NoError(t, err)
  661. require.True(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
  662. "count_tokens passthrough + 客户端带 context-management beta → 字段保留")
  663. }
  664. func TestBuildUpstreamRequest_APIKeyHaikuWithContextManagement_StripsField(t *testing.T) {
  665. // API-key + haiku + body 带 context_management + 客户端 header 未带 context-management beta
  666. // → final beta 不含 → body 字段被 strip
  667. gin.SetMode(gin.TestMode)
  668. rec := httptest.NewRecorder()
  669. c, _ := gin.CreateTestContext(rec)
  670. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  671. c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
  672. account := &Account{ID: 404, Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
  673. Credentials: map[string]any{"api_key": "sk-ant-xxx"},
  674. Status: StatusActive, Schedulable: true,
  675. }
  676. body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[]},"messages":[]}`)
  677. svc := &GatewayService{cfg: &config.Config{}}
  678. req, _, err := svc.buildUpstreamRequest(
  679. context.Background(), c, account, body,
  680. "sk-ant-xxx", "apikey", "claude-haiku-4-5", false, false,
  681. )
  682. require.NoError(t, err)
  683. outBody := readUpstreamBodyForTest(t, req)
  684. require.False(t, gjson.GetBytes(outBody, "context_management").Exists(),
  685. "API-key + haiku + 客户端未带 beta token → body 字段必须被 strip")
  686. }