bedrock_request_test.go 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131
  1. package service
  2. import (
  3. "testing"
  4. "github.com/stretchr/testify/assert"
  5. "github.com/stretchr/testify/require"
  6. "github.com/tidwall/gjson"
  7. )
  8. func TestPrepareBedrockRequestBody_BasicFields(t *testing.T) {
  9. input := `{"model":"claude-opus-4-6","stream":true,"max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`
  10. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "")
  11. require.NoError(t, err)
  12. // anthropic_version 应被注入
  13. assert.Equal(t, "bedrock-2023-05-31", gjson.GetBytes(result, "anthropic_version").String())
  14. // model 和 stream 应被移除
  15. assert.False(t, gjson.GetBytes(result, "model").Exists())
  16. assert.False(t, gjson.GetBytes(result, "stream").Exists())
  17. // max_tokens 应保留
  18. assert.Equal(t, int64(1024), gjson.GetBytes(result, "max_tokens").Int())
  19. }
  20. func TestPrepareBedrockRequestBody_OutputFormatInlineSchema(t *testing.T) {
  21. t.Run("schema inlined into last user message array content", func(t *testing.T) {
  22. input := `{"model":"claude-sonnet-4-5","output_format":{"type":"json","schema":{"name":"string"}},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`
  23. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-sonnet-4-5-v1", "")
  24. require.NoError(t, err)
  25. assert.False(t, gjson.GetBytes(result, "output_format").Exists())
  26. // schema 应内联到最后一条 user message 的 content 数组末尾
  27. contentArr := gjson.GetBytes(result, "messages.0.content").Array()
  28. require.Len(t, contentArr, 2)
  29. assert.Equal(t, "text", contentArr[1].Get("type").String())
  30. assert.Contains(t, contentArr[1].Get("text").String(), `"name":"string"`)
  31. })
  32. t.Run("schema inlined into string content", func(t *testing.T) {
  33. input := `{"model":"claude-sonnet-4-5","output_format":{"type":"json","schema":{"result":"number"}},"messages":[{"role":"user","content":"compute this"}]}`
  34. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-sonnet-4-5-v1", "")
  35. require.NoError(t, err)
  36. assert.False(t, gjson.GetBytes(result, "output_format").Exists())
  37. contentArr := gjson.GetBytes(result, "messages.0.content").Array()
  38. require.Len(t, contentArr, 2)
  39. assert.Equal(t, "compute this", contentArr[0].Get("text").String())
  40. assert.Contains(t, contentArr[1].Get("text").String(), `"result":"number"`)
  41. })
  42. t.Run("no schema field just removes output_format", func(t *testing.T) {
  43. input := `{"model":"claude-sonnet-4-5","output_format":{"type":"json"},"messages":[{"role":"user","content":"hi"}]}`
  44. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-sonnet-4-5-v1", "")
  45. require.NoError(t, err)
  46. assert.False(t, gjson.GetBytes(result, "output_format").Exists())
  47. })
  48. t.Run("no messages just removes output_format", func(t *testing.T) {
  49. input := `{"model":"claude-sonnet-4-5","output_format":{"type":"json","schema":{"name":"string"}}}`
  50. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-sonnet-4-5-v1", "")
  51. require.NoError(t, err)
  52. assert.False(t, gjson.GetBytes(result, "output_format").Exists())
  53. })
  54. }
  55. func TestPrepareBedrockRequestBody_RemoveOutputConfig(t *testing.T) {
  56. input := `{"model":"claude-sonnet-4-5","output_config":{"max_tokens":100},"messages":[]}`
  57. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-sonnet-4-5-v1", "")
  58. require.NoError(t, err)
  59. assert.False(t, gjson.GetBytes(result, "output_config").Exists())
  60. }
  61. func TestRemoveCustomFieldFromTools(t *testing.T) {
  62. input := `{
  63. "tools": [
  64. {"name":"tool1","custom":{"defer_loading":true},"description":"desc1"},
  65. {"name":"tool2","description":"desc2"},
  66. {"name":"tool3","custom":{"defer_loading":true,"other":123},"description":"desc3"}
  67. ]
  68. }`
  69. result := removeCustomFieldFromTools([]byte(input))
  70. tools := gjson.GetBytes(result, "tools").Array()
  71. require.Len(t, tools, 3)
  72. // custom 应被移除
  73. assert.False(t, tools[0].Get("custom").Exists())
  74. // name/description 应保留
  75. assert.Equal(t, "tool1", tools[0].Get("name").String())
  76. assert.Equal(t, "desc1", tools[0].Get("description").String())
  77. // 没有 custom 的工具不受影响
  78. assert.Equal(t, "tool2", tools[1].Get("name").String())
  79. // 第三个工具的 custom 也应被移除
  80. assert.False(t, tools[2].Get("custom").Exists())
  81. assert.Equal(t, "tool3", tools[2].Get("name").String())
  82. }
  83. func TestRemoveCustomFieldFromTools_NoTools(t *testing.T) {
  84. input := `{"messages":[{"role":"user","content":"hi"}]}`
  85. result := removeCustomFieldFromTools([]byte(input))
  86. // 无 tools 时不改变原始数据
  87. assert.JSONEq(t, input, string(result))
  88. }
  89. func TestSanitizeBedrockCacheControl_RemoveScope(t *testing.T) {
  90. input := `{
  91. "system": [{"type":"text","text":"sys","cache_control":{"type":"ephemeral","scope":"global"}}],
  92. "messages": [{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral","scope":"global"}}]}]
  93. }`
  94. result := sanitizeBedrockCacheControl([]byte(input), "us.anthropic.claude-opus-4-6-v1")
  95. // scope 应被移除
  96. assert.False(t, gjson.GetBytes(result, "system.0.cache_control.scope").Exists())
  97. assert.False(t, gjson.GetBytes(result, "messages.0.content.0.cache_control.scope").Exists())
  98. // type 应保留
  99. assert.Equal(t, "ephemeral", gjson.GetBytes(result, "system.0.cache_control.type").String())
  100. assert.Equal(t, "ephemeral", gjson.GetBytes(result, "messages.0.content.0.cache_control.type").String())
  101. }
  102. func TestSanitizeBedrockCacheControl_TTL_OldModel(t *testing.T) {
  103. input := `{
  104. "system": [{"type":"text","text":"sys","cache_control":{"type":"ephemeral","ttl":"5m"}}]
  105. }`
  106. // 旧模型(Claude 3.5)不支持 ttl
  107. result := sanitizeBedrockCacheControl([]byte(input), "anthropic.claude-3-5-sonnet-20241022-v2:0")
  108. assert.False(t, gjson.GetBytes(result, "system.0.cache_control.ttl").Exists())
  109. assert.Equal(t, "ephemeral", gjson.GetBytes(result, "system.0.cache_control.type").String())
  110. }
  111. func TestSanitizeBedrockCacheControl_TTL_Claude45_Supported(t *testing.T) {
  112. input := `{
  113. "system": [{"type":"text","text":"sys","cache_control":{"type":"ephemeral","ttl":"5m"}}]
  114. }`
  115. // Claude 4.5+ 支持 "5m" 和 "1h"
  116. result := sanitizeBedrockCacheControl([]byte(input), "us.anthropic.claude-sonnet-4-5-20250929-v1:0")
  117. assert.True(t, gjson.GetBytes(result, "system.0.cache_control.ttl").Exists())
  118. assert.Equal(t, "5m", gjson.GetBytes(result, "system.0.cache_control.ttl").String())
  119. }
  120. func TestSanitizeBedrockCacheControl_TTL_Claude45_UnsupportedValue(t *testing.T) {
  121. input := `{
  122. "system": [{"type":"text","text":"sys","cache_control":{"type":"ephemeral","ttl":"10m"}}]
  123. }`
  124. // Claude 4.5 不支持 "10m"
  125. result := sanitizeBedrockCacheControl([]byte(input), "us.anthropic.claude-sonnet-4-5-20250929-v1:0")
  126. assert.False(t, gjson.GetBytes(result, "system.0.cache_control.ttl").Exists())
  127. }
  128. func TestSanitizeBedrockCacheControl_TTL_Claude46(t *testing.T) {
  129. input := `{
  130. "messages": [{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral","ttl":"1h"}}]}]
  131. }`
  132. result := sanitizeBedrockCacheControl([]byte(input), "us.anthropic.claude-opus-4-6-v1")
  133. assert.True(t, gjson.GetBytes(result, "messages.0.content.0.cache_control.ttl").Exists())
  134. assert.Equal(t, "1h", gjson.GetBytes(result, "messages.0.content.0.cache_control.ttl").String())
  135. }
  136. func TestSanitizeBedrockCacheControl_NoCacheControl(t *testing.T) {
  137. input := `{"system":[{"type":"text","text":"sys"}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`
  138. result := sanitizeBedrockCacheControl([]byte(input), "us.anthropic.claude-opus-4-6-v1")
  139. // 无 cache_control 时不改变原始数据
  140. assert.JSONEq(t, input, string(result))
  141. }
  142. func TestIsBedrockClaude45OrNewer(t *testing.T) {
  143. tests := []struct {
  144. modelID string
  145. expect bool
  146. }{
  147. {"us.anthropic.claude-opus-4-6-v1", true},
  148. {"us.anthropic.claude-opus-4-8-v1", true},
  149. {"anthropic.claude-fable-5", true},
  150. {"us.anthropic.claude-sonnet-4-6", true},
  151. {"us.anthropic.claude-sonnet-4-5-20250929-v1:0", true},
  152. {"us.anthropic.claude-opus-4-5-20251101-v1:0", true},
  153. {"us.anthropic.claude-haiku-4-5-20251001-v1:0", true},
  154. {"anthropic.claude-3-5-sonnet-20241022-v2:0", false},
  155. {"anthropic.claude-3-opus-20240229-v1:0", false},
  156. {"anthropic.claude-3-haiku-20240307-v1:0", false},
  157. // 未来版本应自动支持
  158. {"us.anthropic.claude-sonnet-5-0-v1", true},
  159. {"us.anthropic.claude-opus-4-7-v1", true},
  160. // 旧版本
  161. {"anthropic.claude-opus-4-1-v1", false},
  162. {"anthropic.claude-sonnet-4-0-v1", false},
  163. // 非 Claude 模型
  164. {"amazon.nova-pro-v1", false},
  165. {"meta.llama3-70b", false},
  166. }
  167. for _, tt := range tests {
  168. t.Run(tt.modelID, func(t *testing.T) {
  169. assert.Equal(t, tt.expect, isBedrockClaude45OrNewer(tt.modelID))
  170. })
  171. }
  172. }
  173. func TestPrepareBedrockRequestBody_FullIntegration(t *testing.T) {
  174. // 模拟一个完整的 Claude Code 请求
  175. input := `{
  176. "model": "claude-opus-4-6",
  177. "stream": true,
  178. "max_tokens": 16384,
  179. "output_format": {"type": "json", "schema": {"result": "string"}},
  180. "output_config": {"max_tokens": 100},
  181. "system": [{"type": "text", "text": "You are helpful", "cache_control": {"type": "ephemeral", "scope": "global", "ttl": "5m"}}],
  182. "messages": [
  183. {"role": "user", "content": [{"type": "text", "text": "hello", "cache_control": {"type": "ephemeral", "ttl": "1h"}}]}
  184. ],
  185. "tools": [
  186. {"name": "bash", "description": "Run bash", "custom": {"defer_loading": true}, "input_schema": {"type": "object"}},
  187. {"name": "read", "description": "Read file", "input_schema": {"type": "object"}}
  188. ]
  189. }`
  190. betaHeader := "context-1m-2025-08-07, compact-2026-01-12"
  191. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", betaHeader)
  192. require.NoError(t, err)
  193. // 基本字段
  194. assert.Equal(t, "bedrock-2023-05-31", gjson.GetBytes(result, "anthropic_version").String())
  195. assert.False(t, gjson.GetBytes(result, "model").Exists())
  196. assert.False(t, gjson.GetBytes(result, "stream").Exists())
  197. assert.Equal(t, int64(16384), gjson.GetBytes(result, "max_tokens").Int())
  198. // anthropic_beta 应包含所有 beta tokens
  199. betaArr := gjson.GetBytes(result, "anthropic_beta").Array()
  200. require.Len(t, betaArr, 2)
  201. assert.Equal(t, "context-1m-2025-08-07", betaArr[0].String())
  202. assert.Equal(t, "compact-2026-01-12", betaArr[1].String())
  203. // output_format 应被移除,schema 内联到最后一条 user message
  204. assert.False(t, gjson.GetBytes(result, "output_format").Exists())
  205. assert.False(t, gjson.GetBytes(result, "output_config").Exists())
  206. // content 数组:原始 text block + 内联 schema block
  207. contentArr := gjson.GetBytes(result, "messages.0.content").Array()
  208. require.Len(t, contentArr, 2)
  209. assert.Equal(t, "hello", contentArr[0].Get("text").String())
  210. assert.Contains(t, contentArr[1].Get("text").String(), `"result":"string"`)
  211. // tools 中的 custom 应被移除
  212. assert.False(t, gjson.GetBytes(result, "tools.0.custom").Exists())
  213. assert.Equal(t, "bash", gjson.GetBytes(result, "tools.0.name").String())
  214. assert.Equal(t, "read", gjson.GetBytes(result, "tools.1.name").String())
  215. // cache_control: scope 应被移除,ttl 在 Claude 4.6 上保留合法值
  216. assert.False(t, gjson.GetBytes(result, "system.0.cache_control.scope").Exists())
  217. assert.Equal(t, "ephemeral", gjson.GetBytes(result, "system.0.cache_control.type").String())
  218. assert.Equal(t, "5m", gjson.GetBytes(result, "system.0.cache_control.ttl").String())
  219. assert.Equal(t, "1h", gjson.GetBytes(result, "messages.0.content.0.cache_control.ttl").String())
  220. }
  221. func TestPrepareBedrockRequestBody_BetaHeader(t *testing.T) {
  222. input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`
  223. t.Run("empty beta header", func(t *testing.T) {
  224. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "")
  225. require.NoError(t, err)
  226. assert.False(t, gjson.GetBytes(result, "anthropic_beta").Exists())
  227. })
  228. t.Run("single beta token", func(t *testing.T) {
  229. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "context-1m-2025-08-07")
  230. require.NoError(t, err)
  231. arr := gjson.GetBytes(result, "anthropic_beta").Array()
  232. require.Len(t, arr, 1)
  233. assert.Equal(t, "context-1m-2025-08-07", arr[0].String())
  234. })
  235. t.Run("multiple beta tokens with spaces", func(t *testing.T) {
  236. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "context-1m-2025-08-07 , compact-2026-01-12 ")
  237. require.NoError(t, err)
  238. arr := gjson.GetBytes(result, "anthropic_beta").Array()
  239. require.Len(t, arr, 2)
  240. assert.Equal(t, "context-1m-2025-08-07", arr[0].String())
  241. assert.Equal(t, "compact-2026-01-12", arr[1].String())
  242. })
  243. t.Run("json array beta header", func(t *testing.T) {
  244. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", `["context-1m-2025-08-07","compact-2026-01-12"]`)
  245. require.NoError(t, err)
  246. arr := gjson.GetBytes(result, "anthropic_beta").Array()
  247. require.Len(t, arr, 2)
  248. assert.Equal(t, "context-1m-2025-08-07", arr[0].String())
  249. assert.Equal(t, "compact-2026-01-12", arr[1].String())
  250. })
  251. }
  252. func TestParseAnthropicBetaHeader(t *testing.T) {
  253. assert.Nil(t, parseAnthropicBetaHeader(""))
  254. assert.Equal(t, []string{"a"}, parseAnthropicBetaHeader("a"))
  255. assert.Equal(t, []string{"a", "b"}, parseAnthropicBetaHeader("a,b"))
  256. assert.Equal(t, []string{"a", "b"}, parseAnthropicBetaHeader("a , b "))
  257. assert.Equal(t, []string{"a", "b", "c"}, parseAnthropicBetaHeader("a,b,c"))
  258. assert.Equal(t, []string{"a", "b"}, parseAnthropicBetaHeader(`["a","b"]`))
  259. }
  260. func TestFilterBedrockBetaTokens(t *testing.T) {
  261. t.Run("supported tokens pass through", func(t *testing.T) {
  262. tokens := []string{"context-1m-2025-08-07", "compact-2026-01-12", "computer-use-2025-11-24"}
  263. result := filterBedrockBetaTokens(tokens)
  264. assert.Equal(t, tokens, result)
  265. })
  266. t.Run("unsupported tokens are filtered out", func(t *testing.T) {
  267. tokens := []string{"context-1m-2025-08-07", "output-128k-2025-02-19", "files-api-2025-04-14", "structured-outputs-2025-11-13"}
  268. result := filterBedrockBetaTokens(tokens)
  269. assert.Equal(t, []string{"context-1m-2025-08-07"}, result)
  270. })
  271. t.Run("advanced-tool-use transforms to tool-search-tool", func(t *testing.T) {
  272. tokens := []string{"advanced-tool-use-2025-11-20"}
  273. result := filterBedrockBetaTokens(tokens)
  274. assert.Contains(t, result, "tool-search-tool-2025-10-19")
  275. // tool-examples 自动关联
  276. assert.Contains(t, result, "tool-examples-2025-10-29")
  277. })
  278. t.Run("tool-search-tool auto-associates tool-examples", func(t *testing.T) {
  279. tokens := []string{"tool-search-tool-2025-10-19"}
  280. result := filterBedrockBetaTokens(tokens)
  281. assert.Contains(t, result, "tool-search-tool-2025-10-19")
  282. assert.Contains(t, result, "tool-examples-2025-10-29")
  283. })
  284. t.Run("no duplication when tool-examples already present", func(t *testing.T) {
  285. tokens := []string{"tool-search-tool-2025-10-19", "tool-examples-2025-10-29"}
  286. result := filterBedrockBetaTokens(tokens)
  287. count := 0
  288. for _, t := range result {
  289. if t == "tool-examples-2025-10-29" {
  290. count++
  291. }
  292. }
  293. assert.Equal(t, 1, count)
  294. })
  295. t.Run("empty input returns nil", func(t *testing.T) {
  296. result := filterBedrockBetaTokens(nil)
  297. assert.Nil(t, result)
  298. })
  299. t.Run("all unsupported returns nil", func(t *testing.T) {
  300. result := filterBedrockBetaTokens([]string{"output-128k-2025-02-19", "effort-2025-11-24"})
  301. assert.Nil(t, result)
  302. })
  303. t.Run("duplicate tokens are deduplicated", func(t *testing.T) {
  304. tokens := []string{"context-1m-2025-08-07", "context-1m-2025-08-07"}
  305. result := filterBedrockBetaTokens(tokens)
  306. assert.Equal(t, []string{"context-1m-2025-08-07"}, result)
  307. })
  308. }
  309. func TestPrepareBedrockRequestBody_BetaFiltering(t *testing.T) {
  310. input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`
  311. t.Run("unsupported beta tokens are filtered", func(t *testing.T) {
  312. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1",
  313. "compact-2026-01-12, output-128k-2025-02-19, files-api-2025-04-14")
  314. require.NoError(t, err)
  315. arr := gjson.GetBytes(result, "anthropic_beta").Array()
  316. require.Len(t, arr, 1)
  317. assert.Equal(t, "compact-2026-01-12", arr[0].String())
  318. })
  319. t.Run("advanced-tool-use transformed in full pipeline", func(t *testing.T) {
  320. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1",
  321. "advanced-tool-use-2025-11-20")
  322. require.NoError(t, err)
  323. arr := gjson.GetBytes(result, "anthropic_beta").Array()
  324. require.Len(t, arr, 2)
  325. assert.Equal(t, "tool-search-tool-2025-10-19", arr[0].String())
  326. assert.Equal(t, "tool-examples-2025-10-29", arr[1].String())
  327. })
  328. }
  329. func TestPrepareBedrockRequestBodyWithTokens_ContextManagementRequiresSupportedBeta(t *testing.T) {
  330. modelID := "us.anthropic.claude-opus-4-6-v1"
  331. t.Run("strips context_management when final tokens omit context-management beta", func(t *testing.T) {
  332. input := `{
  333. "messages":[{"role":"user","content":"hi"}],
  334. "max_tokens":100,
  335. "context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}
  336. }`
  337. betaTokens := []string{"context-1m-2025-08-07"}
  338. originalTokens := append([]string(nil), betaTokens...)
  339. result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, betaTokens, false)
  340. require.NoError(t, err)
  341. assert.False(t, gjson.GetBytes(result, "context_management").Exists())
  342. assert.Equal(t, originalTokens, betaTokens)
  343. assert.Equal(t, originalTokens, bedrockAnthropicBetaNames(result))
  344. })
  345. t.Run("leaves body without context_management otherwise intact", func(t *testing.T) {
  346. input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`
  347. result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, nil, false)
  348. require.NoError(t, err)
  349. assert.False(t, gjson.GetBytes(result, "context_management").Exists())
  350. assert.False(t, gjson.GetBytes(result, "anthropic_beta").Exists())
  351. assert.Equal(t, "hi", gjson.GetBytes(result, "messages.0.content").String())
  352. assert.Equal(t, int64(100), gjson.GetBytes(result, "max_tokens").Int())
  353. })
  354. t.Run("keeps supported context-management beta and retains field", func(t *testing.T) {
  355. input := `{
  356. "messages":[{"role":"user","content":"hi"}],
  357. "max_tokens":100,
  358. "context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}
  359. }`
  360. result, err := PrepareBedrockRequestBodyWithTokens(
  361. []byte(input),
  362. modelID,
  363. []string{bedrockContextManagementBetaToken, "context-1m-2025-08-07"},
  364. false,
  365. )
  366. require.NoError(t, err)
  367. assert.True(t, gjson.GetBytes(result, "context_management").Exists())
  368. assert.Equal(t, []string{bedrockContextManagementBetaToken, "context-1m-2025-08-07"}, bedrockAnthropicBetaNames(result))
  369. })
  370. }
  371. func bedrockAnthropicBetaNames(body []byte) []string {
  372. arr := gjson.GetBytes(body, "anthropic_beta").Array()
  373. names := make([]string, len(arr))
  374. for i, token := range arr {
  375. names[i] = token.String()
  376. }
  377. return names
  378. }
  379. func TestBedrockCrossRegionPrefix(t *testing.T) {
  380. tests := []struct {
  381. region string
  382. expect string
  383. }{
  384. // US regions
  385. {"us-east-1", "us"},
  386. {"us-east-2", "us"},
  387. {"us-west-1", "us"},
  388. {"us-west-2", "us"},
  389. // GovCloud
  390. {"us-gov-east-1", "us-gov"},
  391. {"us-gov-west-1", "us-gov"},
  392. // EU regions
  393. {"eu-west-1", "eu"},
  394. {"eu-west-2", "eu"},
  395. {"eu-west-3", "eu"},
  396. {"eu-central-1", "eu"},
  397. {"eu-central-2", "eu"},
  398. {"eu-north-1", "eu"},
  399. {"eu-south-1", "eu"},
  400. // APAC regions
  401. {"ap-northeast-1", "jp"},
  402. {"ap-northeast-2", "apac"},
  403. {"ap-southeast-1", "apac"},
  404. {"ap-southeast-2", "au"},
  405. {"ap-south-1", "apac"},
  406. // Canada / South America fallback to us
  407. {"ca-central-1", "us"},
  408. {"sa-east-1", "us"},
  409. // Unknown defaults to us
  410. {"me-south-1", "us"},
  411. }
  412. for _, tt := range tests {
  413. t.Run(tt.region, func(t *testing.T) {
  414. assert.Equal(t, tt.expect, BedrockCrossRegionPrefix(tt.region))
  415. })
  416. }
  417. }
  418. func TestResolveBedrockModelID(t *testing.T) {
  419. t.Run("default alias resolves and adjusts region", func(t *testing.T) {
  420. account := &Account{
  421. Platform: PlatformAnthropic,
  422. Type: AccountTypeBedrock,
  423. Credentials: map[string]any{
  424. "aws_region": "eu-west-1",
  425. },
  426. }
  427. modelID, ok := ResolveBedrockModelID(account, "claude-sonnet-4-5")
  428. require.True(t, ok)
  429. assert.Equal(t, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", modelID)
  430. })
  431. t.Run("custom alias mapping reuses default bedrock mapping", func(t *testing.T) {
  432. account := &Account{
  433. Platform: PlatformAnthropic,
  434. Type: AccountTypeBedrock,
  435. Credentials: map[string]any{
  436. "aws_region": "ap-southeast-2",
  437. "model_mapping": map[string]any{
  438. "claude-*": "claude-opus-4-6",
  439. },
  440. },
  441. }
  442. modelID, ok := ResolveBedrockModelID(account, "claude-opus-4-6-thinking")
  443. require.True(t, ok)
  444. assert.Equal(t, "au.anthropic.claude-opus-4-6-v1", modelID)
  445. })
  446. t.Run("default opus 4.8 mapping uses regional Bedrock model id", func(t *testing.T) {
  447. account := &Account{
  448. Platform: PlatformAnthropic,
  449. Type: AccountTypeBedrock,
  450. Credentials: map[string]any{
  451. "aws_region": "eu-west-1",
  452. },
  453. }
  454. modelID, ok := ResolveBedrockModelID(account, "claude-opus-4-8")
  455. require.True(t, ok)
  456. assert.Equal(t, "eu.anthropic.claude-opus-4-8-v1", modelID)
  457. })
  458. t.Run("默认 Fable 5 映射使用官方 Bedrock 模型 ID", func(t *testing.T) {
  459. account := &Account{
  460. Platform: PlatformAnthropic,
  461. Type: AccountTypeBedrock,
  462. Credentials: map[string]any{
  463. "aws_region": "eu-west-1",
  464. },
  465. }
  466. modelID, ok := ResolveBedrockModelID(account, "claude-fable-5")
  467. require.True(t, ok)
  468. assert.Equal(t, "anthropic.claude-fable-5", modelID)
  469. })
  470. t.Run("默认 Fable 5.1 映射使用官方 Bedrock 模型 ID", func(t *testing.T) {
  471. account := &Account{
  472. Platform: PlatformAnthropic,
  473. Type: AccountTypeBedrock,
  474. Credentials: map[string]any{
  475. "aws_region": "eu-west-1",
  476. },
  477. }
  478. modelID, ok := ResolveBedrockModelID(account, "claude-fable-5-1")
  479. require.True(t, ok)
  480. assert.Equal(t, "anthropic.claude-fable-5-1", modelID)
  481. })
  482. t.Run("force global rewrites anthropic regional model id", func(t *testing.T) {
  483. account := &Account{
  484. Platform: PlatformAnthropic,
  485. Type: AccountTypeBedrock,
  486. Credentials: map[string]any{
  487. "aws_region": "us-east-1",
  488. "aws_force_global": "true",
  489. "model_mapping": map[string]any{
  490. "claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6",
  491. },
  492. },
  493. }
  494. modelID, ok := ResolveBedrockModelID(account, "claude-sonnet-4-6")
  495. require.True(t, ok)
  496. assert.Equal(t, "global.anthropic.claude-sonnet-4-6", modelID)
  497. })
  498. t.Run("direct bedrock model id passes through", func(t *testing.T) {
  499. account := &Account{
  500. Platform: PlatformAnthropic,
  501. Type: AccountTypeBedrock,
  502. Credentials: map[string]any{
  503. "aws_region": "us-east-1",
  504. },
  505. }
  506. modelID, ok := ResolveBedrockModelID(account, "anthropic.claude-haiku-4-5-20251001-v1:0")
  507. require.True(t, ok)
  508. assert.Equal(t, "anthropic.claude-haiku-4-5-20251001-v1:0", modelID)
  509. })
  510. t.Run("unsupported alias returns false", func(t *testing.T) {
  511. account := &Account{
  512. Platform: PlatformAnthropic,
  513. Type: AccountTypeBedrock,
  514. Credentials: map[string]any{
  515. "aws_region": "us-east-1",
  516. },
  517. }
  518. _, ok := ResolveBedrockModelID(account, "claude-3-5-sonnet-20241022")
  519. assert.False(t, ok)
  520. })
  521. }
  522. func TestAutoInjectBedrockBetaTokens(t *testing.T) {
  523. t.Run("no auto-inject for thinking (interleaved-thinking not supported)", func(t *testing.T) {
  524. body := []byte(`{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[{"role":"user","content":"hi"}]}`)
  525. result := autoInjectBedrockBetaTokens(nil, body, "us.anthropic.claude-opus-4-6-v1")
  526. // interleaved-thinking-2025-05-14 已从白名单移除,不应自动注入
  527. assert.Empty(t, result)
  528. })
  529. t.Run("no duplicate when already present", func(t *testing.T) {
  530. body := []byte(`{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[{"role":"user","content":"hi"}]}`)
  531. result := autoInjectBedrockBetaTokens([]string{"context-1m-2025-08-07"}, body, "us.anthropic.claude-opus-4-6-v1")
  532. assert.Equal(t, []string{"context-1m-2025-08-07"}, result)
  533. })
  534. t.Run("inject computer-use when computer tool present", func(t *testing.T) {
  535. body := []byte(`{"tools":[{"type":"computer_20250124","name":"computer","display_width_px":1024}],"messages":[{"role":"user","content":"hi"}]}`)
  536. result := autoInjectBedrockBetaTokens(nil, body, "us.anthropic.claude-opus-4-6-v1")
  537. assert.Contains(t, result, "computer-use-2025-11-24")
  538. })
  539. t.Run("inject advanced-tool-use for programmatic tool calling", func(t *testing.T) {
  540. body := []byte(`{"tools":[{"name":"bash","allowed_callers":["code_execution_20250825"]}],"messages":[{"role":"user","content":"hi"}]}`)
  541. result := autoInjectBedrockBetaTokens(nil, body, "us.anthropic.claude-opus-4-6-v1")
  542. assert.Contains(t, result, "advanced-tool-use-2025-11-20")
  543. })
  544. t.Run("inject advanced-tool-use for input examples", func(t *testing.T) {
  545. body := []byte(`{"tools":[{"name":"bash","input_examples":[{"cmd":"ls"}]}],"messages":[{"role":"user","content":"hi"}]}`)
  546. result := autoInjectBedrockBetaTokens(nil, body, "us.anthropic.claude-opus-4-6-v1")
  547. assert.Contains(t, result, "advanced-tool-use-2025-11-20")
  548. })
  549. t.Run("inject tool-search-tool directly for pure tool search (no programmatic/inputExamples)", func(t *testing.T) {
  550. body := []byte(`{"tools":[{"type":"tool_search_tool_regex_20251119","name":"search"}],"messages":[{"role":"user","content":"hi"}]}`)
  551. result := autoInjectBedrockBetaTokens(nil, body, "us.anthropic.claude-sonnet-4-6")
  552. // 纯 tool search 场景直接注入 Bedrock 特定头,不走 advanced-tool-use 转换
  553. assert.Contains(t, result, "tool-search-tool-2025-10-19")
  554. assert.NotContains(t, result, "advanced-tool-use-2025-11-20")
  555. })
  556. t.Run("inject advanced-tool-use when tool search combined with programmatic calling", func(t *testing.T) {
  557. body := []byte(`{"tools":[{"type":"tool_search_tool_regex_20251119","name":"search"},{"name":"bash","allowed_callers":["code_execution_20250825"]}],"messages":[{"role":"user","content":"hi"}]}`)
  558. result := autoInjectBedrockBetaTokens(nil, body, "us.anthropic.claude-sonnet-4-6")
  559. // 混合场景使用 advanced-tool-use(后续由 filter 转换为 tool-search-tool)
  560. assert.Contains(t, result, "advanced-tool-use-2025-11-20")
  561. })
  562. t.Run("do not inject tool-search beta for unsupported models", func(t *testing.T) {
  563. body := []byte(`{"tools":[{"type":"tool_search_tool_regex_20251119","name":"search"}],"messages":[{"role":"user","content":"hi"}]}`)
  564. result := autoInjectBedrockBetaTokens(nil, body, "anthropic.claude-3-5-sonnet-20241022-v2:0")
  565. assert.NotContains(t, result, "advanced-tool-use-2025-11-20")
  566. assert.NotContains(t, result, "tool-search-tool-2025-10-19")
  567. })
  568. t.Run("no injection for regular tools", func(t *testing.T) {
  569. body := []byte(`{"tools":[{"name":"bash","description":"run bash","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"hi"}]}`)
  570. result := autoInjectBedrockBetaTokens(nil, body, "us.anthropic.claude-opus-4-6-v1")
  571. assert.Empty(t, result)
  572. })
  573. t.Run("no injection when no features detected", func(t *testing.T) {
  574. body := []byte(`{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`)
  575. result := autoInjectBedrockBetaTokens(nil, body, "us.anthropic.claude-opus-4-6-v1")
  576. assert.Empty(t, result)
  577. })
  578. t.Run("preserves existing tokens", func(t *testing.T) {
  579. body := []byte(`{"thinking":{"type":"enabled"},"messages":[{"role":"user","content":"hi"}]}`)
  580. existing := []string{"context-1m-2025-08-07", "compact-2026-01-12"}
  581. result := autoInjectBedrockBetaTokens(existing, body, "us.anthropic.claude-opus-4-6-v1")
  582. assert.Contains(t, result, "context-1m-2025-08-07")
  583. assert.Contains(t, result, "compact-2026-01-12")
  584. // interleaved-thinking 不再自动注入
  585. assert.NotContains(t, result, "interleaved-thinking-2025-05-14")
  586. })
  587. }
  588. func TestResolveBedrockBetaTokens(t *testing.T) {
  589. t.Run("body-only tool features resolve to final bedrock tokens", func(t *testing.T) {
  590. body := []byte(`{"tools":[{"name":"bash","allowed_callers":["code_execution_20250825"]}],"messages":[{"role":"user","content":"hi"}]}`)
  591. result := ResolveBedrockBetaTokens("", body, "us.anthropic.claude-opus-4-6-v1")
  592. assert.Contains(t, result, "tool-search-tool-2025-10-19")
  593. assert.Contains(t, result, "tool-examples-2025-10-29")
  594. })
  595. t.Run("unsupported client beta tokens are filtered out", func(t *testing.T) {
  596. body := []byte(`{"messages":[{"role":"user","content":"hi"}]}`)
  597. result := ResolveBedrockBetaTokens("context-1m-2025-08-07,files-api-2025-04-14", body, "us.anthropic.claude-opus-4-6-v1")
  598. assert.Equal(t, []string{"context-1m-2025-08-07"}, result)
  599. })
  600. }
  601. func TestPrepareBedrockRequestBody_AutoBetaInjection(t *testing.T) {
  602. t.Run("thinking in body does not auto-inject beta (not supported)", func(t *testing.T) {
  603. input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100,"thinking":{"type":"enabled","budget_tokens":10000}}`
  604. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "")
  605. require.NoError(t, err)
  606. // interleaved-thinking 已从白名单移除,不应自动注入
  607. assert.False(t, gjson.GetBytes(result, "anthropic_beta").Exists())
  608. })
  609. t.Run("header tokens preserved without auto-injection", func(t *testing.T) {
  610. input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100,"thinking":{"type":"enabled","budget_tokens":10000}}`
  611. result, err := PrepareBedrockRequestBody([]byte(input), "us.anthropic.claude-opus-4-6-v1", "context-1m-2025-08-07")
  612. require.NoError(t, err)
  613. arr := gjson.GetBytes(result, "anthropic_beta").Array()
  614. names := make([]string, len(arr))
  615. for i, v := range arr {
  616. names[i] = v.String()
  617. }
  618. assert.Contains(t, names, "context-1m-2025-08-07")
  619. // interleaved-thinking 不再自动注入
  620. assert.NotContains(t, names, "interleaved-thinking-2025-05-14")
  621. })
  622. }
  623. func TestAdjustBedrockModelRegionPrefix(t *testing.T) {
  624. tests := []struct {
  625. name string
  626. modelID string
  627. region string
  628. expect string
  629. }{
  630. // US region — no change needed
  631. {"us region keeps us prefix", "us.anthropic.claude-opus-4-6-v1", "us-east-1", "us.anthropic.claude-opus-4-6-v1"},
  632. // EU region — replace us → eu
  633. {"eu region replaces prefix", "us.anthropic.claude-opus-4-6-v1", "eu-west-1", "eu.anthropic.claude-opus-4-6-v1"},
  634. {"eu region sonnet", "us.anthropic.claude-sonnet-4-6", "eu-central-1", "eu.anthropic.claude-sonnet-4-6"},
  635. // APAC region — jp and au have dedicated prefixes per AWS docs
  636. {"jp region (ap-northeast-1)", "us.anthropic.claude-sonnet-4-5-20250929-v1:0", "ap-northeast-1", "jp.anthropic.claude-sonnet-4-5-20250929-v1:0"},
  637. {"au region (ap-southeast-2)", "us.anthropic.claude-haiku-4-5-20251001-v1:0", "ap-southeast-2", "au.anthropic.claude-haiku-4-5-20251001-v1:0"},
  638. {"apac region (ap-southeast-1)", "us.anthropic.claude-sonnet-4-5-20250929-v1:0", "ap-southeast-1", "apac.anthropic.claude-sonnet-4-5-20250929-v1:0"},
  639. // eu → us (user manually set eu prefix, moved to us region)
  640. {"eu to us", "eu.anthropic.claude-opus-4-6-v1", "us-west-2", "us.anthropic.claude-opus-4-6-v1"},
  641. // global prefix — replace to match region
  642. {"global to eu", "global.anthropic.claude-opus-4-6-v1", "eu-west-1", "eu.anthropic.claude-opus-4-6-v1"},
  643. // No known prefix — leave unchanged
  644. {"no prefix unchanged", "anthropic.claude-3-5-sonnet-20241022-v2:0", "eu-west-1", "anthropic.claude-3-5-sonnet-20241022-v2:0"},
  645. // GovCloud — uses independent us-gov prefix
  646. {"govcloud from us", "us.anthropic.claude-opus-4-6-v1", "us-gov-east-1", "us-gov.anthropic.claude-opus-4-6-v1"},
  647. {"govcloud already correct", "us-gov.anthropic.claude-opus-4-6-v1", "us-gov-west-1", "us-gov.anthropic.claude-opus-4-6-v1"},
  648. // Force global (special region value)
  649. {"force global from us", "us.anthropic.claude-opus-4-6-v1", "global", "global.anthropic.claude-opus-4-6-v1"},
  650. {"force global from eu", "eu.anthropic.claude-sonnet-4-6", "global", "global.anthropic.claude-sonnet-4-6"},
  651. }
  652. for _, tt := range tests {
  653. t.Run(tt.name, func(t *testing.T) {
  654. assert.Equal(t, tt.expect, AdjustBedrockModelRegionPrefix(tt.modelID, tt.region))
  655. })
  656. }
  657. }
  658. func TestIsBedrockOpus47OrNewer(t *testing.T) {
  659. tests := []struct {
  660. modelID string
  661. expect bool
  662. }{
  663. {"us.anthropic.claude-opus-4-8-v1", true},
  664. {"us.anthropic.claude-opus-4-7-v1", true},
  665. {"us.anthropic.claude-opus-4-6-v1", false},
  666. {"us.anthropic.claude-opus-4-5-20251101-v1:0", false},
  667. {"us.anthropic.claude-opus-5-0-v1", true},
  668. // Sonnet 4.7 is not Opus → false
  669. {"us.anthropic.claude-sonnet-4-7-v1", false},
  670. {"us.anthropic.claude-sonnet-4-6", false},
  671. // Haiku is not Opus
  672. {"us.anthropic.claude-haiku-4-5-20251001-v1:0", false},
  673. // Non-Claude models
  674. {"amazon.nova-pro-v1", false},
  675. }
  676. for _, tt := range tests {
  677. t.Run(tt.modelID, func(t *testing.T) {
  678. assert.Equal(t, tt.expect, isBedrockOpus47OrNewer(tt.modelID))
  679. })
  680. }
  681. }
  682. func TestSanitizeBedrockThinking(t *testing.T) {
  683. t.Run("Fable 5 将 enabled 转换为 adaptive 并移除预算", func(t *testing.T) {
  684. input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}`
  685. result := sanitizeBedrockThinking([]byte(input), "anthropic.claude-fable-5")
  686. assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
  687. assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
  688. })
  689. t.Run("Fable 5 adaptive 移除预算", func(t *testing.T) {
  690. input := `{"thinking":{"type":"adaptive","budget_tokens":10000},"messages":[]}`
  691. result := sanitizeBedrockThinking([]byte(input), "claude-fable-5")
  692. assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
  693. assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
  694. })
  695. t.Run("opus 4.7 converts enabled to adaptive", func(t *testing.T) {
  696. input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}`
  697. result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
  698. assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
  699. assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
  700. })
  701. t.Run("opus 4.7 keeps adaptive unchanged", func(t *testing.T) {
  702. input := `{"thinking":{"type":"adaptive"},"messages":[]}`
  703. result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
  704. assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
  705. })
  706. t.Run("opus 4.6 enabled without budget_tokens gets default", func(t *testing.T) {
  707. input := `{"thinking":{"type":"enabled"},"messages":[]}`
  708. result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-6-v1")
  709. assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
  710. assert.Equal(t, int64(defaultThinkingBudgetTokens), gjson.GetBytes(result, "thinking.budget_tokens").Int())
  711. })
  712. t.Run("opus 4.6 enabled with budget_tokens unchanged", func(t *testing.T) {
  713. input := `{"thinking":{"type":"enabled","budget_tokens":20000},"messages":[]}`
  714. result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-6-v1")
  715. assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
  716. assert.Equal(t, int64(20000), gjson.GetBytes(result, "thinking.budget_tokens").Int())
  717. })
  718. t.Run("no thinking field unchanged", func(t *testing.T) {
  719. input := `{"messages":[]}`
  720. result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
  721. assert.JSONEq(t, input, string(result))
  722. })
  723. t.Run("sonnet 4.6 enabled without budget_tokens gets default", func(t *testing.T) {
  724. input := `{"thinking":{"type":"enabled"},"messages":[]}`
  725. result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-sonnet-4-6")
  726. assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
  727. assert.Equal(t, int64(defaultThinkingBudgetTokens), gjson.GetBytes(result, "thinking.budget_tokens").Int())
  728. })
  729. }
  730. func TestSanitizeBedrockToolUseIDs(t *testing.T) {
  731. t.Run("clean IDs unchanged", func(t *testing.T) {
  732. input := `{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01AbCdEf","name":"bash","input":{}}]}]}`
  733. result := sanitizeBedrockToolUseIDs([]byte(input))
  734. assert.Equal(t, "toolu_01AbCdEf", gjson.GetBytes(result, "messages.0.content.0.id").String())
  735. })
  736. t.Run("dots in tool_use ID replaced with underscores", func(t *testing.T) {
  737. input := `{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu.01.Ab","name":"bash","input":{}}]}]}`
  738. result := sanitizeBedrockToolUseIDs([]byte(input))
  739. assert.Equal(t, "toolu_01_Ab", gjson.GetBytes(result, "messages.0.content.0.id").String())
  740. })
  741. t.Run("special chars in tool_use ID sanitized", func(t *testing.T) {
  742. input := `{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu:01@Ab#Cd","name":"bash","input":{}}]}]}`
  743. result := sanitizeBedrockToolUseIDs([]byte(input))
  744. id := gjson.GetBytes(result, "messages.0.content.0.id").String()
  745. assert.Regexp(t, `^[a-zA-Z0-9_-]+$`, id)
  746. })
  747. t.Run("tool_result tool_use_id sanitized", func(t *testing.T) {
  748. input := `{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu.01.Ab","content":"ok"}]}]}`
  749. result := sanitizeBedrockToolUseIDs([]byte(input))
  750. assert.Equal(t, "toolu_01_Ab", gjson.GetBytes(result, "messages.0.content.0.tool_use_id").String())
  751. })
  752. t.Run("mixed clean and dirty IDs", func(t *testing.T) {
  753. input := `{"messages":[
  754. {"role":"assistant","content":[{"type":"tool_use","id":"clean_id-123","name":"a","input":{}}]},
  755. {"role":"user","content":[{"type":"tool_result","tool_use_id":"dirty.id@456","content":"ok"}]},
  756. {"role":"assistant","content":[{"type":"tool_use","id":"also.dirty","name":"b","input":{}}]}
  757. ]}`
  758. result := sanitizeBedrockToolUseIDs([]byte(input))
  759. assert.Equal(t, "clean_id-123", gjson.GetBytes(result, "messages.0.content.0.id").String())
  760. assert.Equal(t, "dirty_id_456", gjson.GetBytes(result, "messages.1.content.0.tool_use_id").String())
  761. assert.Equal(t, "also_dirty", gjson.GetBytes(result, "messages.2.content.0.id").String())
  762. })
  763. t.Run("no messages unchanged", func(t *testing.T) {
  764. input := `{"system":[{"type":"text","text":"hi"}]}`
  765. result := sanitizeBedrockToolUseIDs([]byte(input))
  766. assert.JSONEq(t, input, string(result))
  767. })
  768. t.Run("string content skipped", func(t *testing.T) {
  769. input := `{"messages":[{"role":"user","content":"plain text"}]}`
  770. result := sanitizeBedrockToolUseIDs([]byte(input))
  771. assert.JSONEq(t, input, string(result))
  772. })
  773. t.Run("empty ID skipped", func(t *testing.T) {
  774. input := `{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"","name":"a","input":{}}]}]}`
  775. result := sanitizeBedrockToolUseIDs([]byte(input))
  776. assert.Equal(t, "", gjson.GetBytes(result, "messages.0.content.0.id").String())
  777. })
  778. }
  779. func TestSanitizeBedrockThinking_EdgeCases(t *testing.T) {
  780. t.Run("opus 4.7 enabled without budget_tokens converts to adaptive", func(t *testing.T) {
  781. input := `{"thinking":{"type":"enabled"},"messages":[]}`
  782. result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
  783. assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
  784. assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
  785. })
  786. t.Run("thinking type disabled unchanged", func(t *testing.T) {
  787. input := `{"thinking":{"type":"disabled"},"messages":[]}`
  788. result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
  789. assert.Equal(t, "disabled", gjson.GetBytes(result, "thinking.type").String())
  790. })
  791. t.Run("thinking type empty string unchanged", func(t *testing.T) {
  792. input := `{"thinking":{"type":""},"messages":[]}`
  793. result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
  794. assert.JSONEq(t, input, string(result))
  795. })
  796. t.Run("thinking is not an object unchanged", func(t *testing.T) {
  797. input := `{"thinking":true,"messages":[]}`
  798. result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
  799. assert.JSONEq(t, input, string(result))
  800. })
  801. t.Run("opus 4.7 adaptive with budget_tokens preserved", func(t *testing.T) {
  802. input := `{"thinking":{"type":"adaptive","budget_tokens":5000},"messages":[]}`
  803. result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1")
  804. assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
  805. assert.Equal(t, int64(5000), gjson.GetBytes(result, "thinking.budget_tokens").Int())
  806. })
  807. // Forward() passes parsed.Model (standard names like "claude-opus-4-7")
  808. t.Run("standard model name opus 4.7 converts enabled to adaptive", func(t *testing.T) {
  809. input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}`
  810. result := sanitizeBedrockThinking([]byte(input), "claude-opus-4-7")
  811. assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
  812. assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
  813. })
  814. t.Run("standard model name opus 4.6 keeps enabled", func(t *testing.T) {
  815. input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}`
  816. result := sanitizeBedrockThinking([]byte(input), "claude-opus-4-6")
  817. assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
  818. assert.Equal(t, int64(10000), gjson.GetBytes(result, "thinking.budget_tokens").Int())
  819. })
  820. }
  821. func TestIsBedrockOpus47OrNewer_EdgeCases(t *testing.T) {
  822. tests := []struct {
  823. modelID string
  824. expect bool
  825. }{
  826. {"anthropic.claude-opus-4-8-v1", true},
  827. {"anthropic.claude-opus-4-7-v1", true},
  828. {"us.anthropic.claude-opus-4-7-20270101-v1:0", true},
  829. {"", false},
  830. // Forward() passes parsed.Model (standard names), not Bedrock IDs
  831. {"claude-opus-4-8", true},
  832. {"claude-opus-4-7", true},
  833. {"claude-opus-4-6", false},
  834. {"claude-sonnet-4-7", false},
  835. }
  836. for _, tt := range tests {
  837. t.Run(tt.modelID, func(t *testing.T) {
  838. assert.Equal(t, tt.expect, isBedrockOpus47OrNewer(tt.modelID))
  839. })
  840. }
  841. }
  842. func TestPrepareBedrockRequestBodyWithTokens_CCCompat(t *testing.T) {
  843. input := `{
  844. "model":"claude-opus-4-6",
  845. "stream":true,
  846. "max_tokens":16384,
  847. "thinking":{"type":"enabled"},
  848. "messages":[
  849. {"role":"assistant","content":[{"type":"tool_use","id":"toolu.01.Ab","name":"bash","input":{}}]},
  850. {"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu.01.Ab","content":"ok"}]}
  851. ]
  852. }`
  853. t.Run("ccCompat=false skips thinking and toolUseID sanitization", func(t *testing.T) {
  854. result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), "us.anthropic.claude-opus-4-6-v1", nil, false)
  855. require.NoError(t, err)
  856. assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
  857. assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
  858. assert.Equal(t, "toolu.01.Ab", gjson.GetBytes(result, "messages.0.content.0.id").String())
  859. })
  860. t.Run("ccCompat=true applies thinking fix and toolUseID sanitization (opus 4.6)", func(t *testing.T) {
  861. result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), "us.anthropic.claude-opus-4-6-v1", nil, true)
  862. require.NoError(t, err)
  863. assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
  864. assert.Equal(t, int64(defaultThinkingBudgetTokens), gjson.GetBytes(result, "thinking.budget_tokens").Int())
  865. assert.Equal(t, "toolu_01_Ab", gjson.GetBytes(result, "messages.0.content.0.id").String())
  866. assert.Equal(t, "toolu_01_Ab", gjson.GetBytes(result, "messages.1.content.0.tool_use_id").String())
  867. })
  868. t.Run("ccCompat=true converts thinking to adaptive for opus 4.7", func(t *testing.T) {
  869. result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), "us.anthropic.claude-opus-4-7-v1", nil, true)
  870. require.NoError(t, err)
  871. assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String())
  872. assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists())
  873. assert.Equal(t, "toolu_01_Ab", gjson.GetBytes(result, "messages.0.content.0.id").String())
  874. })
  875. }
  876. func TestSanitizeBedrockCCFields(t *testing.T) {
  877. t.Run("removes service_tier and interface_geo", func(t *testing.T) {
  878. body := []byte(`{"model":"claude-opus-4-6","service_tier":"standard","interface_geo":"us","messages":[]}`)
  879. result := sanitizeBedrockCCFields(body)
  880. assert.False(t, gjson.GetBytes(result, "service_tier").Exists())
  881. assert.False(t, gjson.GetBytes(result, "interface_geo").Exists())
  882. assert.True(t, gjson.GetBytes(result, "messages").Exists())
  883. })
  884. t.Run("removes context_management", func(t *testing.T) {
  885. body := []byte(`{"model":"claude-opus-4-6","context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[]}`)
  886. result := sanitizeBedrockCCFields(body)
  887. assert.False(t, gjson.GetBytes(result, "context_management").Exists())
  888. assert.True(t, gjson.GetBytes(result, "messages").Exists())
  889. })
  890. t.Run("injects max_tokens when missing", func(t *testing.T) {
  891. body := []byte(`{"model":"claude-opus-4-6","messages":[]}`)
  892. result := sanitizeBedrockCCFields(body)
  893. assert.Equal(t, int64(defaultCCMaxTokens), gjson.GetBytes(result, "max_tokens").Int())
  894. })
  895. t.Run("preserves existing max_tokens", func(t *testing.T) {
  896. body := []byte(`{"model":"claude-opus-4-6","max_tokens":4096,"messages":[]}`)
  897. result := sanitizeBedrockCCFields(body)
  898. assert.Equal(t, int64(4096), gjson.GetBytes(result, "max_tokens").Int())
  899. })
  900. t.Run("injects anthropic_version when missing", func(t *testing.T) {
  901. body := []byte(`{"model":"claude-opus-4-6","messages":[]}`)
  902. result := sanitizeBedrockCCFields(body)
  903. assert.Equal(t, "bedrock-2023-05-31", gjson.GetBytes(result, "anthropic_version").String())
  904. })
  905. t.Run("preserves existing anthropic_version", func(t *testing.T) {
  906. body := []byte(`{"model":"claude-opus-4-6","anthropic_version":"bedrock-2023-05-31","messages":[]}`)
  907. result := sanitizeBedrockCCFields(body)
  908. assert.Equal(t, "bedrock-2023-05-31", gjson.GetBytes(result, "anthropic_version").String())
  909. })
  910. t.Run("no-op when fields already clean", func(t *testing.T) {
  911. body := []byte(`{"model":"claude-opus-4-6","max_tokens":81920,"anthropic_version":"bedrock-2023-05-31","messages":[]}`)
  912. result := sanitizeBedrockCCFields(body)
  913. assert.Equal(t, int64(defaultCCMaxTokens), gjson.GetBytes(result, "max_tokens").Int())
  914. assert.Equal(t, "bedrock-2023-05-31", gjson.GetBytes(result, "anthropic_version").String())
  915. assert.False(t, gjson.GetBytes(result, "service_tier").Exists())
  916. assert.False(t, gjson.GetBytes(result, "interface_geo").Exists())
  917. assert.False(t, gjson.GetBytes(result, "context_management").Exists())
  918. })
  919. t.Run("full CC request sanitization", func(t *testing.T) {
  920. body := []byte(`{
  921. "model":"claude-opus-4-6",
  922. "service_tier":"standard",
  923. "interface_geo":"us",
  924. "context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},
  925. "messages":[{"role":"user","content":"hello"}],
  926. "thinking":{"type":"enabled"}
  927. }`)
  928. result := sanitizeBedrockCCFields(body)
  929. assert.False(t, gjson.GetBytes(result, "service_tier").Exists())
  930. assert.False(t, gjson.GetBytes(result, "interface_geo").Exists())
  931. assert.False(t, gjson.GetBytes(result, "context_management").Exists())
  932. assert.Equal(t, int64(defaultCCMaxTokens), gjson.GetBytes(result, "max_tokens").Int())
  933. assert.Equal(t, "bedrock-2023-05-31", gjson.GetBytes(result, "anthropic_version").String())
  934. assert.Equal(t, "enabled", gjson.GetBytes(result, "thinking.type").String())
  935. })
  936. }
  937. func TestSanitizeBedrockCCBetaTokens(t *testing.T) {
  938. t.Run("filters unsupported beta tokens", func(t *testing.T) {
  939. input := `{"anthropic_beta":["prompt-caching-2024-07-31","context-1m-2025-08-07","unsupported-feature"],"messages":[]}`
  940. result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
  941. beta := gjson.GetBytes(result, "anthropic_beta")
  942. assert.True(t, beta.Exists())
  943. assert.True(t, beta.IsArray())
  944. tokens := beta.Array()
  945. assert.Equal(t, 1, len(tokens))
  946. assert.Equal(t, "context-1m-2025-08-07", tokens[0].String())
  947. })
  948. t.Run("removes anthropic_beta if all tokens filtered", func(t *testing.T) {
  949. input := `{"anthropic_beta":["prompt-caching-2024-07-31","unsupported-feature"],"messages":[]}`
  950. result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
  951. assert.False(t, gjson.GetBytes(result, "anthropic_beta").Exists())
  952. })
  953. t.Run("thinking alone does not auto-inject beta tokens", func(t *testing.T) {
  954. input := `{"anthropic_beta":[],"thinking":{"type":"enabled"},"messages":[]}`
  955. result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
  956. assert.False(t, gjson.GetBytes(result, "anthropic_beta").Exists())
  957. })
  958. t.Run("auto-injects computer-use beta token", func(t *testing.T) {
  959. input := `{"anthropic_beta":[],"tools":[{"type":"computer_20250124","name":"computer"}],"messages":[]}`
  960. result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
  961. beta := gjson.GetBytes(result, "anthropic_beta")
  962. assert.True(t, beta.Exists())
  963. tokens := beta.Array()
  964. assert.Equal(t, 1, len(tokens))
  965. assert.Equal(t, "computer-use-2025-11-24", tokens[0].String())
  966. })
  967. t.Run("transforms advanced-tool-use to tool-search-tool", func(t *testing.T) {
  968. input := `{"anthropic_beta":["advanced-tool-use-2025-11-20"],"messages":[]}`
  969. result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
  970. beta := gjson.GetBytes(result, "anthropic_beta")
  971. tokens := beta.Array()
  972. assert.Equal(t, 2, len(tokens)) // tool-search-tool + tool-examples (auto-associated)
  973. assert.Contains(t, []string{tokens[0].String(), tokens[1].String()}, "tool-search-tool-2025-10-19")
  974. assert.Contains(t, []string{tokens[0].String(), tokens[1].String()}, "tool-examples-2025-10-29")
  975. })
  976. t.Run("no-op when anthropic_beta not present", func(t *testing.T) {
  977. input := `{"messages":[]}`
  978. result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
  979. assert.False(t, gjson.GetBytes(result, "anthropic_beta").Exists())
  980. })
  981. t.Run("preserves supported beta tokens", func(t *testing.T) {
  982. input := `{"anthropic_beta":["computer-use-2025-11-24","context-1m-2025-08-07"],"messages":[]}`
  983. result := sanitizeBedrockCCBetaTokens([]byte(input), "claude-opus-4-6")
  984. beta := gjson.GetBytes(result, "anthropic_beta")
  985. tokens := beta.Array()
  986. assert.Equal(t, 2, len(tokens))
  987. assert.Contains(t, []string{tokens[0].String(), tokens[1].String()}, "computer-use-2025-11-24")
  988. assert.Contains(t, []string{tokens[0].String(), tokens[1].String()}, "context-1m-2025-08-07")
  989. })
  990. }