gateway_request_test.go 54 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672
  1. //go:build unit
  2. package service
  3. import (
  4. "encoding/json"
  5. "fmt"
  6. "strings"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/domain"
  9. "github.com/stretchr/testify/require"
  10. "github.com/tidwall/gjson"
  11. )
  12. func TestParseGatewayRequest(t *testing.T) {
  13. body := []byte(`{"model":"claude-3-7-sonnet","stream":true,"metadata":{"user_id":"session_123e4567-e89b-12d3-a456-426614174000"},"system":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral"}}],"messages":[{"content":"hi"}]}`)
  14. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), "")
  15. require.NoError(t, err)
  16. require.Equal(t, "claude-3-7-sonnet", parsed.Model)
  17. require.True(t, parsed.Stream)
  18. require.Equal(t, "session_123e4567-e89b-12d3-a456-426614174000", parsed.MetadataUserID)
  19. require.True(t, parsed.HasSystem)
  20. require.NotEmpty(t, parsed.SystemRaw())
  21. require.NotEmpty(t, parsed.MessagesRaw())
  22. require.False(t, parsed.ThinkingEnabled)
  23. }
  24. func TestParseGatewayRequest_ThinkingEnabled(t *testing.T) {
  25. body := []byte(`{"model":"claude-sonnet-4-5","thinking":{"type":"enabled"},"messages":[{"content":"hi"}]}`)
  26. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), "")
  27. require.NoError(t, err)
  28. require.Equal(t, "claude-sonnet-4-5", parsed.Model)
  29. require.True(t, parsed.ThinkingEnabled)
  30. }
  31. func TestParseGatewayRequest_ThinkingAdaptiveEnabled(t *testing.T) {
  32. body := []byte(`{"model":"claude-sonnet-4-5","thinking":{"type":"adaptive"},"messages":[{"content":"hi"}]}`)
  33. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), "")
  34. require.NoError(t, err)
  35. require.Equal(t, "claude-sonnet-4-5", parsed.Model)
  36. require.True(t, parsed.ThinkingEnabled)
  37. }
  38. func TestParseGatewayRequest_AnthropicFastSpeed(t *testing.T) {
  39. parsed, err := ParseGatewayRequest(
  40. NewRequestBodyRef([]byte(`{"model":"claude-opus-4-8","speed":" FAST "}`)),
  41. domain.PlatformAnthropic,
  42. )
  43. require.NoError(t, err)
  44. require.Equal(t, "fast", parsed.Speed)
  45. nonAnthropic, err := ParseGatewayRequest(
  46. NewRequestBodyRef([]byte(`{"model":"gpt-5.4","speed":"fast"}`)),
  47. "responses",
  48. )
  49. require.NoError(t, err)
  50. require.Empty(t, nonAnthropic.Speed)
  51. }
  52. func TestParseGatewayRequest_MaxTokens(t *testing.T) {
  53. body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`)
  54. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), "")
  55. require.NoError(t, err)
  56. require.Equal(t, 1, parsed.MaxTokens)
  57. }
  58. func TestParseGatewayRequest_MaxTokensNonIntegralIgnored(t *testing.T) {
  59. body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1.5}`)
  60. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), "")
  61. require.NoError(t, err)
  62. require.Equal(t, 0, parsed.MaxTokens)
  63. }
  64. func TestParseGatewayRequest_SystemNull(t *testing.T) {
  65. body := []byte(`{"model":"claude-3","system":null}`)
  66. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), "")
  67. require.NoError(t, err)
  68. // 显式传入 system:null 也应视为“字段已存在”,避免默认 system 被注入。
  69. require.True(t, parsed.HasSystem)
  70. require.Equal(t, []byte("null"), parsed.SystemRaw())
  71. }
  72. func TestParseGatewayRequest_InvalidModelType(t *testing.T) {
  73. body := []byte(`{"model":123}`)
  74. _, err := ParseGatewayRequest(NewRequestBodyRef(body), "")
  75. require.Error(t, err)
  76. }
  77. func TestParseGatewayRequest_InvalidStreamType(t *testing.T) {
  78. body := []byte(`{"stream":"true"}`)
  79. _, err := ParseGatewayRequest(NewRequestBodyRef(body), "")
  80. require.Error(t, err)
  81. }
  82. func TestParseGatewayRequest_AnthropicNormalizesClaudeCodeLongContextModelSuffix(t *testing.T) {
  83. tests := []struct {
  84. name string
  85. model string
  86. want string
  87. }{
  88. {name: "lowercase suffix", model: "claude-opus-4-8[1m]", want: "claude-opus-4-8"},
  89. {name: "uppercase suffix", model: "claude-opus-4-8[1M]", want: "claude-opus-4-8"},
  90. {name: "duplicated suffix", model: "claude-opus-4-8[1M][1m]", want: "claude-opus-4-8"},
  91. {name: "suffix in middle", model: "claude-opus-4-8[1m]-preview", want: "claude-opus-4-8[1m]-preview"},
  92. {name: "suffix only", model: "[1m]", want: "[1m]"},
  93. }
  94. for _, tt := range tests {
  95. t.Run(tt.name, func(t *testing.T) {
  96. body := []byte(fmt.Sprintf(`{"model":%q,"system":"test","messages":[{"role":"user","content":"hi"}]}`, tt.model))
  97. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformAnthropic)
  98. require.NoError(t, err)
  99. require.Equal(t, tt.want, parsed.Model)
  100. require.Equal(t, tt.want, gjson.GetBytes(parsed.Body.Bytes(), "model").String())
  101. require.Equal(t, `"test"`, string(parsed.SystemRaw()))
  102. require.NotEmpty(t, parsed.MessagesRaw())
  103. })
  104. }
  105. }
  106. func TestParseGatewayRequest_NonAnthropicPreservesClaudeCodeLongContextModelSuffix(t *testing.T) {
  107. body := []byte(`{"model":"claude-opus-4-8[1m]","input":"hi"}`)
  108. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), "responses")
  109. require.NoError(t, err)
  110. require.Equal(t, "claude-opus-4-8[1m]", parsed.Model)
  111. require.Equal(t, "claude-opus-4-8[1m]", gjson.GetBytes(parsed.Body.Bytes(), "model").String())
  112. }
  113. func TestParseGatewayRequest_ResponsesInput(t *testing.T) {
  114. body := []byte(`{"model":"gpt-5.1","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`)
  115. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), "responses")
  116. require.NoError(t, err)
  117. require.NotEmpty(t, parsed.InputRaw())
  118. require.Nil(t, parsed.MessagesRaw())
  119. require.Equal(t, "hello", gjson.ParseBytes(parsed.InputRaw()).Get("0.content.0.text").String())
  120. }
  121. // ============ Gemini 原生格式解析测试 ============
  122. func TestParseGatewayRequest_GeminiContents(t *testing.T) {
  123. body := []byte(`{
  124. "contents": [
  125. {"role": "user", "parts": [{"text": "Hello"}]},
  126. {"role": "model", "parts": [{"text": "Hi there"}]},
  127. {"role": "user", "parts": [{"text": "How are you?"}]}
  128. ]
  129. }`)
  130. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
  131. require.NoError(t, err)
  132. require.Len(t, gjson.ParseBytes(parsed.MessagesRaw()).Array(), 3, "should parse contents as Messages")
  133. require.False(t, parsed.HasSystem, "Gemini format should not set HasSystem")
  134. require.Nil(t, parsed.SystemRaw(), "no systemInstruction means nil System")
  135. }
  136. func TestParseGatewayRequest_GeminiSystemInstruction(t *testing.T) {
  137. body := []byte(`{
  138. "systemInstruction": {
  139. "parts": [{"text": "You are a helpful assistant."}]
  140. },
  141. "contents": [
  142. {"role": "user", "parts": [{"text": "Hello"}]}
  143. ]
  144. }`)
  145. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
  146. require.NoError(t, err)
  147. system := gjson.ParseBytes(parsed.SystemRaw())
  148. require.True(t, system.IsArray(), "should parse systemInstruction.parts as System")
  149. require.Len(t, system.Array(), 1)
  150. require.Equal(t, "You are a helpful assistant.", system.Get("0.text").String())
  151. require.Len(t, gjson.ParseBytes(parsed.MessagesRaw()).Array(), 1)
  152. }
  153. func TestParseGatewayRequest_GeminiWithModel(t *testing.T) {
  154. body := []byte(`{
  155. "model": "gemini-2.5-pro",
  156. "contents": [{"role": "user", "parts": [{"text": "test"}]}]
  157. }`)
  158. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
  159. require.NoError(t, err)
  160. require.Equal(t, "gemini-2.5-pro", parsed.Model)
  161. require.Len(t, gjson.ParseBytes(parsed.MessagesRaw()).Array(), 1)
  162. }
  163. func TestParseGatewayRequest_GeminiIgnoresAnthropicFields(t *testing.T) {
  164. // Gemini 格式下 system/messages 字段应被忽略
  165. body := []byte(`{
  166. "system": "should be ignored",
  167. "messages": [{"role": "user", "content": "ignored"}],
  168. "contents": [{"role": "user", "parts": [{"text": "real content"}]}]
  169. }`)
  170. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
  171. require.NoError(t, err)
  172. require.False(t, parsed.HasSystem, "Gemini protocol should not parse Anthropic system field")
  173. require.Nil(t, parsed.SystemRaw(), "no systemInstruction = nil System")
  174. require.Len(t, gjson.ParseBytes(parsed.MessagesRaw()).Array(), 1, "should use contents, not messages")
  175. }
  176. func TestParseGatewayRequest_GeminiEmptyContents(t *testing.T) {
  177. body := []byte(`{"contents": []}`)
  178. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
  179. require.NoError(t, err)
  180. require.Empty(t, gjson.ParseBytes(parsed.MessagesRaw()).Array())
  181. }
  182. func TestParseGatewayRequest_GeminiNoContents(t *testing.T) {
  183. body := []byte(`{"model": "gemini-2.5-flash"}`)
  184. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
  185. require.NoError(t, err)
  186. require.Nil(t, parsed.MessagesRaw())
  187. require.Equal(t, "gemini-2.5-flash", parsed.Model)
  188. }
  189. func TestParseGatewayRequest_AnthropicIgnoresGeminiFields(t *testing.T) {
  190. // Anthropic 格式下 contents/systemInstruction 字段应被忽略
  191. body := []byte(`{
  192. "system": "real system",
  193. "messages": [{"role": "user", "content": "real content"}],
  194. "contents": [{"role": "user", "parts": [{"text": "ignored"}]}],
  195. "systemInstruction": {"parts": [{"text": "ignored"}]}
  196. }`)
  197. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformAnthropic)
  198. require.NoError(t, err)
  199. require.True(t, parsed.HasSystem)
  200. require.Equal(t, "real system", gjson.ParseBytes(parsed.SystemRaw()).String())
  201. messages := gjson.ParseBytes(parsed.MessagesRaw()).Array()
  202. require.Len(t, messages, 1)
  203. require.Equal(t, "real content", messages[0].Get("content").String())
  204. }
  205. func TestFilterThinkingBlocks(t *testing.T) {
  206. containsThinkingBlock := func(body []byte) bool {
  207. var req map[string]any
  208. if err := json.Unmarshal(body, &req); err != nil {
  209. return false
  210. }
  211. messages, ok := req["messages"].([]any)
  212. if !ok {
  213. return false
  214. }
  215. for _, msg := range messages {
  216. msgMap, ok := msg.(map[string]any)
  217. if !ok {
  218. continue
  219. }
  220. content, ok := msgMap["content"].([]any)
  221. if !ok {
  222. continue
  223. }
  224. for _, block := range content {
  225. blockMap, ok := block.(map[string]any)
  226. if !ok {
  227. continue
  228. }
  229. blockType, _ := blockMap["type"].(string)
  230. if blockType == "thinking" {
  231. return true
  232. }
  233. if blockType == "" {
  234. if _, hasThinking := blockMap["thinking"]; hasThinking {
  235. return true
  236. }
  237. }
  238. }
  239. }
  240. return false
  241. }
  242. tests := []struct {
  243. name string
  244. input string
  245. shouldFilter bool
  246. expectError bool
  247. }{
  248. {
  249. name: "filters thinking blocks",
  250. input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"},{"type":"thinking","thinking":"internal","signature":"invalid"},{"type":"text","text":"World"}]}]}`,
  251. shouldFilter: true,
  252. },
  253. {
  254. name: "does not filter signed thinking blocks when thinking adaptive",
  255. input: `{"thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"ok","signature":"sig_real_123"},{"type":"text","text":"B"}]}]}`,
  256. shouldFilter: false,
  257. },
  258. {
  259. name: "filters unsigned thinking blocks when thinking adaptive",
  260. input: `{"thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"internal","signature":""},{"type":"text","text":"B"}]}]}`,
  261. shouldFilter: true,
  262. },
  263. {
  264. name: "handles no thinking blocks",
  265. input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"}]}]}`,
  266. shouldFilter: false,
  267. },
  268. {
  269. name: "handles invalid JSON gracefully",
  270. input: `{invalid json`,
  271. shouldFilter: false,
  272. expectError: true,
  273. },
  274. {
  275. name: "handles multiple messages with thinking blocks",
  276. input: `{"messages":[{"role":"user","content":[{"type":"text","text":"A"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"think"},{"type":"text","text":"B"}]}]}`,
  277. shouldFilter: true,
  278. },
  279. {
  280. name: "filters thinking blocks without type discriminator",
  281. input: `{"messages":[{"role":"assistant","content":[{"thinking":{"text":"internal"}},{"type":"text","text":"B"}]}]}`,
  282. shouldFilter: true,
  283. },
  284. {
  285. name: "does not filter tool_use input fields named thinking",
  286. input: `{"messages":[{"role":"user","content":[{"type":"tool_use","id":"t1","name":"foo","input":{"thinking":"keepme","x":1}},{"type":"text","text":"Hello"}]}]}`,
  287. shouldFilter: false,
  288. },
  289. {
  290. name: "handles empty messages array",
  291. input: `{"messages":[]}`,
  292. shouldFilter: false,
  293. },
  294. {
  295. name: "handles missing messages field",
  296. input: `{"model":"claude-3"}`,
  297. shouldFilter: false,
  298. },
  299. }
  300. for _, tt := range tests {
  301. t.Run(tt.name, func(t *testing.T) {
  302. result := FilterThinkingBlocks([]byte(tt.input), "claude-sonnet-4-5")
  303. if tt.expectError {
  304. // For invalid JSON, should return original
  305. require.Equal(t, tt.input, string(result))
  306. return
  307. }
  308. if tt.shouldFilter {
  309. require.False(t, containsThinkingBlock(result))
  310. } else {
  311. // Ensure we don't rewrite JSON when no filtering is needed.
  312. require.Equal(t, tt.input, string(result))
  313. }
  314. // Verify valid JSON returned (unless input was invalid)
  315. var parsed map[string]any
  316. err := json.Unmarshal(result, &parsed)
  317. require.NoError(t, err)
  318. })
  319. }
  320. }
  321. func TestFilterThinkingBlocksForRetry_DisablesThinkingAndPreservesAsText(t *testing.T) {
  322. input := []byte(`{
  323. "model":"claude-3-5-sonnet-20241022",
  324. "thinking":{"type":"enabled","budget_tokens":1024},
  325. "messages":[
  326. {"role":"user","content":[{"type":"text","text":"Hi"}]},
  327. {"role":"assistant","content":[
  328. {"type":"thinking","thinking":"Let me think...","signature":"bad_sig"},
  329. {"type":"text","text":"Answer"}
  330. ]}
  331. ]
  332. }`)
  333. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  334. var req map[string]any
  335. require.NoError(t, json.Unmarshal(out, &req))
  336. _, hasThinking := req["thinking"]
  337. require.False(t, hasThinking)
  338. msgs, ok := req["messages"].([]any)
  339. require.True(t, ok)
  340. require.Len(t, msgs, 2)
  341. assistant, ok := msgs[1].(map[string]any)
  342. require.True(t, ok)
  343. content, ok := assistant["content"].([]any)
  344. require.True(t, ok)
  345. require.Len(t, content, 2)
  346. first, ok := content[0].(map[string]any)
  347. require.True(t, ok)
  348. require.Equal(t, "text", first["type"])
  349. require.Equal(t, "Let me think...", first["text"])
  350. }
  351. func TestFilterThinkingBlocksForRetry_DisablesThinkingEvenWithoutThinkingBlocks(t *testing.T) {
  352. input := []byte(`{
  353. "model":"claude-3-5-sonnet-20241022",
  354. "thinking":{"type":"enabled","budget_tokens":1024},
  355. "messages":[
  356. {"role":"user","content":[{"type":"text","text":"Hi"}]},
  357. {"role":"assistant","content":[{"type":"text","text":"Prefill"}]}
  358. ]
  359. }`)
  360. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  361. var req map[string]any
  362. require.NoError(t, json.Unmarshal(out, &req))
  363. _, hasThinking := req["thinking"]
  364. require.False(t, hasThinking)
  365. }
  366. func TestFilterThinkingBlocksForRetry_RemovesRedactedThinkingAndKeepsValidContent(t *testing.T) {
  367. input := []byte(`{
  368. "thinking":{"type":"enabled","budget_tokens":1024},
  369. "messages":[
  370. {"role":"assistant","content":[
  371. {"type":"redacted_thinking","data":"..."},
  372. {"type":"text","text":"Visible"}
  373. ]}
  374. ]
  375. }`)
  376. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  377. var req map[string]any
  378. require.NoError(t, json.Unmarshal(out, &req))
  379. _, hasThinking := req["thinking"]
  380. require.False(t, hasThinking)
  381. msgs, ok := req["messages"].([]any)
  382. require.True(t, ok)
  383. msg0, ok := msgs[0].(map[string]any)
  384. require.True(t, ok)
  385. content, ok := msg0["content"].([]any)
  386. require.True(t, ok)
  387. require.Len(t, content, 1)
  388. content0, ok := content[0].(map[string]any)
  389. require.True(t, ok)
  390. require.Equal(t, "text", content0["type"])
  391. require.Equal(t, "Visible", content0["text"])
  392. }
  393. func TestFilterThinkingBlocksForRetry_DropsThinkingBlockWithEmptyContent(t *testing.T) {
  394. // 跨模型场景:其他模型回过的 assistant 历史里携带了 type=thinking 但 thinking 字段为空,
  395. // 喂给开启 extended thinking 的 claude 时上游会报:
  396. // "messages.1.content.0.thinking: each thinking block must contain thinking"
  397. // 重试应当把空 thinking 块丢弃,并保留其它有效内容。
  398. input := []byte(`{
  399. "thinking":{"type":"enabled","budget_tokens":1024},
  400. "messages":[
  401. {"role":"user","content":[{"type":"text","text":"Hi"}]},
  402. {"role":"assistant","content":[
  403. {"type":"thinking","thinking":"","signature":"sig"},
  404. {"type":"text","text":"Answer"}
  405. ]}
  406. ]
  407. }`)
  408. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  409. var req map[string]any
  410. require.NoError(t, json.Unmarshal(out, &req))
  411. _, hasThinking := req["thinking"]
  412. require.False(t, hasThinking, "top-level thinking should be removed")
  413. msgs := req["messages"].([]any)
  414. assistant := msgs[1].(map[string]any)
  415. content := assistant["content"].([]any)
  416. require.Len(t, content, 1, "empty thinking block should be dropped, only text remains")
  417. require.Equal(t, "text", content[0].(map[string]any)["type"])
  418. require.Equal(t, "Answer", content[0].(map[string]any)["text"])
  419. }
  420. func TestFilterThinkingBlocksForRetry_EmptyContentGetsPlaceholder(t *testing.T) {
  421. input := []byte(`{
  422. "thinking":{"type":"enabled"},
  423. "messages":[
  424. {"role":"assistant","content":[{"type":"redacted_thinking","data":"..."}]}
  425. ]
  426. }`)
  427. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  428. var req map[string]any
  429. require.NoError(t, json.Unmarshal(out, &req))
  430. msgs, ok := req["messages"].([]any)
  431. require.True(t, ok)
  432. msg0, ok := msgs[0].(map[string]any)
  433. require.True(t, ok)
  434. content, ok := msg0["content"].([]any)
  435. require.True(t, ok)
  436. require.Len(t, content, 1)
  437. content0, ok := content[0].(map[string]any)
  438. require.True(t, ok)
  439. require.Equal(t, "text", content0["type"])
  440. require.NotEmpty(t, content0["text"])
  441. }
  442. func TestFilterThinkingBlocksForRetry_StripsEmptyTextBlocks(t *testing.T) {
  443. // Empty text blocks cause upstream 400: "text content blocks must be non-empty"
  444. input := []byte(`{
  445. "messages":[
  446. {"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":""}]},
  447. {"role":"assistant","content":[{"type":"text","text":""}]}
  448. ]
  449. }`)
  450. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  451. var req map[string]any
  452. require.NoError(t, json.Unmarshal(out, &req))
  453. msgs, ok := req["messages"].([]any)
  454. require.True(t, ok)
  455. // First message: empty text block stripped, "hello" preserved
  456. msg0 := msgs[0].(map[string]any)
  457. content0 := msg0["content"].([]any)
  458. require.Len(t, content0, 1)
  459. require.Equal(t, "hello", content0[0].(map[string]any)["text"])
  460. // Second message: only had empty text block → gets placeholder
  461. msg1 := msgs[1].(map[string]any)
  462. content1 := msg1["content"].([]any)
  463. require.Len(t, content1, 1)
  464. block1 := content1[0].(map[string]any)
  465. require.Equal(t, "text", block1["type"])
  466. require.NotEmpty(t, block1["text"])
  467. }
  468. func TestFilterThinkingBlocksForRetry_StripsNestedEmptyTextInToolResult(t *testing.T) {
  469. // Empty text blocks nested inside tool_result content should also be stripped
  470. input := []byte(`{
  471. "messages":[
  472. {"role":"user","content":[
  473. {"type":"tool_result","tool_use_id":"t1","content":[
  474. {"type":"text","text":"valid result"},
  475. {"type":"text","text":""}
  476. ]}
  477. ]}
  478. ]
  479. }`)
  480. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  481. var req map[string]any
  482. require.NoError(t, json.Unmarshal(out, &req))
  483. msgs := req["messages"].([]any)
  484. msg0 := msgs[0].(map[string]any)
  485. content0 := msg0["content"].([]any)
  486. require.Len(t, content0, 1)
  487. toolResult := content0[0].(map[string]any)
  488. require.Equal(t, "tool_result", toolResult["type"])
  489. nestedContent := toolResult["content"].([]any)
  490. require.Len(t, nestedContent, 1)
  491. require.Equal(t, "valid result", nestedContent[0].(map[string]any)["text"])
  492. }
  493. func TestFilterThinkingBlocksForRetry_NestedAllEmptyGetsEmptySlice(t *testing.T) {
  494. // If all nested content blocks in tool_result are empty text, content becomes empty slice
  495. input := []byte(`{
  496. "messages":[
  497. {"role":"user","content":[
  498. {"type":"tool_result","tool_use_id":"t1","content":[
  499. {"type":"text","text":""}
  500. ]},
  501. {"type":"text","text":"hello"}
  502. ]}
  503. ]
  504. }`)
  505. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  506. var req map[string]any
  507. require.NoError(t, json.Unmarshal(out, &req))
  508. msgs := req["messages"].([]any)
  509. msg0 := msgs[0].(map[string]any)
  510. content0 := msg0["content"].([]any)
  511. require.Len(t, content0, 2)
  512. toolResult := content0[0].(map[string]any)
  513. nestedContent := toolResult["content"].([]any)
  514. require.Len(t, nestedContent, 0)
  515. }
  516. func TestStripEmptyTextBlocks(t *testing.T) {
  517. t.Run("strips top-level empty text", func(t *testing.T) {
  518. input := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":""}]}]}`)
  519. out := StripEmptyTextBlocks(input)
  520. var req map[string]any
  521. require.NoError(t, json.Unmarshal(out, &req))
  522. msgs := req["messages"].([]any)
  523. content := msgs[0].(map[string]any)["content"].([]any)
  524. require.Len(t, content, 1)
  525. require.Equal(t, "hello", content[0].(map[string]any)["text"])
  526. })
  527. t.Run("strips nested empty text in tool_result", func(t *testing.T) {
  528. input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"ok"},{"type":"text","text":""}]}]}]}`)
  529. out := StripEmptyTextBlocks(input)
  530. var req map[string]any
  531. require.NoError(t, json.Unmarshal(out, &req))
  532. msgs := req["messages"].([]any)
  533. content := msgs[0].(map[string]any)["content"].([]any)
  534. toolResult := content[0].(map[string]any)
  535. nestedContent := toolResult["content"].([]any)
  536. require.Len(t, nestedContent, 1)
  537. require.Equal(t, "ok", nestedContent[0].(map[string]any)["text"])
  538. })
  539. t.Run("no-op when no empty text", func(t *testing.T) {
  540. input := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  541. out := StripEmptyTextBlocks(input)
  542. require.Equal(t, input, out)
  543. })
  544. t.Run("preserves non-map blocks in content", func(t *testing.T) {
  545. // tool_result content can be a string; non-map blocks should pass through unchanged
  546. input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"string content"},{"type":"text","text":""}]}]}`)
  547. out := StripEmptyTextBlocks(input)
  548. var req map[string]any
  549. require.NoError(t, json.Unmarshal(out, &req))
  550. msgs := req["messages"].([]any)
  551. content := msgs[0].(map[string]any)["content"].([]any)
  552. require.Len(t, content, 1)
  553. toolResult := content[0].(map[string]any)
  554. require.Equal(t, "tool_result", toolResult["type"])
  555. require.Equal(t, "string content", toolResult["content"])
  556. })
  557. t.Run("handles deeply nested tool_result", func(t *testing.T) {
  558. // Recursive: tool_result containing another tool_result with empty text
  559. input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"tool_result","tool_use_id":"t2","content":[{"type":"text","text":""},{"type":"text","text":"deep"}]}]}]}]}`)
  560. out := StripEmptyTextBlocks(input)
  561. var req map[string]any
  562. require.NoError(t, json.Unmarshal(out, &req))
  563. msgs := req["messages"].([]any)
  564. content := msgs[0].(map[string]any)["content"].([]any)
  565. outer := content[0].(map[string]any)
  566. innerContent := outer["content"].([]any)
  567. inner := innerContent[0].(map[string]any)
  568. deepContent := inner["content"].([]any)
  569. require.Len(t, deepContent, 1)
  570. require.Equal(t, "deep", deepContent[0].(map[string]any)["text"])
  571. })
  572. }
  573. func TestFilterThinkingBlocksForRetry_PreservesNonEmptyTextBlocks(t *testing.T) {
  574. // Non-empty text blocks should pass through unchanged
  575. input := []byte(`{
  576. "messages":[
  577. {"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]}
  578. ]
  579. }`)
  580. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  581. // Fast path: no thinking content, no empty content, no empty text blocks → unchanged
  582. require.Equal(t, input, out)
  583. }
  584. func TestFilterSignatureSensitiveBlocksForRetry_DowngradesTools(t *testing.T) {
  585. input := []byte(`{
  586. "thinking":{"type":"enabled","budget_tokens":1024},
  587. "messages":[
  588. {"role":"assistant","content":[
  589. {"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}},
  590. {"type":"tool_result","tool_use_id":"t1","content":"ok","is_error":false}
  591. ]}
  592. ]
  593. }`)
  594. out := FilterSignatureSensitiveBlocksForRetry(input, "claude-sonnet-4-5")
  595. var req map[string]any
  596. require.NoError(t, json.Unmarshal(out, &req))
  597. _, hasThinking := req["thinking"]
  598. require.False(t, hasThinking)
  599. msgs, ok := req["messages"].([]any)
  600. require.True(t, ok)
  601. msg0, ok := msgs[0].(map[string]any)
  602. require.True(t, ok)
  603. content, ok := msg0["content"].([]any)
  604. require.True(t, ok)
  605. require.Len(t, content, 2)
  606. content0, ok := content[0].(map[string]any)
  607. require.True(t, ok)
  608. content1, ok := content[1].(map[string]any)
  609. require.True(t, ok)
  610. require.Equal(t, "text", content0["type"])
  611. require.Equal(t, "text", content1["type"])
  612. require.Contains(t, content0["text"], "tool_use")
  613. require.Contains(t, content1["text"], "tool_result")
  614. }
  615. // ============ Group 6b: context_management.edits 清理测试 ============
  616. // removeThinkingDependentContextStrategies — 边界用例
  617. func TestRemoveThinkingDependentContextStrategies_NoContextManagement(t *testing.T) {
  618. input := []byte(`{"thinking":{"type":"enabled"},"messages":[]}`)
  619. out := removeThinkingDependentContextStrategies(input)
  620. require.Equal(t, input, out, "无 context_management 字段时应原样返回")
  621. }
  622. func TestRemoveThinkingDependentContextStrategies_EmptyEdits(t *testing.T) {
  623. input := []byte(`{"context_management":{"edits":[]},"messages":[]}`)
  624. out := removeThinkingDependentContextStrategies(input)
  625. require.Equal(t, input, out, "edits 为空数组时应原样返回")
  626. }
  627. func TestRemoveThinkingDependentContextStrategies_NoClearThinkingEntry(t *testing.T) {
  628. input := []byte(`{"context_management":{"edits":[{"type":"other_strategy"}]},"messages":[]}`)
  629. out := removeThinkingDependentContextStrategies(input)
  630. require.Equal(t, input, out, "edits 中无 clear_thinking_20251015 时应原样返回")
  631. }
  632. func TestRemoveThinkingDependentContextStrategies_RemovesSingleEntry(t *testing.T) {
  633. input := []byte(`{"context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
  634. out := removeThinkingDependentContextStrategies(input)
  635. var req map[string]any
  636. require.NoError(t, json.Unmarshal(out, &req))
  637. cm, ok := req["context_management"].(map[string]any)
  638. require.True(t, ok)
  639. _, hasEdits := cm["edits"]
  640. require.False(t, hasEdits, "所有 edits 均为 clear_thinking_20251015 时应删除 edits 键")
  641. }
  642. func TestRemoveThinkingDependentContextStrategies_MixedEntries(t *testing.T) {
  643. input := []byte(`{"context_management":{"edits":[{"type":"clear_thinking_20251015"},{"type":"other_strategy","param":1}]},"messages":[]}`)
  644. out := removeThinkingDependentContextStrategies(input)
  645. var req map[string]any
  646. require.NoError(t, json.Unmarshal(out, &req))
  647. cm, ok := req["context_management"].(map[string]any)
  648. require.True(t, ok)
  649. edits, ok := cm["edits"].([]any)
  650. require.True(t, ok)
  651. require.Len(t, edits, 1, "仅移除 clear_thinking_20251015,保留其他条目")
  652. edit0, ok := edits[0].(map[string]any)
  653. require.True(t, ok)
  654. require.Equal(t, "other_strategy", edit0["type"])
  655. }
  656. // FilterThinkingBlocksForRetry — 包含 context_management 的场景
  657. func TestFilterThinkingBlocksForRetry_RemovesClearThinkingStrategy_FastPath(t *testing.T) {
  658. // 快速路径:messages 中无 thinking 块,仅有顶层 thinking 字段
  659. // 这条路径曾因提前 return 跳过 removeThinkingDependentContextStrategies 而存在 bug
  660. input := []byte(`{
  661. "thinking":{"type":"enabled","budget_tokens":1024},
  662. "context_management":{"edits":[{"type":"clear_thinking_20251015"}]},
  663. "messages":[
  664. {"role":"user","content":[{"type":"text","text":"Hello"}]}
  665. ]
  666. }`)
  667. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  668. var req map[string]any
  669. require.NoError(t, json.Unmarshal(out, &req))
  670. _, hasThinking := req["thinking"]
  671. require.False(t, hasThinking, "顶层 thinking 应被移除")
  672. cm, ok := req["context_management"].(map[string]any)
  673. require.True(t, ok)
  674. _, hasEdits := cm["edits"]
  675. require.False(t, hasEdits, "fast path 下 clear_thinking_20251015 应被移除,edits 键应被删除")
  676. }
  677. func TestFilterThinkingBlocksForRetry_RemovesClearThinkingStrategy_WithThinkingBlocks(t *testing.T) {
  678. // 完整路径:messages 中有 thinking 块(非 fast path)
  679. input := []byte(`{
  680. "thinking":{"type":"enabled","budget_tokens":1024},
  681. "context_management":{"edits":[{"type":"clear_thinking_20251015"},{"type":"keep_this"}]},
  682. "messages":[
  683. {"role":"assistant","content":[
  684. {"type":"thinking","thinking":"some thought","signature":"sig"},
  685. {"type":"text","text":"Answer"}
  686. ]}
  687. ]
  688. }`)
  689. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  690. var req map[string]any
  691. require.NoError(t, json.Unmarshal(out, &req))
  692. _, hasThinking := req["thinking"]
  693. require.False(t, hasThinking, "顶层 thinking 应被移除")
  694. cm, ok := req["context_management"].(map[string]any)
  695. require.True(t, ok)
  696. edits, ok := cm["edits"].([]any)
  697. require.True(t, ok)
  698. require.Len(t, edits, 1, "仅移除 clear_thinking_20251015,保留 keep_this")
  699. edit0, ok := edits[0].(map[string]any)
  700. require.True(t, ok)
  701. require.Equal(t, "keep_this", edit0["type"])
  702. }
  703. func TestFilterThinkingBlocksForRetry_NoContextManagement_Unaffected(t *testing.T) {
  704. // 无 context_management 时不应报错,且 thinking 正常被移除
  705. input := []byte(`{
  706. "thinking":{"type":"enabled"},
  707. "messages":[{"role":"user","content":[{"type":"text","text":"Hi"}]}]
  708. }`)
  709. out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5")
  710. var req map[string]any
  711. require.NoError(t, json.Unmarshal(out, &req))
  712. _, hasThinking := req["thinking"]
  713. require.False(t, hasThinking)
  714. _, hasCM := req["context_management"]
  715. require.False(t, hasCM)
  716. }
  717. // FilterSignatureSensitiveBlocksForRetry — 包含 context_management 的场景
  718. func TestFilterSignatureSensitiveBlocksForRetry_RemovesClearThinkingStrategy(t *testing.T) {
  719. input := []byte(`{
  720. "thinking":{"type":"enabled","budget_tokens":1024},
  721. "context_management":{"edits":[{"type":"clear_thinking_20251015"}]},
  722. "messages":[
  723. {"role":"assistant","content":[
  724. {"type":"thinking","thinking":"thought","signature":"sig"}
  725. ]}
  726. ]
  727. }`)
  728. out := FilterSignatureSensitiveBlocksForRetry(input, "claude-sonnet-4-5")
  729. var req map[string]any
  730. require.NoError(t, json.Unmarshal(out, &req))
  731. _, hasThinking := req["thinking"]
  732. require.False(t, hasThinking, "顶层 thinking 应被移除")
  733. cm, ok := req["context_management"].(map[string]any)
  734. require.True(t, ok)
  735. if rawEdits, hasEdits := cm["edits"]; hasEdits {
  736. edits, ok := rawEdits.([]any)
  737. require.True(t, ok)
  738. for _, e := range edits {
  739. em, ok := e.(map[string]any)
  740. require.True(t, ok)
  741. require.NotEqual(t, "clear_thinking_20251015", em["type"], "clear_thinking_20251015 应被移除")
  742. }
  743. }
  744. }
  745. func TestFilterSignatureSensitiveBlocksForRetry_PreservesNonThinkingStrategies(t *testing.T) {
  746. input := []byte(`{
  747. "thinking":{"type":"enabled"},
  748. "context_management":{"edits":[{"type":"clear_thinking_20251015"},{"type":"other_edit"}]},
  749. "messages":[
  750. {"role":"assistant","content":[
  751. {"type":"thinking","thinking":"t","signature":"s"}
  752. ]}
  753. ]
  754. }`)
  755. out := FilterSignatureSensitiveBlocksForRetry(input, "claude-sonnet-4-5")
  756. var req map[string]any
  757. require.NoError(t, json.Unmarshal(out, &req))
  758. cm, ok := req["context_management"].(map[string]any)
  759. require.True(t, ok)
  760. edits, ok := cm["edits"].([]any)
  761. require.True(t, ok)
  762. require.Len(t, edits, 1, "仅移除 clear_thinking_20251015,保留 other_edit")
  763. edit0, ok := edits[0].(map[string]any)
  764. require.True(t, ok)
  765. require.Equal(t, "other_edit", edit0["type"])
  766. }
  767. func TestFilterSignatureSensitiveBlocksForRetry_NoThinkingField_ContextManagementUntouched(t *testing.T) {
  768. // 没有顶层 thinking 字段时,context_management 不应被修改
  769. input := []byte(`{
  770. "context_management":{"edits":[{"type":"clear_thinking_20251015"}]},
  771. "messages":[
  772. {"role":"assistant","content":[
  773. {"type":"thinking","thinking":"t","signature":"s"}
  774. ]}
  775. ]
  776. }`)
  777. out := FilterSignatureSensitiveBlocksForRetry(input, "claude-sonnet-4-5")
  778. var req map[string]any
  779. require.NoError(t, json.Unmarshal(out, &req))
  780. cm, ok := req["context_management"].(map[string]any)
  781. require.True(t, ok)
  782. edits, ok := cm["edits"].([]any)
  783. require.True(t, ok)
  784. require.Len(t, edits, 1, "无顶层 thinking 时 context_management 不应被修改")
  785. }
  786. // ============ Group 7: ParseGatewayRequest 补充单元测试 ============
  787. // Task 7.1 — 类型校验边界测试
  788. func TestParseGatewayRequest_TypeValidation(t *testing.T) {
  789. tests := []struct {
  790. name string
  791. body string
  792. wantErr bool
  793. errSubstr string // 期望的错误信息子串(为空则不检查)
  794. }{
  795. {
  796. name: "model 为 int",
  797. body: `{"model":123}`,
  798. wantErr: true,
  799. errSubstr: "invalid model field type",
  800. },
  801. {
  802. name: "model 为 array",
  803. body: `{"model":[]}`,
  804. wantErr: true,
  805. errSubstr: "invalid model field type",
  806. },
  807. {
  808. name: "model 为 bool",
  809. body: `{"model":true}`,
  810. wantErr: true,
  811. errSubstr: "invalid model field type",
  812. },
  813. {
  814. name: "model 为 null — gjson Null 类型触发类型校验错误",
  815. body: `{"model":null}`,
  816. wantErr: true, // gjson: Exists()=true, Type=Null != String → 返回错误
  817. errSubstr: "invalid model field type",
  818. },
  819. {
  820. name: "stream 为 string",
  821. body: `{"stream":"true"}`,
  822. wantErr: true,
  823. errSubstr: "invalid stream field type",
  824. },
  825. {
  826. name: "stream 为 int",
  827. body: `{"stream":1}`,
  828. wantErr: true,
  829. errSubstr: "invalid stream field type",
  830. },
  831. {
  832. name: "stream 为 null — gjson Null 类型触发类型校验错误",
  833. body: `{"stream":null}`,
  834. wantErr: true, // gjson: Exists()=true, Type=Null != True && != False → 返回错误
  835. errSubstr: "invalid stream field type",
  836. },
  837. {
  838. name: "model 为 object",
  839. body: `{"model":{}}`,
  840. wantErr: true,
  841. errSubstr: "invalid model field type",
  842. },
  843. }
  844. for _, tt := range tests {
  845. t.Run(tt.name, func(t *testing.T) {
  846. _, err := ParseGatewayRequest(NewRequestBodyRef([]byte(tt.body)), "")
  847. if tt.wantErr {
  848. require.Error(t, err)
  849. if tt.errSubstr != "" {
  850. require.Contains(t, err.Error(), tt.errSubstr)
  851. }
  852. } else {
  853. require.NoError(t, err)
  854. }
  855. })
  856. }
  857. }
  858. // Task 7.2 — 可选字段缺失测试
  859. func TestParseGatewayRequest_OptionalFieldsMissing(t *testing.T) {
  860. tests := []struct {
  861. name string
  862. body string
  863. wantModel string
  864. wantStream bool
  865. wantMetadataUID string
  866. wantHasSystem bool
  867. wantThinking bool
  868. wantMaxTokens int
  869. wantMessagesNil bool
  870. wantMessagesLen int
  871. }{
  872. {
  873. name: "完全空 JSON — 所有字段零值",
  874. body: `{}`,
  875. wantModel: "",
  876. wantStream: false,
  877. wantMetadataUID: "",
  878. wantHasSystem: false,
  879. wantThinking: false,
  880. wantMaxTokens: 0,
  881. wantMessagesNil: true,
  882. },
  883. {
  884. name: "metadata 无 user_id",
  885. body: `{"model":"test"}`,
  886. wantModel: "test",
  887. wantMetadataUID: "",
  888. wantHasSystem: false,
  889. wantThinking: false,
  890. },
  891. {
  892. name: "thinking 非 enabled(type=disabled)",
  893. body: `{"model":"test","thinking":{"type":"disabled"}}`,
  894. wantModel: "test",
  895. wantThinking: false,
  896. },
  897. {
  898. name: "thinking 字段缺失",
  899. body: `{"model":"test"}`,
  900. wantModel: "test",
  901. wantThinking: false,
  902. },
  903. }
  904. for _, tt := range tests {
  905. t.Run(tt.name, func(t *testing.T) {
  906. parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(tt.body)), "")
  907. require.NoError(t, err)
  908. require.Equal(t, tt.wantModel, parsed.Model)
  909. require.Equal(t, tt.wantStream, parsed.Stream)
  910. require.Equal(t, tt.wantMetadataUID, parsed.MetadataUserID)
  911. require.Equal(t, tt.wantHasSystem, parsed.HasSystem)
  912. require.Equal(t, tt.wantThinking, parsed.ThinkingEnabled)
  913. require.Equal(t, tt.wantMaxTokens, parsed.MaxTokens)
  914. if tt.wantMessagesNil {
  915. require.Nil(t, parsed.MessagesRaw())
  916. }
  917. if tt.wantMessagesLen > 0 {
  918. require.Len(t, gjson.ParseBytes(parsed.MessagesRaw()).Array(), tt.wantMessagesLen)
  919. }
  920. })
  921. }
  922. }
  923. // Task 7.3 — Gemini 协议分支测试
  924. // 已有测试覆盖:
  925. // - TestParseGatewayRequest_GeminiSystemInstruction: 正常 systemInstruction+contents
  926. // - TestParseGatewayRequest_GeminiNoContents: 缺失 contents
  927. // - TestParseGatewayRequest_GeminiContents: 正常 contents(无 systemInstruction)
  928. // 因此跳过。
  929. // Task 7.4 — max_tokens 边界测试
  930. func TestParseGatewayRequest_MaxTokensBoundary(t *testing.T) {
  931. tests := []struct {
  932. name string
  933. body string
  934. wantMaxTokens int
  935. wantErr bool
  936. }{
  937. {
  938. name: "正常整数",
  939. body: `{"max_tokens":1024}`,
  940. wantMaxTokens: 1024,
  941. },
  942. {
  943. name: "浮点数(非整数)被忽略",
  944. body: `{"max_tokens":10.5}`,
  945. wantMaxTokens: 0,
  946. },
  947. {
  948. name: "负整数可以通过",
  949. body: `{"max_tokens":-1}`,
  950. wantMaxTokens: -1,
  951. },
  952. {
  953. name: "超大值不 panic",
  954. body: `{"max_tokens":9999999999999999}`,
  955. wantMaxTokens: 10000000000000000, // float64 精度导致 9999999999999999 → 1e16
  956. },
  957. {
  958. name: "null 值被忽略",
  959. body: `{"max_tokens":null}`,
  960. wantMaxTokens: 0, // gjson Type=Null != Number → 条件不满足,跳过
  961. },
  962. }
  963. for _, tt := range tests {
  964. t.Run(tt.name, func(t *testing.T) {
  965. parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(tt.body)), "")
  966. if tt.wantErr {
  967. require.Error(t, err)
  968. return
  969. }
  970. require.NoError(t, err)
  971. require.Equal(t, tt.wantMaxTokens, parsed.MaxTokens)
  972. })
  973. }
  974. }
  975. // ============ Task 7.5: Benchmark 测试 ============
  976. // parseGatewayRequestOld 是基于完整 json.Unmarshal 的旧实现,用于 benchmark 对比基线。
  977. // 核心路径:先 Unmarshal 到 map[string]any,再逐字段提取。
  978. func parseGatewayRequestOld(body []byte, protocol string) (*ParsedRequest, error) {
  979. parsed := &ParsedRequest{
  980. Body: NewRequestBodyRef(body),
  981. }
  982. var req map[string]any
  983. if err := json.Unmarshal(body, &req); err != nil {
  984. return nil, err
  985. }
  986. // model
  987. if raw, ok := req["model"]; ok {
  988. s, ok := raw.(string)
  989. if !ok {
  990. return nil, fmt.Errorf("invalid model field type")
  991. }
  992. parsed.Model = s
  993. }
  994. // stream
  995. if raw, ok := req["stream"]; ok {
  996. b, ok := raw.(bool)
  997. if !ok {
  998. return nil, fmt.Errorf("invalid stream field type")
  999. }
  1000. parsed.Stream = b
  1001. }
  1002. // metadata.user_id
  1003. if meta, ok := req["metadata"].(map[string]any); ok {
  1004. if uid, ok := meta["user_id"].(string); ok {
  1005. parsed.MetadataUserID = uid
  1006. }
  1007. }
  1008. // thinking.type
  1009. if thinking, ok := req["thinking"].(map[string]any); ok {
  1010. if thinkType, ok := thinking["type"].(string); ok && thinkType == "enabled" {
  1011. parsed.ThinkingEnabled = true
  1012. }
  1013. }
  1014. // max_tokens
  1015. if raw, ok := req["max_tokens"]; ok {
  1016. if n, ok := parseIntegralNumber(raw); ok {
  1017. parsed.MaxTokens = n
  1018. }
  1019. }
  1020. if err := refreshGatewayRequestRanges(parsed, protocol); err != nil {
  1021. return nil, err
  1022. }
  1023. return parsed, nil
  1024. }
  1025. // buildSmallJSON 构建 ~500B 的小型测试 JSON
  1026. func buildSmallJSON() []byte {
  1027. return []byte(`{"model":"claude-sonnet-4-5","stream":true,"max_tokens":4096,"metadata":{"user_id":"user-abc123"},"thinking":{"type":"enabled","budget_tokens":2048},"system":"You are a helpful assistant.","messages":[{"role":"user","content":"What is the meaning of life?"},{"role":"assistant","content":"The meaning of life is a philosophical question."},{"role":"user","content":"Can you elaborate?"}]}`)
  1028. }
  1029. // buildLargeJSON 构建 ~50KB 的大型测试 JSON(大量 messages)
  1030. func buildLargeJSON() []byte {
  1031. var b strings.Builder
  1032. b.WriteString(`{"model":"claude-sonnet-4-5","stream":true,"max_tokens":8192,"metadata":{"user_id":"user-xyz789"},"system":[{"type":"text","text":"You are a detailed assistant.","cache_control":{"type":"ephemeral"}}],"messages":[`)
  1033. msgCount := 200
  1034. for i := 0; i < msgCount; i++ {
  1035. if i > 0 {
  1036. b.WriteByte(',')
  1037. }
  1038. if i%2 == 0 {
  1039. b.WriteString(fmt.Sprintf(`{"role":"user","content":"This is user message number %d with some extra padding text to make the message reasonably long for benchmarking purposes. Lorem ipsum dolor sit amet."}`, i))
  1040. } else {
  1041. b.WriteString(fmt.Sprintf(`{"role":"assistant","content":[{"type":"text","text":"This is assistant response number %d. I will provide a detailed answer with multiple sentences to simulate real conversation content for benchmark testing."}]}`, i))
  1042. }
  1043. }
  1044. b.WriteString(`]}`)
  1045. return []byte(b.String())
  1046. }
  1047. func BenchmarkParseGatewayRequest_Old_Small(b *testing.B) {
  1048. data := buildSmallJSON()
  1049. b.SetBytes(int64(len(data)))
  1050. b.ResetTimer()
  1051. for i := 0; i < b.N; i++ {
  1052. _, _ = parseGatewayRequestOld(data, "")
  1053. }
  1054. }
  1055. func BenchmarkParseGatewayRequest_New_Small(b *testing.B) {
  1056. data := buildSmallJSON()
  1057. b.SetBytes(int64(len(data)))
  1058. b.ResetTimer()
  1059. for i := 0; i < b.N; i++ {
  1060. _, _ = ParseGatewayRequest(NewRequestBodyRef(data), "")
  1061. }
  1062. }
  1063. func BenchmarkParseGatewayRequest_Old_Large(b *testing.B) {
  1064. data := buildLargeJSON()
  1065. b.SetBytes(int64(len(data)))
  1066. b.ResetTimer()
  1067. for i := 0; i < b.N; i++ {
  1068. _, _ = parseGatewayRequestOld(data, "")
  1069. }
  1070. }
  1071. func TestParseGatewayRequest_OutputEffort(t *testing.T) {
  1072. tests := []struct {
  1073. name string
  1074. body string
  1075. wantEffort string
  1076. }{
  1077. {
  1078. name: "output_config.effort present",
  1079. body: `{"model":"claude-opus-4-6","output_config":{"effort":"medium"},"messages":[]}`,
  1080. wantEffort: "medium",
  1081. },
  1082. {
  1083. name: "output_config.effort max",
  1084. body: `{"model":"claude-opus-4-6","output_config":{"effort":"max"},"messages":[]}`,
  1085. wantEffort: "max",
  1086. },
  1087. {
  1088. name: "output_config.effort xhigh",
  1089. body: `{"model":"claude-opus-4-7","output_config":{"effort":"xhigh"},"messages":[]}`,
  1090. wantEffort: "xhigh",
  1091. },
  1092. {
  1093. name: "output_config without effort",
  1094. body: `{"model":"claude-opus-4-6","output_config":{},"messages":[]}`,
  1095. wantEffort: "",
  1096. },
  1097. {
  1098. name: "no output_config",
  1099. body: `{"model":"claude-opus-4-6","messages":[]}`,
  1100. wantEffort: "",
  1101. },
  1102. {
  1103. name: "effort with whitespace trimmed",
  1104. body: `{"model":"claude-opus-4-6","output_config":{"effort":" high "},"messages":[]}`,
  1105. wantEffort: "high",
  1106. },
  1107. }
  1108. for _, tt := range tests {
  1109. t.Run(tt.name, func(t *testing.T) {
  1110. parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(tt.body)), "")
  1111. require.NoError(t, err)
  1112. require.Equal(t, tt.wantEffort, parsed.OutputEffort)
  1113. })
  1114. }
  1115. }
  1116. func TestNormalizeClaudeOutputEffort(t *testing.T) {
  1117. tests := []struct {
  1118. input string
  1119. want *string
  1120. }{
  1121. {"low", strPtr("low")},
  1122. {"medium", strPtr("medium")},
  1123. {"high", strPtr("high")},
  1124. {"max", strPtr("max")},
  1125. {"LOW", strPtr("low")},
  1126. {"Max", strPtr("max")},
  1127. {" medium ", strPtr("medium")},
  1128. {"xhigh", strPtr("xhigh")},
  1129. {"XHIGH", strPtr("xhigh")},
  1130. {"", nil},
  1131. {"unknown", nil},
  1132. }
  1133. for _, tt := range tests {
  1134. t.Run(tt.input, func(t *testing.T) {
  1135. got := NormalizeClaudeOutputEffort(tt.input)
  1136. if tt.want == nil {
  1137. require.Nil(t, got)
  1138. } else {
  1139. require.NotNil(t, got)
  1140. require.Equal(t, *tt.want, *got)
  1141. }
  1142. })
  1143. }
  1144. }
  1145. func BenchmarkParseGatewayRequest_New_Large(b *testing.B) {
  1146. data := buildLargeJSON()
  1147. b.SetBytes(int64(len(data)))
  1148. b.ResetTimer()
  1149. for i := 0; i < b.N; i++ {
  1150. _, _ = ParseGatewayRequest(NewRequestBodyRef(data), "")
  1151. }
  1152. }
  1153. func TestNormalizeChineseLLMThinking(t *testing.T) {
  1154. tests := []struct {
  1155. name string
  1156. model string
  1157. input string
  1158. wantApplied bool
  1159. wantTypeValue string // expected thinking.type after rewrite; "" = must not exist
  1160. wantUnchanged bool // body must be byte-for-byte unchanged
  1161. }{
  1162. // MiniMax M3 / M2.x — passback-required path: rewrite enabled -> adaptive
  1163. {
  1164. name: "minimax m3 enabled -> adaptive",
  1165. model: "MiniMax-M3",
  1166. input: `{"model":"MiniMax-M3","thinking":{"type":"enabled","budget_tokens":8192},"messages":[]}`,
  1167. wantApplied: true,
  1168. wantTypeValue: "adaptive",
  1169. },
  1170. {
  1171. name: "minimax m2.7 enabled -> adaptive",
  1172. model: "MiniMax-M2.7",
  1173. input: `{"model":"MiniMax-M2.7","thinking":{"type":"enabled","budget_tokens":4096},"messages":[]}`,
  1174. wantApplied: true,
  1175. wantTypeValue: "adaptive",
  1176. },
  1177. {
  1178. name: "minimax m3 adaptive is left alone",
  1179. model: "MiniMax-M3",
  1180. input: `{"model":"MiniMax-M3","thinking":{"type":"adaptive","budget_tokens":8192},"messages":[]}`,
  1181. wantApplied: false,
  1182. wantUnchanged: true,
  1183. },
  1184. {
  1185. name: "minimax m3 disabled is left alone",
  1186. model: "MiniMax-M3",
  1187. input: `{"model":"MiniMax-M3","thinking":{"type":"disabled"},"messages":[]}`,
  1188. wantApplied: false,
  1189. wantUnchanged: true,
  1190. },
  1191. {
  1192. name: "minimax m3 with no thinking field is no-op",
  1193. model: "MiniMax-M3",
  1194. input: `{"model":"MiniMax-M3","messages":[]}`,
  1195. wantApplied: false,
  1196. wantUnchanged: true,
  1197. },
  1198. // Non-MiniMax Chinese LLMs: no-op (Kimi/GLM/DeepSeek accept enabled as-is)
  1199. {
  1200. name: "kimi k2.6 with enabled left alone",
  1201. model: "kimi-k2.6",
  1202. input: `{"model":"kimi-k2.6","thinking":{"type":"enabled","budget_tokens":8192},"messages":[]}`,
  1203. wantApplied: false,
  1204. wantUnchanged: true,
  1205. },
  1206. {
  1207. name: "glm-5.1 with enabled left alone",
  1208. model: "glm-5.1",
  1209. input: `{"model":"glm-5.1","thinking":{"type":"enabled"},"messages":[]}`,
  1210. wantApplied: false,
  1211. wantUnchanged: true,
  1212. },
  1213. {
  1214. name: "deepseek v4-pro with enabled left alone",
  1215. model: "deepseek-v4-pro",
  1216. input: `{"model":"deepseek-v4-pro","thinking":{"type":"enabled"},"messages":[]}`,
  1217. wantApplied: false,
  1218. wantUnchanged: true,
  1219. },
  1220. // Anthropic-strict model: never rewritten even though prefix would not match anyway
  1221. {
  1222. name: "claude opus 4.6 with enabled left alone",
  1223. model: "claude-opus-4.6-20260201",
  1224. input: `{"model":"claude-opus-4.6-20260201","thinking":{"type":"enabled","budget_tokens":8192},"messages":[]}`,
  1225. wantApplied: false,
  1226. wantUnchanged: true,
  1227. },
  1228. // Edge case: invalid JSON — fail-safe return original
  1229. {
  1230. name: "invalid json returned unchanged",
  1231. model: "MiniMax-M3",
  1232. input: `{not json`,
  1233. wantApplied: false,
  1234. wantUnchanged: true,
  1235. },
  1236. }
  1237. for _, tt := range tests {
  1238. t.Run(tt.name, func(t *testing.T) {
  1239. got, applied := NormalizeChineseLLMThinking([]byte(tt.input), tt.model)
  1240. require.Equal(t, tt.wantApplied, applied, "applied mismatch")
  1241. if tt.wantUnchanged {
  1242. require.Equal(t, tt.input, string(got), "body must be byte-for-byte unchanged")
  1243. return
  1244. }
  1245. // Parsed-back validation: output must be valid JSON with the expected thinking.type
  1246. var parsed struct {
  1247. Thinking struct {
  1248. Type string `json:"type"`
  1249. } `json:"thinking"`
  1250. }
  1251. require.NoError(t, json.Unmarshal(got, &parsed), "output must be valid JSON")
  1252. require.Equal(t, tt.wantTypeValue, parsed.Thinking.Type)
  1253. })
  1254. }
  1255. }
  1256. func TestDefaultEffortForThinkingEnabled(t *testing.T) {
  1257. tests := []struct {
  1258. name string
  1259. model string
  1260. want *string // nil = expect no fallback
  1261. }{
  1262. // passback-required 上游中不支持 effort 档位的国产模型→补默认 high
  1263. {name: "glm-5.1", model: "glm-5.1", want: strPtr("high")},
  1264. {name: "glm-4.7", model: "glm-4.7", want: strPtr("high")},
  1265. {name: "kimi-k2.6", model: "kimi-k2.6", want: strPtr("high")},
  1266. {name: "kimi-k2-thinking", model: "kimi-k2-thinking", want: strPtr("high")},
  1267. {name: "moonshot-v1-8k", model: "moonshot-v1-8k", want: strPtr("high")},
  1268. {name: "minimax-m3 (lowercase)", model: "minimax-m3", want: strPtr("high")},
  1269. {name: "MiniMax-M3 (mixed case)", model: "MiniMax-M3", want: strPtr("high")},
  1270. {name: "qwen3-thinking variant", model: "qwen3-235b-a22b-thinking-2507", want: strPtr("high")},
  1271. // DeepSeek 有原生 effort 支持→不注入默认,让客户端意图透传
  1272. {name: "deepseek-v4-pro excluded", model: "deepseek-v4-pro", want: nil},
  1273. {name: "deepseek-v4-flash excluded", model: "deepseek-v4-flash", want: nil},
  1274. {name: "deepseek-chat excluded", model: "deepseek-chat", want: nil},
  1275. // 非 passback-required 模型一律返回 nil
  1276. {name: "claude opus 4.6 (anthropic-strict)", model: "claude-opus-4.6-20260201", want: nil},
  1277. {name: "gpt-5.5 (unknown)", model: "gpt-5.5", want: nil},
  1278. {name: "gemini-3.1-pro (unknown)", model: "gemini-3.1-pro", want: nil},
  1279. {name: "empty", model: "", want: nil},
  1280. }
  1281. for _, tt := range tests {
  1282. t.Run(tt.name, func(t *testing.T) {
  1283. got := DefaultEffortForThinkingEnabled(tt.model)
  1284. if tt.want == nil {
  1285. require.Nil(t, got)
  1286. return
  1287. }
  1288. require.NotNil(t, got)
  1289. require.Equal(t, *tt.want, *got)
  1290. })
  1291. }
  1292. }
  1293. func TestOpenAIBodyHasThinkingEnabled(t *testing.T) {
  1294. tests := []struct {
  1295. name string
  1296. body string
  1297. want bool
  1298. }{
  1299. {name: "enabled", body: `{"thinking":{"type":"enabled"}}`, want: true},
  1300. {name: "adaptive", body: `{"thinking":{"type":"adaptive"}}`, want: true},
  1301. {name: "ENABLED (uppercase)", body: `{"thinking":{"type":"ENABLED"}}`, want: true},
  1302. {name: "disabled", body: `{"thinking":{"type":"disabled"}}`, want: false},
  1303. {name: "empty body", body: ``, want: false},
  1304. {name: "no thinking field", body: `{"model":"gpt-5"}`, want: false},
  1305. {name: "thinking object but no type", body: `{"thinking":{"budget_tokens":1024}}`, want: false},
  1306. {name: "invalid json", body: `{not json`, want: false},
  1307. }
  1308. for _, tt := range tests {
  1309. t.Run(tt.name, func(t *testing.T) {
  1310. require.Equal(t, tt.want, OpenAIBodyHasThinkingEnabled([]byte(tt.body)))
  1311. })
  1312. }
  1313. }
  1314. func TestApplyThinkingEnabledFallback(t *testing.T) {
  1315. tests := []struct {
  1316. name string
  1317. effort *string
  1318. body string
  1319. model string
  1320. want *string
  1321. wantPassThr bool // 为 true 时 want 是传入 effort 原指针
  1322. }{
  1323. // effort 非 nil → 原值透传,不覆盖
  1324. {
  1325. name: "existing effort never overridden (kimi + thinking)",
  1326. effort: strPtr("medium"),
  1327. body: `{"thinking":{"type":"enabled"}}`,
  1328. model: "kimi-k2.6",
  1329. wantPassThr: true,
  1330. },
  1331. {
  1332. name: "existing low effort kept for deepseek",
  1333. effort: strPtr("low"),
  1334. body: `{"thinking":{"type":"enabled"}}`,
  1335. model: "deepseek-v4-pro",
  1336. wantPassThr: true,
  1337. },
  1338. // effort=nil + thinking enabled + passback-required 模型 → 填 high
  1339. {
  1340. name: "glm-5.1 + thinking enabled -> high",
  1341. effort: nil,
  1342. body: `{"thinking":{"type":"enabled"}}`,
  1343. model: "glm-5.1",
  1344. want: strPtr("high"),
  1345. },
  1346. {
  1347. name: "kimi-k2.6 + adaptive -> high",
  1348. effort: nil,
  1349. body: `{"thinking":{"type":"adaptive"}}`,
  1350. model: "kimi-k2.6",
  1351. want: strPtr("high"),
  1352. },
  1353. {
  1354. name: "MiniMax-M3 + enabled -> high",
  1355. effort: nil,
  1356. body: `{"thinking":{"type":"enabled"}}`,
  1357. model: "MiniMax-M3",
  1358. want: strPtr("high"),
  1359. },
  1360. // effort=nil + thinking disabled → nil
  1361. {
  1362. name: "glm + thinking disabled -> nil",
  1363. effort: nil,
  1364. body: `{"thinking":{"type":"disabled"}}`,
  1365. model: "glm-5.1",
  1366. want: nil,
  1367. },
  1368. {
  1369. name: "glm + no thinking field -> nil",
  1370. effort: nil,
  1371. body: `{"model":"glm-5.1"}`,
  1372. model: "glm-5.1",
  1373. want: nil,
  1374. },
  1375. // effort=nil + thinking enabled + non-passback → nil
  1376. {
  1377. name: "deepseek + thinking enabled -> nil (deepseek excluded)",
  1378. effort: nil,
  1379. body: `{"thinking":{"type":"enabled"}}`,
  1380. model: "deepseek-v4-pro",
  1381. want: nil,
  1382. },
  1383. {
  1384. name: "claude + thinking enabled -> nil (strict not passback)",
  1385. effort: nil,
  1386. body: `{"thinking":{"type":"enabled"}}`,
  1387. model: "claude-opus-4.6",
  1388. want: nil,
  1389. },
  1390. {
  1391. name: "gpt-5 + thinking enabled -> nil (unknown)",
  1392. effort: nil,
  1393. body: `{"thinking":{"type":"enabled"}}`,
  1394. model: "gpt-5.5",
  1395. want: nil,
  1396. },
  1397. }
  1398. for _, tt := range tests {
  1399. t.Run(tt.name, func(t *testing.T) {
  1400. got := ApplyThinkingEnabledFallback(tt.effort, []byte(tt.body), tt.model)
  1401. if tt.wantPassThr {
  1402. require.Same(t, tt.effort, got, "non-nil effort must be returned unchanged (same pointer)")
  1403. return
  1404. }
  1405. if tt.want == nil {
  1406. require.Nil(t, got)
  1407. return
  1408. }
  1409. require.NotNil(t, got)
  1410. require.Equal(t, *tt.want, *got)
  1411. })
  1412. }
  1413. }
  1414. func TestNormalizeGLMOpenAIReasoningEffort(t *testing.T) {
  1415. tests := []struct {
  1416. name string
  1417. model string
  1418. input string
  1419. wantApplied bool
  1420. wantPath string
  1421. wantValue string
  1422. wantUnchanged bool
  1423. }{
  1424. {
  1425. name: "flat xhigh maps to max",
  1426. model: "glm-5.2",
  1427. input: `{"model":"glm-5.2","reasoning_effort":"xhigh","messages":[]}`,
  1428. wantApplied: true,
  1429. wantPath: "reasoning_effort",
  1430. wantValue: "max",
  1431. },
  1432. {
  1433. name: "flat x-high maps to max",
  1434. model: "GLM-5.2",
  1435. input: `{"model":"glm-5.2","reasoning_effort":"x-high","messages":[]}`,
  1436. wantApplied: true,
  1437. wantPath: "reasoning_effort",
  1438. wantValue: "max",
  1439. },
  1440. {
  1441. name: "flat ultracode maps to max",
  1442. model: "glm-5.2",
  1443. input: `{"model":"glm-5.2","reasoning_effort":"ultracode","messages":[]}`,
  1444. wantApplied: true,
  1445. wantPath: "reasoning_effort",
  1446. wantValue: "max",
  1447. },
  1448. {
  1449. name: "flat medium maps to high",
  1450. model: "glm-5.2",
  1451. input: `{"model":"glm-5.2","reasoning_effort":"medium","messages":[]}`,
  1452. wantApplied: true,
  1453. wantPath: "reasoning_effort",
  1454. wantValue: "high",
  1455. },
  1456. {
  1457. name: "glm 5.2 low maps to high",
  1458. model: "glm-5.2",
  1459. input: `{"model":"glm-5.2","reasoning_effort":"low","messages":[]}`,
  1460. wantApplied: true,
  1461. wantPath: "reasoning_effort",
  1462. wantValue: "high",
  1463. },
  1464. {
  1465. name: "nested high case-normalizes",
  1466. model: "glm-5.2",
  1467. input: `{"model":"glm-5.2","reasoning":{"effort":"HIGH"},"messages":[]}`,
  1468. wantApplied: true,
  1469. wantPath: "reasoning.effort",
  1470. wantValue: "high",
  1471. },
  1472. {
  1473. name: "glm 5.3 native low unchanged",
  1474. model: "glm-5.3",
  1475. input: `{"model":"glm-5.3","reasoning_effort":"low","messages":[]}`,
  1476. wantApplied: false,
  1477. wantUnchanged: true,
  1478. },
  1479. {
  1480. name: "native max unchanged",
  1481. model: "glm-5.2",
  1482. input: `{"model":"glm-5.2","reasoning_effort":"max","messages":[]}`,
  1483. wantApplied: false,
  1484. wantUnchanged: true,
  1485. },
  1486. {
  1487. name: "non glm unchanged",
  1488. model: "deepseek-v4-pro",
  1489. input: `{"model":"deepseek-v4-pro","reasoning_effort":"xhigh","messages":[]}`,
  1490. wantApplied: false,
  1491. wantUnchanged: true,
  1492. },
  1493. {
  1494. name: "missing effort unchanged",
  1495. model: "glm-5.2",
  1496. input: `{"model":"glm-5.2","messages":[]}`,
  1497. wantApplied: false,
  1498. wantUnchanged: true,
  1499. },
  1500. {
  1501. name: "unknown effort unchanged",
  1502. model: "glm-5.2",
  1503. input: `{"model":"glm-5.2","reasoning_effort":"banana","messages":[]}`,
  1504. wantApplied: false,
  1505. wantUnchanged: true,
  1506. },
  1507. }
  1508. for _, tt := range tests {
  1509. t.Run(tt.name, func(t *testing.T) {
  1510. got, applied := NormalizeGLMOpenAIReasoningEffort([]byte(tt.input), tt.model)
  1511. require.Equal(t, tt.wantApplied, applied)
  1512. if tt.wantUnchanged {
  1513. require.Equal(t, tt.input, string(got))
  1514. return
  1515. }
  1516. require.Equal(t, tt.wantValue, gjson.GetBytes(got, tt.wantPath).String())
  1517. })
  1518. }
  1519. }