openai_codex_transform_test.go 69 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201
  1. package service
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "strings"
  6. "testing"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) {
  10. // 续链场景:保留 item_reference 与 id,但不再强制 store=true。
  11. reqBody := map[string]any{
  12. "model": "gpt-5.2",
  13. "input": []any{
  14. map[string]any{"type": "item_reference", "id": "ref1", "text": "x"},
  15. map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "ok", "id": "o1"},
  16. },
  17. "tool_choice": "auto",
  18. }
  19. applyCodexOAuthTransform(reqBody, false, false)
  20. // 未显式设置 store=true,默认为 false。
  21. store, ok := reqBody["store"].(bool)
  22. require.True(t, ok)
  23. require.False(t, store)
  24. input, ok := reqBody["input"].([]any)
  25. require.True(t, ok)
  26. require.Len(t, input, 2)
  27. // 校验 input[0] 为 map,避免断言失败导致测试中断。
  28. first, ok := input[0].(map[string]any)
  29. require.True(t, ok)
  30. require.Equal(t, "item_reference", first["type"])
  31. require.Equal(t, "ref1", first["id"])
  32. // 校验 input[1] 为 map,确保后续字段断言安全。
  33. second, ok := input[1].(map[string]any)
  34. require.True(t, ok)
  35. require.Equal(t, "o1", second["id"])
  36. require.Equal(t, "fc_1", second["call_id"])
  37. }
  38. func TestApplyCodexOAuthTransform_MessagesBridgePromptCacheKeyIsHeaderOnly(t *testing.T) {
  39. reqBody := map[string]any{
  40. "model": "gpt-5.5",
  41. "prompt_cache_key": "anthropic-metadata-session-1",
  42. "input": []any{
  43. map[string]any{
  44. "type": "message",
  45. "role": "developer",
  46. "content": []any{
  47. map[string]any{
  48. "type": "input_text",
  49. "text": openAICompatClaudeCodeTodoGuardMarker,
  50. },
  51. },
  52. },
  53. map[string]any{
  54. "type": "message",
  55. "role": "user",
  56. "content": "hello",
  57. },
  58. },
  59. }
  60. result := applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{
  61. SkipDefaultInstructions: true,
  62. PreserveToolCallIDs: true,
  63. })
  64. require.Equal(t, "anthropic-metadata-session-1", result.PromptCacheKey)
  65. require.True(t, result.Modified)
  66. require.NotContains(t, reqBody, "prompt_cache_key")
  67. }
  68. func TestApplyCodexOAuthTransform_ToolContinuationPreservesNativeMessageAndReasoningIDs(t *testing.T) {
  69. reqBody := map[string]any{
  70. "model": "gpt-5.2",
  71. "input": []any{
  72. map[string]any{"type": "message", "id": "msg_0", "role": "user", "content": "hi"},
  73. map[string]any{"type": "item_reference", "id": "rs_123"},
  74. },
  75. "tool_choice": "auto",
  76. }
  77. applyCodexOAuthTransform(reqBody, false, false)
  78. input, ok := reqBody["input"].([]any)
  79. require.True(t, ok)
  80. require.Len(t, input, 2)
  81. first, ok := input[0].(map[string]any)
  82. require.True(t, ok)
  83. require.Equal(t, "msg_0", first["id"])
  84. second, ok := input[1].(map[string]any)
  85. require.True(t, ok)
  86. require.Equal(t, "rs_123", second["id"])
  87. }
  88. func TestApplyCodexOAuthTransform_ToolContinuationNormalizesToolReferenceIDsOnly(t *testing.T) {
  89. reqBody := map[string]any{
  90. "model": "gpt-5.2",
  91. "input": []any{
  92. map[string]any{"type": "item_reference", "id": "call_1"},
  93. map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "ok"},
  94. },
  95. "tool_choice": "auto",
  96. }
  97. applyCodexOAuthTransform(reqBody, false, false)
  98. input, ok := reqBody["input"].([]any)
  99. require.True(t, ok)
  100. require.Len(t, input, 2)
  101. first, ok := input[0].(map[string]any)
  102. require.True(t, ok)
  103. require.Equal(t, "fc_1", first["id"])
  104. second, ok := input[1].(map[string]any)
  105. require.True(t, ok)
  106. require.Equal(t, "fc_1", second["call_id"])
  107. }
  108. func TestApplyCodexOAuthTransform_NormalizesIsolatedLegacyReferenceAcrossTurns(t *testing.T) {
  109. reqBody := map[string]any{
  110. "model": "gpt-5.2",
  111. "input": []any{
  112. map[string]any{"type": "item_reference", "id": "call_previous_turn"},
  113. map[string]any{"type": "item_reference", "id": "fc_remote_item"},
  114. map[string]any{"type": "item_reference", "id": "vendor_remote_item"},
  115. },
  116. }
  117. applyCodexOAuthTransform(reqBody, false, false)
  118. input, ok := reqBody["input"].([]any)
  119. require.True(t, ok)
  120. require.Len(t, input, 3)
  121. for i, expectedID := range []string{"fc_previous_turn", "fc_remote_item", "vendor_remote_item"} {
  122. item, itemOK := input[i].(map[string]any)
  123. require.True(t, itemOK)
  124. require.Equal(t, expectedID, item["id"])
  125. }
  126. }
  127. func TestApplyCodexOAuthTransform_BoundsLongCallIDsAndPreservesPairing(t *testing.T) {
  128. suffix := strings.Repeat("z", 62)
  129. for _, tc := range []struct {
  130. name string
  131. callID string
  132. outputCallID string
  133. }{
  134. {name: "non-native boundary id", callID: "call-" + strings.Repeat("x", 59), outputCallID: "call-" + strings.Repeat("x", 59)},
  135. {name: "overlong fc id", callID: "fc_" + strings.Repeat("y", 62), outputCallID: "fc_" + strings.Repeat("y", 62)},
  136. {name: "equivalent prefixes", callID: "call_" + suffix, outputCallID: "fc_" + suffix},
  137. } {
  138. t.Run(tc.name, func(t *testing.T) {
  139. reqBody := map[string]any{
  140. "model": "gpt-5.2",
  141. "input": []any{
  142. map[string]any{"type": "function_call", "call_id": tc.callID, "name": "shell"},
  143. map[string]any{"type": "function_call_output", "call_id": tc.outputCallID, "output": "done"},
  144. },
  145. }
  146. applyCodexOAuthTransform(reqBody, false, false)
  147. input, ok := reqBody["input"].([]any)
  148. require.True(t, ok)
  149. call, ok := input[0].(map[string]any)
  150. require.True(t, ok)
  151. output, ok := input[1].(map[string]any)
  152. require.True(t, ok)
  153. fixedCallID, ok := call["call_id"].(string)
  154. require.True(t, ok)
  155. require.LessOrEqual(t, len(fixedCallID), codexCallIDMaxLength)
  156. require.True(t, strings.HasPrefix(fixedCallID, codexCallIDPrefix))
  157. require.Equal(t, fixedCallID, output["call_id"])
  158. require.Equal(t, fixedCallID, normalizeCodexCallID(tc.callID))
  159. require.Equal(t, fixedCallID, normalizeCodexCallID(tc.outputCallID))
  160. })
  161. }
  162. }
  163. func TestApplyCodexOAuthTransform_PreservesCallIDsWithinLimitWhenRequested(t *testing.T) {
  164. // preserve 模式下 ≤64 字符的 id 必须原样透传(含 64 字符等长边界),
  165. // 不做任何前缀改写或压缩。
  166. for _, tc := range []struct {
  167. name string
  168. callID string
  169. }{
  170. {name: "anthropic toolu id", callID: "toolu_01ABCdefGHIjklMNOpqrsTUV"},
  171. {name: "boundary 64 chars", callID: "toolu_" + strings.Repeat("x", codexCallIDMaxLength-len("toolu_"))},
  172. } {
  173. t.Run(tc.name, func(t *testing.T) {
  174. require.LessOrEqual(t, len(tc.callID), codexCallIDMaxLength)
  175. reqBody := map[string]any{
  176. "model": "gpt-5.2",
  177. "input": []any{
  178. map[string]any{"type": "function_call", "call_id": tc.callID, "name": "shell"},
  179. map[string]any{"type": "function_call_output", "call_id": tc.callID, "output": "done"},
  180. },
  181. }
  182. applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{
  183. PreserveToolCallIDs: true,
  184. })
  185. input, ok := reqBody["input"].([]any)
  186. require.True(t, ok)
  187. call, ok := input[0].(map[string]any)
  188. require.True(t, ok)
  189. output, ok := input[1].(map[string]any)
  190. require.True(t, ok)
  191. require.Equal(t, tc.callID, call["call_id"])
  192. require.Equal(t, tc.callID, output["call_id"])
  193. })
  194. }
  195. }
  196. func TestApplyCodexOAuthTransform_CompactsOverlongCallIDsWhenPreserveRequested(t *testing.T) {
  197. // preserve 模式下超过 64 字符的 id 若原样透传,上游必然 400
  198. // ("Invalid 'input[N].call_id': string too long"),需退回确定性压缩;
  199. // function_call 与 function_call_output 两侧压缩结果一致,配对保持。
  200. callID := "srvtoolu_" + strings.Repeat("x", 69) // 78 字符,对应生产环境真实报错长度
  201. require.Len(t, callID, 78)
  202. reqBody := map[string]any{
  203. "model": "gpt-5.2",
  204. "input": []any{
  205. map[string]any{"type": "function_call", "call_id": callID, "name": "shell"},
  206. map[string]any{"type": "function_call_output", "call_id": callID, "output": "done"},
  207. },
  208. }
  209. applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{
  210. PreserveToolCallIDs: true,
  211. })
  212. input, ok := reqBody["input"].([]any)
  213. require.True(t, ok)
  214. call, ok := input[0].(map[string]any)
  215. require.True(t, ok)
  216. output, ok := input[1].(map[string]any)
  217. require.True(t, ok)
  218. compacted, ok := call["call_id"].(string)
  219. require.True(t, ok)
  220. require.Len(t, compacted, codexCallIDMaxLength)
  221. require.True(t, strings.HasPrefix(compacted, codexCallIDPrefix))
  222. require.Equal(t, compacted, output["call_id"], "两侧压缩结果必须一致以保持配对")
  223. require.Equal(t, normalizeCodexCallID(callID), compacted, "压缩必须是确定性的")
  224. }
  225. func TestApplyCodexOAuthTransform_ToolSearchOutputPreservesCallID(t *testing.T) {
  226. reqBody := map[string]any{
  227. "model": "gpt-5.2",
  228. "input": []any{
  229. map[string]any{"type": "tool_search_output", "call_id": "call_1", "output": "ok"},
  230. },
  231. }
  232. applyCodexOAuthTransform(reqBody, false, false)
  233. input, ok := reqBody["input"].([]any)
  234. require.True(t, ok)
  235. require.Len(t, input, 1)
  236. first, ok := input[0].(map[string]any)
  237. require.True(t, ok)
  238. require.Equal(t, "tool_search_output", first["type"])
  239. require.Equal(t, "tsc_1", first["call_id"])
  240. }
  241. func TestApplyCodexOAuthTransform_CustomAndMCPToolOutputsPreserveCallID(t *testing.T) {
  242. reqBody := map[string]any{
  243. "model": "gpt-5.2",
  244. "input": []any{
  245. map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom", "output": "ok"},
  246. map[string]any{"type": "mcp_tool_call_output", "call_id": "call_mcp", "output": "ok"},
  247. },
  248. }
  249. applyCodexOAuthTransform(reqBody, false, false)
  250. input, ok := reqBody["input"].([]any)
  251. require.True(t, ok)
  252. require.Len(t, input, 2)
  253. first, ok := input[0].(map[string]any)
  254. require.True(t, ok)
  255. require.Equal(t, "ctc_custom", first["call_id"])
  256. second, ok := input[1].(map[string]any)
  257. require.True(t, ok)
  258. require.Equal(t, "fc_mcp", second["call_id"])
  259. }
  260. func TestApplyCodexOAuthTransform_NormalizesNativeToolCallPairsByType(t *testing.T) {
  261. reqBody := map[string]any{
  262. "model": "gpt-5.6-sol",
  263. "input": []any{
  264. map[string]any{"type": "custom_tool_call", "id": "fc_custom", "call_id": "call_custom", "name": "apply_patch"},
  265. map[string]any{"type": "custom_tool_call_output", "call_id": "fc_custom", "output": "done"},
  266. map[string]any{"type": "tool_search_call", "id": "fc_search", "call_id": "call_search"},
  267. map[string]any{"type": "tool_search_output", "call_id": "fc_search", "output": "result"},
  268. },
  269. }
  270. applyCodexOAuthTransform(reqBody, false, false)
  271. input, ok := reqBody["input"].([]any)
  272. require.True(t, ok)
  273. require.Len(t, input, 4)
  274. custom, ok := input[0].(map[string]any)
  275. require.True(t, ok)
  276. customOutput, ok := input[1].(map[string]any)
  277. require.True(t, ok)
  278. search, ok := input[2].(map[string]any)
  279. require.True(t, ok)
  280. searchOutput, ok := input[3].(map[string]any)
  281. require.True(t, ok)
  282. require.NotContains(t, custom, "id", "the invalid replay item id must be removed, not fabricated")
  283. require.Equal(t, "ctc_custom", custom["call_id"])
  284. require.Equal(t, custom["call_id"], customOutput["call_id"])
  285. require.NotContains(t, search, "id", "the invalid replay item id must be removed, not fabricated")
  286. require.Equal(t, "tsc_search", search["call_id"])
  287. require.Equal(t, search["call_id"], searchOutput["call_id"])
  288. }
  289. func TestApplyCodexOAuthTransform_PreservesNativeCallIDsWhenRequested(t *testing.T) {
  290. reqBody := map[string]any{
  291. "model": "gpt-5.6-sol",
  292. "input": []any{
  293. map[string]any{"type": "custom_tool_call", "id": "ctc_custom", "call_id": "call_custom", "name": "apply_patch"},
  294. map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom", "output": "done"},
  295. map[string]any{"type": "tool_search_call", "id": "tsc_search", "call_id": "call_search"},
  296. map[string]any{"type": "tool_search_output", "call_id": "call_search", "output": "result"},
  297. },
  298. }
  299. applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{PreserveToolCallIDs: true})
  300. input, ok := reqBody["input"].([]any)
  301. require.True(t, ok)
  302. require.Len(t, input, 4)
  303. first, ok := input[0].(map[string]any)
  304. require.True(t, ok)
  305. second, ok := input[1].(map[string]any)
  306. require.True(t, ok)
  307. third, ok := input[2].(map[string]any)
  308. require.True(t, ok)
  309. fourth, ok := input[3].(map[string]any)
  310. require.True(t, ok)
  311. require.Equal(t, "ctc_custom", first["id"])
  312. require.Equal(t, "call_custom", first["call_id"])
  313. require.Equal(t, "call_custom", second["call_id"])
  314. require.Equal(t, "tsc_search", third["id"])
  315. require.Equal(t, "call_search", third["call_id"])
  316. require.Equal(t, "call_search", fourth["call_id"])
  317. }
  318. func TestApplyCodexOAuthTransform_BoundsEquivalentNativeToolCallIDsWithPairing(t *testing.T) {
  319. suffix := strings.Repeat("x", 70)
  320. reqBody := map[string]any{
  321. "model": "gpt-5.6-sol",
  322. "input": []any{
  323. map[string]any{"type": "custom_tool_call", "call_id": "call_" + suffix, "name": "apply_patch"},
  324. map[string]any{"type": "custom_tool_call_output", "call_id": "fc_" + suffix, "output": "done"},
  325. },
  326. }
  327. applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{PreserveToolCallIDs: true})
  328. input, ok := reqBody["input"].([]any)
  329. require.True(t, ok)
  330. require.Len(t, input, 2)
  331. first, ok := input[0].(map[string]any)
  332. require.True(t, ok)
  333. second, ok := input[1].(map[string]any)
  334. require.True(t, ok)
  335. callID, ok := first["call_id"].(string)
  336. require.True(t, ok)
  337. outputCallID, ok := second["call_id"].(string)
  338. require.True(t, ok)
  339. require.LessOrEqual(t, len(callID), codexCallIDMaxLength)
  340. require.True(t, strings.HasPrefix(callID, "ctc_"))
  341. require.Equal(t, callID, outputCallID)
  342. }
  343. func TestApplyCodexOAuthTransform_ImageAndWebSearchCallsDoNotGainCallID(t *testing.T) {
  344. reqBody := map[string]any{
  345. "model": "gpt-5.2",
  346. "input": []any{
  347. map[string]any{"type": "image_generation_call", "id": "ig_123", "status": "completed"},
  348. map[string]any{"type": "web_search_call", "call_id": "call_bad", "status": "completed"},
  349. },
  350. "tool_choice": "auto",
  351. }
  352. applyCodexOAuthTransform(reqBody, false, false)
  353. input, ok := reqBody["input"].([]any)
  354. require.True(t, ok)
  355. require.Len(t, input, 2)
  356. first, ok := input[0].(map[string]any)
  357. require.True(t, ok)
  358. require.Equal(t, "ig_123", first["id"])
  359. _, hasCallID := first["call_id"]
  360. require.False(t, hasCallID)
  361. second, ok := input[1].(map[string]any)
  362. require.True(t, ok)
  363. _, hasCallID = second["call_id"]
  364. require.False(t, hasCallID)
  365. }
  366. func TestApplyCodexOAuthTransform_ConvertsToolRoleMessageToFunctionCallOutput(t *testing.T) {
  367. reqBody := map[string]any{
  368. "model": "gpt-5.4",
  369. "input": []any{
  370. map[string]any{
  371. "type": "message",
  372. "role": "tool",
  373. "tool_call_id": "call_1",
  374. "content": "ok",
  375. },
  376. },
  377. }
  378. applyCodexOAuthTransform(reqBody, true, false)
  379. input, ok := reqBody["input"].([]any)
  380. require.True(t, ok)
  381. require.Len(t, input, 1)
  382. item, ok := input[0].(map[string]any)
  383. require.True(t, ok)
  384. require.Equal(t, "function_call_output", item["type"])
  385. require.Equal(t, "fc_1", item["call_id"])
  386. require.Equal(t, "ok", item["output"])
  387. _, hasRole := item["role"]
  388. require.False(t, hasRole)
  389. }
  390. func TestApplyCodexOAuthTransform_StringifiesNonStringMessageContentText(t *testing.T) {
  391. reqBody := map[string]any{
  392. "model": "gpt-5.4",
  393. "input": []any{
  394. map[string]any{
  395. "type": "message",
  396. "role": "user",
  397. "content": []any{
  398. map[string]any{"type": "input_text", "text": []any{"a", "b"}},
  399. },
  400. },
  401. },
  402. }
  403. applyCodexOAuthTransform(reqBody, true, false)
  404. input, ok := reqBody["input"].([]any)
  405. require.True(t, ok)
  406. item, ok := input[0].(map[string]any)
  407. require.True(t, ok)
  408. content, ok := item["content"].([]any)
  409. require.True(t, ok)
  410. part, ok := content[0].(map[string]any)
  411. require.True(t, ok)
  412. require.Equal(t, `["a","b"]`, part["text"])
  413. }
  414. func TestApplyCodexOAuthTransform_PreservesAllowedTools(t *testing.T) {
  415. for _, placement := range []string{"top_level", "additional_tools"} {
  416. for _, mode := range []string{"auto", "required"} {
  417. t.Run(placement+"/"+mode, func(t *testing.T) {
  418. decision := map[string]any{"type": "function", "name": "ProbeAccept"}
  419. choice := map[string]any{
  420. "type": "allowed_tools", "mode": mode,
  421. "tools": []any{map[string]any{"type": "function", "name": "ProbeAccept"}},
  422. }
  423. tools := []any{
  424. map[string]any{"type": "function", "name": "ProbeBase"},
  425. map[string]any{"type": "web_search"},
  426. map[string]any{"type": "image_generation"},
  427. }
  428. input := []any{map[string]any{"type": "message", "role": "user", "content": "probe"}}
  429. if placement == "top_level" {
  430. tools = append(tools, decision)
  431. } else {
  432. input = append(input, map[string]any{
  433. "type": "additional_tools", "role": "developer", "tools": []any{decision},
  434. })
  435. }
  436. reqBody := map[string]any{"tools": tools, "input": input, "tool_choice": choice}
  437. before, err := json.Marshal(reqBody)
  438. require.NoError(t, err)
  439. reqBody["model"] = "gpt-6-astra"
  440. result := applyCodexOAuthTransform(reqBody, true, false)
  441. require.NoError(t, result.Error)
  442. after, err := json.Marshal(map[string]any{
  443. "tools": reqBody["tools"], "input": reqBody["input"], "tool_choice": reqBody["tool_choice"],
  444. })
  445. require.NoError(t, err)
  446. require.JSONEq(t, string(before), string(after))
  447. })
  448. }
  449. }
  450. }
  451. func TestNormalizeCodexToolChoice_InvalidAllowedToolsNeverBecomesAuto(t *testing.T) {
  452. for _, choice := range []map[string]any{
  453. {"type": "allowed_tools"},
  454. {"type": "allowed_tools", "mode": "invalid", "tools": []any{}},
  455. {"type": "allowed_tools", "mode": "required", "tools": "invalid"},
  456. {"type": "allowed_tools", "mode": "required", "tools": []any{map[string]any{"type": "function", "name": "missing"}}},
  457. } {
  458. reqBody := map[string]any{"tool_choice": choice}
  459. require.False(t, normalizeCodexToolChoice(reqBody))
  460. // The upstream owns schema validation. A malformed restriction must never
  461. // silently become permission to call every supplied tool.
  462. require.Equal(t, choice, reqBody["tool_choice"])
  463. }
  464. }
  465. func TestApplyCodexOAuthTransform_AllowedToolsKeepsReservedNameReferences(t *testing.T) {
  466. declaration := map[string]any{"type": "function", "name": "python"}
  467. reference := map[string]any{"type": "function", "name": "python"}
  468. choice := map[string]any{"type": "allowed_tools", "mode": "required", "tools": []any{reference}}
  469. reqBody := map[string]any{
  470. "model": "gpt-6-astra", "tools": []any{declaration}, "tool_choice": choice,
  471. }
  472. result := applyCodexOAuthTransform(reqBody, true, false)
  473. require.NoError(t, result.Error)
  474. require.Equal(t, choice, reqBody["tool_choice"])
  475. require.Equal(t, codexPythonToolAlias, declaration["name"])
  476. require.Equal(t, codexPythonToolAlias, reference["name"])
  477. require.Equal(t, "python", result.ToolNameReverse[codexPythonToolAlias])
  478. }
  479. func TestApplyCodexOAuthTransform_DowngradesUnknownToolChoice(t *testing.T) {
  480. reqBody := map[string]any{
  481. "model": "gpt-5.4",
  482. "tools": []any{
  483. map[string]any{"type": "function", "name": "shell"},
  484. },
  485. "tool_choice": map[string]any{"type": "custom"},
  486. }
  487. applyCodexOAuthTransform(reqBody, true, false)
  488. require.Equal(t, "auto", reqBody["tool_choice"])
  489. }
  490. func TestApplyCodexOAuthTransform_PreservesKnownToolChoice(t *testing.T) {
  491. reqBody := map[string]any{
  492. "model": "gpt-5.4",
  493. "tools": []any{
  494. map[string]any{"type": "custom", "name": "shell"},
  495. },
  496. "tool_choice": map[string]any{"type": "custom"},
  497. }
  498. applyCodexOAuthTransform(reqBody, true, false)
  499. choice, ok := reqBody["tool_choice"].(map[string]any)
  500. require.True(t, ok)
  501. require.Equal(t, "custom", choice["type"])
  502. }
  503. func TestApplyCodexOAuthTransform_NormalizesLegacyFunctionToolChoice(t *testing.T) {
  504. reqBody := map[string]any{
  505. "model": "gpt-5.4",
  506. "tools": []any{
  507. map[string]any{"type": "function", "name": "shell"},
  508. },
  509. "tool_choice": map[string]any{
  510. "type": "function",
  511. "function": map[string]any{"name": "shell"},
  512. },
  513. }
  514. applyCodexOAuthTransform(reqBody, true, false)
  515. choice, ok := reqBody["tool_choice"].(map[string]any)
  516. require.True(t, ok)
  517. require.Equal(t, "function", choice["type"])
  518. require.Equal(t, "shell", choice["name"])
  519. require.NotContains(t, choice, "function")
  520. }
  521. func TestApplyCodexOAuthTransform_DowngradesMissingFunctionToolChoice(t *testing.T) {
  522. reqBody := map[string]any{
  523. "model": "gpt-5.4",
  524. "tools": []any{
  525. map[string]any{"type": "function", "name": "shell"},
  526. },
  527. "tool_choice": map[string]any{
  528. "type": "function",
  529. "function": map[string]any{"name": "missing"},
  530. },
  531. }
  532. applyCodexOAuthTransform(reqBody, true, false)
  533. require.Equal(t, "auto", reqBody["tool_choice"])
  534. }
  535. func TestApplyCodexOAuthTransform_AddsFallbackNameForFunctionCallInput(t *testing.T) {
  536. reqBody := map[string]any{
  537. "model": "gpt-5.4",
  538. "input": []any{
  539. map[string]any{"type": "message", "role": "user", "content": "run tool"},
  540. map[string]any{"type": "function_call", "call_id": "call_1", "arguments": "{}"},
  541. },
  542. }
  543. applyCodexOAuthTransform(reqBody, true, false)
  544. input, ok := reqBody["input"].([]any)
  545. require.True(t, ok)
  546. require.Len(t, input, 2)
  547. item, ok := input[1].(map[string]any)
  548. require.True(t, ok)
  549. require.Equal(t, "function_call", item["type"])
  550. require.Equal(t, "tool", item["name"])
  551. require.Equal(t, "fc_1", item["call_id"])
  552. }
  553. func TestApplyCodexOAuthTransform_PreservesFunctionCallInputName(t *testing.T) {
  554. reqBody := map[string]any{
  555. "model": "gpt-5.4",
  556. "input": []any{
  557. map[string]any{"type": "custom_tool_call", "call_id": "call_1", "name": "shell", "input": "pwd"},
  558. },
  559. }
  560. applyCodexOAuthTransform(reqBody, true, false)
  561. input, ok := reqBody["input"].([]any)
  562. require.True(t, ok)
  563. require.Len(t, input, 1)
  564. item, ok := input[0].(map[string]any)
  565. require.True(t, ok)
  566. require.Equal(t, "shell", item["name"])
  567. require.Equal(t, "ctc_1", item["call_id"])
  568. }
  569. func TestApplyCodexOAuthTransform_PreservesMCPToolCallIDAndName(t *testing.T) {
  570. reqBody := map[string]any{
  571. "model": "gpt-5.4",
  572. "input": []any{
  573. map[string]any{
  574. "type": "mcp_tool_call",
  575. "call_id": "call_abc",
  576. "name": "remote_tool",
  577. "arguments": "{}",
  578. },
  579. },
  580. }
  581. applyCodexOAuthTransform(reqBody, true, false)
  582. input, ok := reqBody["input"].([]any)
  583. require.True(t, ok)
  584. require.Len(t, input, 1)
  585. item, ok := input[0].(map[string]any)
  586. require.True(t, ok)
  587. require.Equal(t, "mcp_tool_call", item["type"])
  588. require.Equal(t, "remote_tool", item["name"])
  589. require.Equal(t, "fc_abc", item["call_id"])
  590. }
  591. func TestCodexInputItemRequiresNameTypesAllowCallID(t *testing.T) {
  592. for _, typ := range []string{"function_call", "custom_tool_call", "mcp_tool_call"} {
  593. require.True(t, codexInputItemRequiresName(typ), typ)
  594. require.True(t, isCodexToolCallItemType(typ), typ)
  595. }
  596. }
  597. func TestApplyCodexOAuthTransform_ExplicitStoreFalsePreserved(t *testing.T) {
  598. // 续链场景:显式 store=false 不再强制为 true,保持 false。
  599. reqBody := map[string]any{
  600. "model": "gpt-5.1",
  601. "store": false,
  602. "input": []any{
  603. map[string]any{"type": "function_call_output", "call_id": "call_1"},
  604. },
  605. "tool_choice": "auto",
  606. }
  607. applyCodexOAuthTransform(reqBody, false, false)
  608. store, ok := reqBody["store"].(bool)
  609. require.True(t, ok)
  610. require.False(t, store)
  611. }
  612. func TestApplyCodexOAuthTransform_ExplicitStoreTrueForcedFalse(t *testing.T) {
  613. // 显式 store=true 也会强制为 false。
  614. reqBody := map[string]any{
  615. "model": "gpt-5.1",
  616. "store": true,
  617. "input": []any{
  618. map[string]any{"type": "function_call_output", "call_id": "call_1"},
  619. },
  620. "tool_choice": "auto",
  621. }
  622. applyCodexOAuthTransform(reqBody, false, false)
  623. store, ok := reqBody["store"].(bool)
  624. require.True(t, ok)
  625. require.False(t, store)
  626. }
  627. func TestApplyCodexOAuthTransform_CompactForcesNonStreaming(t *testing.T) {
  628. reqBody := map[string]any{
  629. "model": "gpt-5.1-codex",
  630. "store": true,
  631. "stream": true,
  632. }
  633. result := applyCodexOAuthTransform(reqBody, true, true)
  634. _, hasStore := reqBody["store"]
  635. require.False(t, hasStore)
  636. _, hasStream := reqBody["stream"]
  637. require.False(t, hasStream)
  638. require.True(t, result.Modified)
  639. }
  640. func TestApplyCodexOAuthTransform_NonContinuationDefaultsStoreFalseAndStripsIDs(t *testing.T) {
  641. // 非续链场景:未设置 store 时默认 false,并移除 input 中的 id。
  642. reqBody := map[string]any{
  643. "model": "gpt-5.1",
  644. "input": []any{
  645. map[string]any{"type": "text", "id": "t1", "text": "hi"},
  646. },
  647. }
  648. applyCodexOAuthTransform(reqBody, false, false)
  649. store, ok := reqBody["store"].(bool)
  650. require.True(t, ok)
  651. require.False(t, store)
  652. input, ok := reqBody["input"].([]any)
  653. require.True(t, ok)
  654. require.Len(t, input, 1)
  655. // 校验 input[0] 为 map,避免类型不匹配触发 errcheck。
  656. item, ok := input[0].(map[string]any)
  657. require.True(t, ok)
  658. _, hasID := item["id"]
  659. require.False(t, hasID)
  660. }
  661. func TestFilterCodexInput_RemovesItemReferenceWhenNotPreserved(t *testing.T) {
  662. input := []any{
  663. map[string]any{"type": "item_reference", "id": "ref1"},
  664. map[string]any{"type": "text", "id": "t1", "text": "hi"},
  665. }
  666. filtered := filterCodexInput(input, false)
  667. require.Len(t, filtered, 1)
  668. // 校验 filtered[0] 为 map,确保字段检查可靠。
  669. item, ok := filtered[0].(map[string]any)
  670. require.True(t, ok)
  671. require.Equal(t, "text", item["type"])
  672. _, hasID := item["id"]
  673. require.False(t, hasID)
  674. }
  675. func TestApplyCodexOAuthTransform_NormalizeCodexTools_PreservesResponsesFunctionTools(t *testing.T) {
  676. reqBody := map[string]any{
  677. "model": "gpt-5.1",
  678. "tools": []any{
  679. map[string]any{
  680. "type": "function",
  681. "name": "bash",
  682. "description": "desc",
  683. "parameters": map[string]any{"type": "object"},
  684. },
  685. map[string]any{
  686. "type": "function",
  687. "function": nil,
  688. },
  689. },
  690. }
  691. applyCodexOAuthTransform(reqBody, false, false)
  692. tools, ok := reqBody["tools"].([]any)
  693. require.True(t, ok)
  694. require.Len(t, tools, 1)
  695. first, ok := tools[0].(map[string]any)
  696. require.True(t, ok)
  697. require.Equal(t, "function", first["type"])
  698. require.Equal(t, "bash", first["name"])
  699. }
  700. func TestNormalizeOpenAIResponsesImageGenerationTools_RewritesLegacyFields(t *testing.T) {
  701. reqBody := map[string]any{
  702. "tools": []any{
  703. map[string]any{
  704. "type": "image_generation",
  705. "format": "png",
  706. "compression": 60,
  707. },
  708. },
  709. }
  710. modified := normalizeOpenAIResponsesImageGenerationTools(reqBody)
  711. require.True(t, modified)
  712. tools, ok := reqBody["tools"].([]any)
  713. require.True(t, ok)
  714. first, ok := tools[0].(map[string]any)
  715. require.True(t, ok)
  716. require.Equal(t, "png", first["output_format"])
  717. require.Equal(t, 60, first["output_compression"])
  718. _, hasFormat := first["format"]
  719. require.False(t, hasFormat)
  720. _, hasCompression := first["compression"]
  721. require.False(t, hasCompression)
  722. }
  723. func TestEnsureOpenAIResponsesImageGenerationTool_NoTools(t *testing.T) {
  724. reqBody := map[string]any{
  725. "model": "gpt-5.4",
  726. "input": "draw a cat",
  727. }
  728. modified := ensureOpenAIResponsesImageGenerationTool(reqBody)
  729. require.True(t, modified)
  730. tools, ok := reqBody["tools"].([]any)
  731. require.True(t, ok)
  732. require.Len(t, tools, 1)
  733. tool, ok := tools[0].(map[string]any)
  734. require.True(t, ok)
  735. require.Equal(t, "image_generation", tool["type"])
  736. require.Equal(t, "png", tool["output_format"])
  737. }
  738. func TestEnsureOpenAIResponsesImageGenerationTool_SkipsSpark(t *testing.T) {
  739. reqBody := map[string]any{
  740. "model": "gpt-5.3-codex-spark",
  741. "input": "draw a cat",
  742. }
  743. modified := ensureOpenAIResponsesImageGenerationTool(reqBody)
  744. require.False(t, modified)
  745. require.NotContains(t, reqBody, "tools")
  746. }
  747. func TestEnsureOpenAIResponsesImageGenerationTool_AppendsToExistingTools(t *testing.T) {
  748. reqBody := map[string]any{
  749. "model": "gpt-5.4",
  750. "tools": []any{
  751. map[string]any{"type": "web_search"},
  752. },
  753. }
  754. modified := ensureOpenAIResponsesImageGenerationTool(reqBody)
  755. require.True(t, modified)
  756. tools, ok := reqBody["tools"].([]any)
  757. require.True(t, ok)
  758. require.Len(t, tools, 2)
  759. first, ok := tools[0].(map[string]any)
  760. require.True(t, ok)
  761. require.Equal(t, "web_search", first["type"])
  762. second, ok := tools[1].(map[string]any)
  763. require.True(t, ok)
  764. require.Equal(t, "image_generation", second["type"])
  765. require.Equal(t, "png", second["output_format"])
  766. }
  767. func TestEnsureOpenAIResponsesImageGenerationTool_PreservesExistingImageTool(t *testing.T) {
  768. reqBody := map[string]any{
  769. "model": "gpt-5.4",
  770. "tools": []any{
  771. map[string]any{"type": "image_generation", "output_format": "webp"},
  772. map[string]any{"type": "web_search"},
  773. },
  774. }
  775. modified := ensureOpenAIResponsesImageGenerationTool(reqBody)
  776. require.False(t, modified)
  777. tools, ok := reqBody["tools"].([]any)
  778. require.True(t, ok)
  779. require.Len(t, tools, 2)
  780. tool, ok := tools[0].(map[string]any)
  781. require.True(t, ok)
  782. require.Equal(t, "webp", tool["output_format"])
  783. }
  784. func TestEnsureOpenAIResponsesImageGenerationTool_PreservesImageGenNamespace(t *testing.T) {
  785. tests := []struct {
  786. name string
  787. reqBody map[string]any
  788. }{
  789. {
  790. name: "top-level tools",
  791. reqBody: map[string]any{
  792. "model": "gpt-5.5",
  793. "tools": []any{
  794. map[string]any{
  795. "type": "namespace",
  796. "name": "image_gen",
  797. "tools": []any{
  798. map[string]any{"type": "function", "name": "imagegen"},
  799. },
  800. },
  801. },
  802. },
  803. },
  804. {
  805. name: "responses lite additional_tools",
  806. reqBody: map[string]any{
  807. "model": "gpt-5.5",
  808. "input": []any{
  809. map[string]any{
  810. "type": "additional_tools",
  811. "tools": []any{
  812. map[string]any{
  813. "type": "namespace",
  814. "name": "image_gen",
  815. "tools": []any{
  816. map[string]any{"type": "function", "name": "imagegen"},
  817. },
  818. },
  819. },
  820. },
  821. },
  822. },
  823. },
  824. }
  825. for _, tt := range tests {
  826. t.Run(tt.name, func(t *testing.T) {
  827. require.True(t, hasOpenAIImageGenerationTool(tt.reqBody))
  828. modified := ensureOpenAIResponsesImageGenerationTool(tt.reqBody)
  829. require.False(t, modified)
  830. tools, _ := tt.reqBody["tools"].([]any)
  831. for _, rawTool := range tools {
  832. tool, ok := rawTool.(map[string]any)
  833. require.True(t, ok)
  834. require.NotEqual(t, "image_generation", firstNonEmptyString(tool["type"]))
  835. }
  836. })
  837. }
  838. }
  839. func TestCodexImageGenerationBridge_PreservesClientImageFunctionTools(t *testing.T) {
  840. tests := []struct {
  841. name string
  842. reqBody map[string]any
  843. wantClient bool
  844. }{
  845. {
  846. name: "flat image_gen function",
  847. reqBody: map[string]any{
  848. "model": "gpt-5.5",
  849. "input": "draw a cat",
  850. "tools": []any{
  851. map[string]any{"type": "function", "name": "image_gen.imagegen"},
  852. },
  853. },
  854. wantClient: true,
  855. },
  856. {
  857. name: "nested image_gen function",
  858. reqBody: map[string]any{
  859. "model": "gpt-5.5",
  860. "input": "draw a cat",
  861. "tools": []any{
  862. map[string]any{
  863. "type": "function",
  864. "function": map[string]any{
  865. "name": "image_gen.imagegen",
  866. },
  867. },
  868. },
  869. },
  870. wantClient: true,
  871. },
  872. {
  873. name: "similar function name still receives hosted bridge",
  874. reqBody: map[string]any{
  875. "model": "gpt-5.5",
  876. "input": "draw a cat",
  877. "tools": []any{
  878. map[string]any{"type": "function", "name": "image_gen.imagegenerator"},
  879. },
  880. },
  881. wantClient: false,
  882. },
  883. }
  884. for _, tt := range tests {
  885. t.Run(tt.name, func(t *testing.T) {
  886. tt.reqBody["instructions"] = "existing instructions"
  887. require.Equal(t, tt.wantClient, hasCodexImageGenerationFunctionTool(tt.reqBody))
  888. toolModified := ensureOpenAIResponsesImageGenerationTool(tt.reqBody)
  889. choiceModified := ensureOpenAIResponsesImageGenerationToolChoiceAuto(tt.reqBody)
  890. instructionsModified := applyCodexImageGenerationBridgeInstructions(tt.reqBody)
  891. require.Equal(t, !tt.wantClient, toolModified)
  892. require.Equal(t, !tt.wantClient, choiceModified)
  893. require.Equal(t, !tt.wantClient, instructionsModified)
  894. hasHostedTool := false
  895. tools, _ := tt.reqBody["tools"].([]any)
  896. for _, rawTool := range tools {
  897. tool, ok := rawTool.(map[string]any)
  898. if ok && firstNonEmptyString(tool["type"]) == "image_generation" {
  899. hasHostedTool = true
  900. }
  901. }
  902. require.Equal(t, !tt.wantClient, hasHostedTool)
  903. if tt.wantClient {
  904. require.NotContains(t, tt.reqBody, "tool_choice")
  905. require.Equal(t, "existing instructions", tt.reqBody["instructions"])
  906. } else {
  907. require.Equal(t, "auto", tt.reqBody["tool_choice"])
  908. require.Contains(t, tt.reqBody["instructions"], codexImageGenerationBridgeMarker)
  909. }
  910. })
  911. }
  912. }
  913. func TestApplyCodexImageGenerationBridgeInstructions_AppendsBridgeOnce(t *testing.T) {
  914. reqBody := map[string]any{
  915. "model": "gpt-5.4",
  916. "instructions": "existing instructions",
  917. "tools": []any{
  918. map[string]any{"type": "image_generation", "output_format": "png"},
  919. },
  920. }
  921. modified := applyCodexImageGenerationBridgeInstructions(reqBody)
  922. require.True(t, modified)
  923. instructions, ok := reqBody["instructions"].(string)
  924. require.True(t, ok)
  925. require.Contains(t, instructions, "existing instructions")
  926. require.Contains(t, instructions, codexImageGenerationBridgeMarker)
  927. require.Contains(t, instructions, "Responses native `image_generation` tool")
  928. modified = applyCodexImageGenerationBridgeInstructions(reqBody)
  929. require.False(t, modified)
  930. }
  931. func TestApplyCodexImageGenerationBridgeInstructions_SkipsSpark(t *testing.T) {
  932. reqBody := map[string]any{
  933. "model": "gpt-5.3-codex-spark",
  934. "instructions": "existing instructions",
  935. "tools": []any{
  936. map[string]any{"type": "image_generation", "output_format": "png"},
  937. },
  938. }
  939. modified := applyCodexImageGenerationBridgeInstructions(reqBody)
  940. require.False(t, modified)
  941. require.Equal(t, "existing instructions", reqBody["instructions"])
  942. }
  943. func TestApplyCodexImageGenerationBridgeInstructions_SkipsWithoutImageTool(t *testing.T) {
  944. reqBody := map[string]any{
  945. "instructions": "existing instructions",
  946. "tools": []any{
  947. map[string]any{"type": "web_search"},
  948. },
  949. }
  950. modified := applyCodexImageGenerationBridgeInstructions(reqBody)
  951. require.False(t, modified)
  952. require.Equal(t, "existing instructions", reqBody["instructions"])
  953. }
  954. func TestValidateCodexSparkInputRejectsInputImage(t *testing.T) {
  955. reqBody := map[string]any{
  956. "model": "gpt-5.3-codex-spark",
  957. "input": []any{
  958. map[string]any{
  959. "role": "user",
  960. "content": []any{
  961. map[string]any{"type": "input_text", "text": "describe"},
  962. map[string]any{"type": "input_image", "image_url": "data:image/png;base64,aGVsbG8="},
  963. },
  964. },
  965. },
  966. }
  967. err := validateCodexSparkInput(reqBody, "gpt-5.3-codex-spark")
  968. require.Error(t, err)
  969. require.Contains(t, err.Error(), "does not support image input")
  970. }
  971. func TestValidateCodexSparkInputRejectsChatImageURL(t *testing.T) {
  972. reqBody := map[string]any{
  973. "model": "gpt-5.3-codex-spark",
  974. "messages": []any{
  975. map[string]any{
  976. "role": "user",
  977. "content": []any{
  978. map[string]any{"type": "text", "text": "describe"},
  979. map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,aGVsbG8="}},
  980. },
  981. },
  982. },
  983. }
  984. err := validateCodexSparkInput(reqBody, "gpt-5.3-codex-spark")
  985. require.Error(t, err)
  986. }
  987. func TestValidateCodexSparkInputAllowsTextOnly(t *testing.T) {
  988. reqBody := map[string]any{
  989. "model": "gpt-5.3-codex-spark",
  990. "input": []any{
  991. map[string]any{
  992. "role": "user",
  993. "content": []any{
  994. map[string]any{"type": "input_text", "text": "hello"},
  995. },
  996. },
  997. },
  998. }
  999. require.NoError(t, validateCodexSparkInput(reqBody, "gpt-5.3-codex-spark"))
  1000. }
  1001. func TestApplyCodexOAuthTransform_AddsSparkImageUnsupportedInstructions(t *testing.T) {
  1002. reqBody := map[string]any{
  1003. "model": "gpt-5.3-codex-spark",
  1004. "instructions": "existing instructions",
  1005. "input": "hello",
  1006. }
  1007. result := applyCodexOAuthTransform(reqBody, true, false)
  1008. require.True(t, result.Modified)
  1009. instructions, ok := reqBody["instructions"].(string)
  1010. require.True(t, ok)
  1011. require.Contains(t, instructions, "existing instructions")
  1012. require.Contains(t, instructions, codexSparkImageUnsupportedMarker)
  1013. require.Contains(t, instructions, "does not support image generation")
  1014. require.Contains(t, instructions, "switch to a non-Spark Codex model")
  1015. require.NotContains(t, instructions, codexImageGenerationBridgeMarker)
  1016. }
  1017. func TestApplyCodexOAuthTransform_DoesNotAddSparkImageUnsupportedForNonSpark(t *testing.T) {
  1018. reqBody := map[string]any{
  1019. "model": "gpt-5.4",
  1020. "instructions": "existing instructions",
  1021. "input": "hello",
  1022. }
  1023. applyCodexOAuthTransform(reqBody, true, false)
  1024. instructions, ok := reqBody["instructions"].(string)
  1025. require.True(t, ok)
  1026. require.NotContains(t, instructions, codexSparkImageUnsupportedMarker)
  1027. }
  1028. // gpt-5.3-codex-spark rejects the image_generation tool upstream (HTTP 400
  1029. // invalid_request_error, param=tools). Codex CLI advertises that tool by default,
  1030. // so the OAuth transform must strip it for spark while keeping the rest.
  1031. func TestApplyCodexOAuthTransform_StripsImageGenerationToolForSpark(t *testing.T) {
  1032. reqBody := map[string]any{
  1033. "model": "gpt-5.3-codex-spark",
  1034. "input": "hello",
  1035. "tools": []any{
  1036. map[string]any{"type": "function", "name": "shell"},
  1037. map[string]any{"type": "image_generation", "output_format": "png"},
  1038. },
  1039. }
  1040. result := applyCodexOAuthTransform(reqBody, true, false)
  1041. require.True(t, result.Modified)
  1042. require.False(t, hasOpenAIImageGenerationTool(reqBody))
  1043. tools, ok := reqBody["tools"].([]any)
  1044. require.True(t, ok)
  1045. require.Len(t, tools, 1)
  1046. first, ok := tools[0].(map[string]any)
  1047. require.True(t, ok)
  1048. require.Equal(t, "function", first["type"])
  1049. require.Equal(t, "shell", first["name"])
  1050. }
  1051. // Spark reasoning-effort aliases (e.g. -low/-high) normalize to gpt-5.3-codex-spark,
  1052. // so they must be stripped too.
  1053. func TestApplyCodexOAuthTransform_StripsImageGenerationToolForSparkAlias(t *testing.T) {
  1054. reqBody := map[string]any{
  1055. "model": "gpt-5.3-codex-spark-high",
  1056. "input": "hello",
  1057. "tools": []any{
  1058. map[string]any{"type": "image_generation", "output_format": "png"},
  1059. },
  1060. }
  1061. result := applyCodexOAuthTransform(reqBody, true, false)
  1062. require.True(t, result.Modified)
  1063. require.False(t, hasOpenAIImageGenerationTool(reqBody))
  1064. // tools became empty after stripping the only entry; the key is dropped.
  1065. _, hasTools := reqBody["tools"]
  1066. require.False(t, hasTools)
  1067. }
  1068. func TestStripOpenAIImageGenerationTools_StripsNamespaceFormats(t *testing.T) {
  1069. imageNamespace := func() map[string]any {
  1070. return map[string]any{
  1071. "type": "namespace",
  1072. "name": "image_gen",
  1073. "tools": []any{
  1074. map[string]any{"type": "function", "name": "imagegen"},
  1075. },
  1076. }
  1077. }
  1078. codeNamespace := func() map[string]any {
  1079. return map[string]any{
  1080. "type": "namespace",
  1081. "name": "code_tools",
  1082. "tools": []any{
  1083. map[string]any{"type": "function", "name": "run"},
  1084. },
  1085. }
  1086. }
  1087. reqBody := map[string]any{
  1088. "model": "gpt-5.5",
  1089. "tools": []any{
  1090. map[string]any{"type": "function", "name": "shell"},
  1091. imageNamespace(),
  1092. codeNamespace(),
  1093. },
  1094. "input": []any{
  1095. map[string]any{"type": "message", "role": "user", "content": "hello"},
  1096. map[string]any{
  1097. "type": "additional_tools",
  1098. "tools": []any{imageNamespace(), codeNamespace()},
  1099. },
  1100. map[string]any{
  1101. "type": "additional_tools",
  1102. "tools": []any{imageNamespace()},
  1103. },
  1104. },
  1105. "tool_choice": map[string]any{"type": "namespace", "name": "image_gen"},
  1106. }
  1107. require.True(t, stripOpenAIImageGenerationTools(reqBody))
  1108. require.False(t, hasOpenAIImageGenerationTool(reqBody))
  1109. require.NotContains(t, reqBody, "tool_choice")
  1110. tools, ok := reqBody["tools"].([]any)
  1111. require.True(t, ok)
  1112. require.Len(t, tools, 2)
  1113. firstTool, ok := tools[0].(map[string]any)
  1114. require.True(t, ok)
  1115. secondTool, ok := tools[1].(map[string]any)
  1116. require.True(t, ok)
  1117. require.Equal(t, "shell", firstTool["name"])
  1118. require.Equal(t, "code_tools", secondTool["name"])
  1119. input, ok := reqBody["input"].([]any)
  1120. require.True(t, ok)
  1121. require.Len(t, input, 2)
  1122. message, ok := input[0].(map[string]any)
  1123. require.True(t, ok)
  1124. require.Equal(t, "message", message["type"])
  1125. additionalToolsItem, ok := input[1].(map[string]any)
  1126. require.True(t, ok)
  1127. additionalTools, ok := additionalToolsItem["tools"].([]any)
  1128. require.True(t, ok)
  1129. require.Len(t, additionalTools, 1)
  1130. additionalTool, ok := additionalTools[0].(map[string]any)
  1131. require.True(t, ok)
  1132. require.Equal(t, "code_tools", additionalTool["name"])
  1133. require.False(t, stripOpenAIImageGenerationTools(reqBody), "stripping should be idempotent")
  1134. }
  1135. func TestStripOpenAIImageGenerationTools_KeepsNonImageNamespaces(t *testing.T) {
  1136. reqBody := map[string]any{
  1137. "tools": []any{
  1138. map[string]any{"type": "namespace", "name": "code_tools"},
  1139. },
  1140. "input": []any{
  1141. map[string]any{
  1142. "type": "additional_tools",
  1143. "tools": []any{
  1144. map[string]any{"type": "namespace", "name": "browser_tools"},
  1145. },
  1146. },
  1147. },
  1148. "tool_choice": "auto",
  1149. }
  1150. require.False(t, stripOpenAIImageGenerationTools(reqBody))
  1151. require.Equal(t, "auto", reqBody["tool_choice"])
  1152. require.False(t, hasOpenAIImageGenerationTool(reqBody))
  1153. }
  1154. func TestStripOpenAIImageGenerationTools_KeepsCustomImagegenFunctionChoice(t *testing.T) {
  1155. reqBody := map[string]any{
  1156. "tool_choice": map[string]any{
  1157. "function": map[string]any{"name": "imagegen"},
  1158. },
  1159. }
  1160. require.False(t, stripOpenAIImageGenerationTools(reqBody))
  1161. require.Contains(t, reqBody, "tool_choice")
  1162. }
  1163. // Non-spark Codex models support image_generation; the tool must be preserved.
  1164. func TestApplyCodexOAuthTransform_KeepsImageGenerationToolForNonSpark(t *testing.T) {
  1165. reqBody := map[string]any{
  1166. "model": "gpt-5.3-codex",
  1167. "input": "hello",
  1168. "tools": []any{
  1169. map[string]any{"type": "image_generation", "output_format": "png"},
  1170. },
  1171. }
  1172. applyCodexOAuthTransform(reqBody, true, false)
  1173. require.True(t, hasOpenAIImageGenerationTool(reqBody))
  1174. }
  1175. func TestNormalizeOpenAIResponsesImageOnlyModel_BuildsImageToolRequest(t *testing.T) {
  1176. reqBody := map[string]any{
  1177. "model": "gpt-image-2",
  1178. "prompt": "draw a cat",
  1179. "size": "1024x1024",
  1180. "output_format": "png",
  1181. }
  1182. modified := normalizeOpenAIResponsesImageOnlyModel(reqBody)
  1183. require.True(t, modified)
  1184. require.Equal(t, openAIImagesResponsesMainModel, reqBody["model"])
  1185. require.Equal(t, "draw a cat", reqBody["input"])
  1186. _, hasPrompt := reqBody["prompt"]
  1187. require.False(t, hasPrompt)
  1188. _, hasTopLevelSize := reqBody["size"]
  1189. require.False(t, hasTopLevelSize)
  1190. tools, ok := reqBody["tools"].([]any)
  1191. require.True(t, ok)
  1192. require.Len(t, tools, 1)
  1193. tool, ok := tools[0].(map[string]any)
  1194. require.True(t, ok)
  1195. require.Equal(t, "image_generation", tool["type"])
  1196. require.Equal(t, "gpt-image-2", tool["model"])
  1197. require.Equal(t, "1024x1024", tool["size"])
  1198. require.Equal(t, "png", tool["output_format"])
  1199. choice, ok := reqBody["tool_choice"].(map[string]any)
  1200. require.True(t, ok)
  1201. require.Equal(t, "image_generation", choice["type"])
  1202. }
  1203. func TestNormalizeOpenAIResponsesImageOnlyModel_PreservesExistingImageTool(t *testing.T) {
  1204. reqBody := map[string]any{
  1205. "model": "gpt-image-2",
  1206. "input": "draw a cat",
  1207. "tools": []any{
  1208. map[string]any{
  1209. "type": "image_generation",
  1210. "model": "gpt-image-1.5",
  1211. },
  1212. },
  1213. "tool_choice": "auto",
  1214. }
  1215. modified := normalizeOpenAIResponsesImageOnlyModel(reqBody)
  1216. require.True(t, modified)
  1217. require.Equal(t, openAIImagesResponsesMainModel, reqBody["model"])
  1218. require.Equal(t, "auto", reqBody["tool_choice"])
  1219. tools, ok := reqBody["tools"].([]any)
  1220. require.True(t, ok)
  1221. require.Len(t, tools, 1)
  1222. tool, ok := tools[0].(map[string]any)
  1223. require.True(t, ok)
  1224. require.Equal(t, "gpt-image-1.5", tool["model"])
  1225. }
  1226. func TestValidateOpenAIResponsesImageModel_RejectsImageOnlyModel(t *testing.T) {
  1227. err := validateOpenAIResponsesImageModel(map[string]any{
  1228. "tools": []any{
  1229. map[string]any{"type": "image_generation"},
  1230. },
  1231. }, "gpt-image-2")
  1232. require.ErrorContains(t, err, `/v1/responses image_generation requests require a Responses-capable text model`)
  1233. }
  1234. func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) {
  1235. // 空 input 应保持为空且不触发异常。
  1236. reqBody := map[string]any{
  1237. "model": "gpt-5.1",
  1238. "input": []any{},
  1239. }
  1240. applyCodexOAuthTransform(reqBody, false, false)
  1241. input, ok := reqBody["input"].([]any)
  1242. require.True(t, ok)
  1243. require.Len(t, input, 0)
  1244. }
  1245. func TestNormalizeCodexModel_Gpt53(t *testing.T) {
  1246. cases := map[string]string{
  1247. "gpt-6-astra": "gpt-6-astra",
  1248. "openai/gpt-6-astra": "gpt-6-astra",
  1249. "gpt-6": "gpt-6-astra",
  1250. "openai/gpt-6": "gpt-6-astra",
  1251. "gpt-5.4": "gpt-5.4",
  1252. "gpt5.5": "gpt-5.5",
  1253. "openai/gpt5.5": "gpt-5.5",
  1254. "gpt-5.5-pro": "gpt-5.5-pro",
  1255. "gpt5.5-pro": "gpt-5.5-pro",
  1256. "openai/gpt5.5-pro": "gpt-5.5-pro",
  1257. "gpt-5.5-pro-high": "gpt-5.5-pro",
  1258. "codex-auto-review": "codex-auto-review",
  1259. "gpt5.4": "gpt-5.4",
  1260. "gpt-5.4-high": "gpt-5.4",
  1261. "gpt-5.4-chat-latest": "gpt-5.4",
  1262. "gpt 5.4": "gpt-5.4",
  1263. "gpt-5.4-mini": "gpt-5.4-mini",
  1264. "gpt5.4-mini": "gpt-5.4-mini",
  1265. "gpt5.4mini": "gpt-5.4-mini",
  1266. "gpt 5.4 mini": "gpt-5.4-mini",
  1267. "gpt-5.3": "gpt-5.3-codex",
  1268. "gpt5.3": "gpt-5.3-codex",
  1269. "gpt-5.3-codex": "gpt-5.3-codex",
  1270. "gpt5.3-codex": "gpt-5.3-codex",
  1271. "gpt5.3codex": "gpt-5.3-codex",
  1272. "gpt-5.3-codex-xhigh": "gpt-5.3-codex",
  1273. "gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
  1274. "gpt5.3-codex-spark": "gpt-5.3-codex-spark",
  1275. "gpt5.3codexspark": "gpt-5.3-codex-spark",
  1276. "gpt 5.3 codex spark": "gpt-5.3-codex-spark",
  1277. "gpt-5.3-codex-spark-high": "gpt-5.3-codex-spark",
  1278. "gpt-5.3-codex-spark-xhigh": "gpt-5.3-codex-spark",
  1279. "gpt 5.3 codex": "gpt-5.3-codex",
  1280. }
  1281. for input, expected := range cases {
  1282. require.Equal(t, expected, normalizeCodexModel(input))
  1283. }
  1284. }
  1285. func TestNormalizeCodexModel_RemovedModelsFallbackToSupportedTargets(t *testing.T) {
  1286. cases := map[string]string{
  1287. "": "gpt-5.4",
  1288. "gpt-5": "gpt-5.4",
  1289. "gpt-5-mini": "gpt-5.4",
  1290. "gpt-5-nano": "gpt-5.4",
  1291. "gpt-5.1": "gpt-5.4",
  1292. "gpt-5.1-codex": "gpt-5.3-codex",
  1293. "gpt-5.1-codex-max": "gpt-5.3-codex",
  1294. "gpt-5.1-codex-mini": "gpt-5.3-codex",
  1295. "gpt-5.2-codex": "gpt-5.2",
  1296. "codex-mini-latest": "gpt-5.3-codex",
  1297. "gpt-5-codex": "gpt-5.3-codex",
  1298. }
  1299. for input, expected := range cases {
  1300. require.Equal(t, expected, normalizeCodexModel(input))
  1301. }
  1302. }
  1303. func TestApplyCodexOAuthTransform_PreservesBareSparkModel(t *testing.T) {
  1304. reqBody := map[string]any{
  1305. "model": "gpt-5.3-codex-spark",
  1306. "input": []any{},
  1307. }
  1308. result := applyCodexOAuthTransform(reqBody, false, false)
  1309. require.Equal(t, "gpt-5.3-codex-spark", reqBody["model"])
  1310. require.Equal(t, "gpt-5.3-codex-spark", result.NormalizedModel)
  1311. store, ok := reqBody["store"].(bool)
  1312. require.True(t, ok)
  1313. require.False(t, store)
  1314. }
  1315. func TestApplyCodexOAuthTransform_TrimmedModelWithoutPolicyRewrite(t *testing.T) {
  1316. reqBody := map[string]any{
  1317. "model": " gpt-5.3-codex-spark ",
  1318. "input": []any{},
  1319. }
  1320. result := applyCodexOAuthTransform(reqBody, false, false)
  1321. require.Equal(t, "gpt-5.3-codex-spark", reqBody["model"])
  1322. require.Equal(t, "gpt-5.3-codex-spark", result.NormalizedModel)
  1323. require.True(t, result.Modified)
  1324. }
  1325. func TestApplyCodexOAuthTransform_CodexCLI_PreservesExistingInstructions(t *testing.T) {
  1326. // Codex CLI 场景:已有 instructions 时不修改
  1327. reqBody := map[string]any{
  1328. "model": "gpt-5.1",
  1329. "instructions": "existing instructions",
  1330. }
  1331. result := applyCodexOAuthTransform(reqBody, true, false) // isCodexCLI=true
  1332. instructions, ok := reqBody["instructions"].(string)
  1333. require.True(t, ok)
  1334. require.Equal(t, "existing instructions", instructions)
  1335. // Modified 仍可能为 true(因为其他字段被修改),但 instructions 应保持不变
  1336. _ = result
  1337. }
  1338. func TestApplyCodexOAuthTransform_CodexCLI_SuppliesDefaultWhenEmpty(t *testing.T) {
  1339. // Codex CLI 场景:无 instructions 时补充默认值
  1340. reqBody := map[string]any{
  1341. "model": "gpt-5.1",
  1342. // 没有 instructions 字段
  1343. }
  1344. result := applyCodexOAuthTransform(reqBody, true, false) // isCodexCLI=true
  1345. instructions, ok := reqBody["instructions"].(string)
  1346. require.True(t, ok)
  1347. require.NotEmpty(t, instructions)
  1348. require.True(t, result.Modified)
  1349. }
  1350. func TestApplyCodexOAuthTransform_GPT55SuppliesModelSpecificInstructions(t *testing.T) {
  1351. reqBody := map[string]any{
  1352. "model": "gpt-5.5",
  1353. "instructions": " ",
  1354. }
  1355. result := applyCodexOAuthTransform(reqBody, true, false)
  1356. instructions, ok := reqBody["instructions"].(string)
  1357. require.True(t, ok)
  1358. require.Contains(t, instructions, "You are Codex, a coding agent based on GPT-5")
  1359. require.NotContains(t, instructions, "You are GPT-5.1 running in the Codex CLI")
  1360. require.True(t, result.Modified)
  1361. }
  1362. func TestApplyCodexOAuthTransform_GPT6AstraSuppliesModelSpecificInstructions(t *testing.T) {
  1363. reqBody := map[string]any{
  1364. "model": "gpt-6-astra",
  1365. }
  1366. result := applyCodexOAuthTransform(reqBody, true, false)
  1367. instructions, ok := reqBody["instructions"].(string)
  1368. require.True(t, ok)
  1369. require.True(t, strings.HasPrefix(strings.TrimSpace(instructions), "You are Codex, an agent based on GPT-6."))
  1370. require.NotContains(t, instructions, "You are Codex, a coding agent based on GPT-5.")
  1371. require.True(t, result.Modified)
  1372. }
  1373. func TestApplyCodexOAuthTransform_NonCodexCLI_PreservesExistingInstructions(t *testing.T) {
  1374. // 非 Codex CLI 场景:已有 instructions 时保留客户端的值,不再覆盖
  1375. reqBody := map[string]any{
  1376. "model": "gpt-5.1",
  1377. "instructions": "old instructions",
  1378. }
  1379. applyCodexOAuthTransform(reqBody, false, false) // isCodexCLI=false
  1380. instructions, ok := reqBody["instructions"].(string)
  1381. require.True(t, ok)
  1382. require.Equal(t, "old instructions", instructions)
  1383. }
  1384. func TestApplyCodexOAuthTransform_StringInputConvertedToArray(t *testing.T) {
  1385. reqBody := map[string]any{"model": "gpt-5.4", "input": "Hello, world!"}
  1386. result := applyCodexOAuthTransform(reqBody, false, false)
  1387. require.True(t, result.Modified)
  1388. input, ok := reqBody["input"].([]any)
  1389. require.True(t, ok)
  1390. require.Len(t, input, 1)
  1391. msg, ok := input[0].(map[string]any)
  1392. require.True(t, ok)
  1393. require.Equal(t, "message", msg["type"])
  1394. require.Equal(t, "user", msg["role"])
  1395. require.Equal(t, "Hello, world!", msg["content"])
  1396. }
  1397. func TestApplyCodexOAuthTransform_EmptyStringInputBecomesEmptyArray(t *testing.T) {
  1398. reqBody := map[string]any{"model": "gpt-5.4", "input": ""}
  1399. result := applyCodexOAuthTransform(reqBody, false, false)
  1400. require.True(t, result.Modified)
  1401. input, ok := reqBody["input"].([]any)
  1402. require.True(t, ok)
  1403. require.Len(t, input, 0)
  1404. }
  1405. func TestApplyCodexOAuthTransform_WhitespaceStringInputBecomesEmptyArray(t *testing.T) {
  1406. reqBody := map[string]any{"model": "gpt-5.4", "input": " "}
  1407. result := applyCodexOAuthTransform(reqBody, false, false)
  1408. require.True(t, result.Modified)
  1409. input, ok := reqBody["input"].([]any)
  1410. require.True(t, ok)
  1411. require.Len(t, input, 0)
  1412. }
  1413. func TestApplyCodexOAuthTransform_StringInputWithToolsField(t *testing.T) {
  1414. reqBody := map[string]any{
  1415. "model": "gpt-5.4",
  1416. "input": "Run the tests",
  1417. "tools": []any{map[string]any{"type": "function", "name": "bash"}},
  1418. }
  1419. applyCodexOAuthTransform(reqBody, false, false)
  1420. input, ok := reqBody["input"].([]any)
  1421. require.True(t, ok)
  1422. require.Len(t, input, 1)
  1423. }
  1424. func TestExtractSystemMessagesFromInput(t *testing.T) {
  1425. t.Run("no system messages", func(t *testing.T) {
  1426. reqBody := map[string]any{
  1427. "input": []any{
  1428. map[string]any{"role": "user", "content": "hello"},
  1429. },
  1430. }
  1431. result := extractSystemMessagesFromInput(reqBody, false)
  1432. require.False(t, result)
  1433. input, ok := reqBody["input"].([]any)
  1434. require.True(t, ok)
  1435. require.Len(t, input, 1)
  1436. _, hasInstructions := reqBody["instructions"]
  1437. require.False(t, hasInstructions)
  1438. })
  1439. t.Run("string content system message", func(t *testing.T) {
  1440. reqBody := map[string]any{
  1441. "input": []any{
  1442. map[string]any{"role": "system", "content": "You are an assistant."},
  1443. map[string]any{"role": "user", "content": "hello"},
  1444. },
  1445. }
  1446. result := extractSystemMessagesFromInput(reqBody, false)
  1447. require.True(t, result)
  1448. input, ok := reqBody["input"].([]any)
  1449. require.True(t, ok)
  1450. require.Len(t, input, 2)
  1451. msg, ok := input[0].(map[string]any)
  1452. require.True(t, ok)
  1453. require.Equal(t, "developer", msg["role"])
  1454. require.Equal(t, "You are an assistant.", msg["content"])
  1455. user, ok := input[1].(map[string]any)
  1456. require.True(t, ok)
  1457. require.Equal(t, "user", user["role"])
  1458. require.Equal(t, "You are an assistant.", reqBody["instructions"])
  1459. })
  1460. t.Run("array content system message", func(t *testing.T) {
  1461. reqBody := map[string]any{
  1462. "input": []any{
  1463. map[string]any{
  1464. "role": "system",
  1465. "content": []any{
  1466. map[string]any{"type": "text", "text": "Be helpful."},
  1467. },
  1468. },
  1469. },
  1470. }
  1471. result := extractSystemMessagesFromInput(reqBody, false)
  1472. require.True(t, result)
  1473. require.Equal(t, "Be helpful.", reqBody["instructions"])
  1474. input, ok := reqBody["input"].([]any)
  1475. require.True(t, ok)
  1476. require.Len(t, input, 1)
  1477. msg, ok := input[0].(map[string]any)
  1478. require.True(t, ok)
  1479. require.Equal(t, "developer", msg["role"])
  1480. require.Equal(t, []any{
  1481. map[string]any{"type": "text", "text": "Be helpful."},
  1482. }, msg["content"])
  1483. require.Equal(t, "Be helpful.", reqBody["instructions"])
  1484. })
  1485. t.Run("multiple system messages concatenated", func(t *testing.T) {
  1486. reqBody := map[string]any{
  1487. "input": []any{
  1488. map[string]any{"role": "system", "content": "First."},
  1489. map[string]any{"role": "system", "content": "Second."},
  1490. map[string]any{"role": "user", "content": "hi"},
  1491. },
  1492. }
  1493. result := extractSystemMessagesFromInput(reqBody, false)
  1494. require.True(t, result)
  1495. require.Equal(t, "First.\n\nSecond.", reqBody["instructions"])
  1496. input, ok := reqBody["input"].([]any)
  1497. require.True(t, ok)
  1498. require.Len(t, input, 3)
  1499. first, ok := input[0].(map[string]any)
  1500. require.True(t, ok)
  1501. require.Equal(t, "developer", first["role"])
  1502. second, ok := input[1].(map[string]any)
  1503. require.True(t, ok)
  1504. require.Equal(t, "developer", second["role"])
  1505. user, ok := input[2].(map[string]any)
  1506. require.True(t, ok)
  1507. require.Equal(t, "user", user["role"])
  1508. require.Equal(t, "First.\n\nSecond.", reqBody["instructions"])
  1509. })
  1510. t.Run("mixed system and non-system preserves non-system", func(t *testing.T) {
  1511. reqBody := map[string]any{
  1512. "input": []any{
  1513. map[string]any{"role": "user", "content": "hello"},
  1514. map[string]any{"role": "system", "content": "Sys prompt."},
  1515. map[string]any{"role": "assistant", "content": "Hi there"},
  1516. },
  1517. }
  1518. result := extractSystemMessagesFromInput(reqBody, false)
  1519. require.True(t, result)
  1520. input, ok := reqBody["input"].([]any)
  1521. require.True(t, ok)
  1522. require.Len(t, input, 3)
  1523. first, ok := input[0].(map[string]any)
  1524. require.True(t, ok)
  1525. require.Equal(t, "user", first["role"])
  1526. second, ok := input[1].(map[string]any)
  1527. require.True(t, ok)
  1528. require.Equal(t, "developer", second["role"])
  1529. third, ok := input[2].(map[string]any)
  1530. require.True(t, ok)
  1531. require.Equal(t, "assistant", third["role"])
  1532. require.Equal(t, "Sys prompt.", reqBody["instructions"])
  1533. })
  1534. t.Run("existing instructions prepended", func(t *testing.T) {
  1535. reqBody := map[string]any{
  1536. "input": []any{
  1537. map[string]any{"role": "system", "content": "Extracted."},
  1538. map[string]any{"role": "user", "content": "hi"},
  1539. },
  1540. "instructions": "Existing instructions.",
  1541. }
  1542. result := extractSystemMessagesFromInput(reqBody, false)
  1543. require.True(t, result)
  1544. require.Equal(t, "Extracted.\n\nExisting instructions.", reqBody["instructions"])
  1545. input, ok := reqBody["input"].([]any)
  1546. require.True(t, ok)
  1547. msg, ok := input[0].(map[string]any)
  1548. require.True(t, ok)
  1549. require.Equal(t, "developer", msg["role"])
  1550. })
  1551. t.Run("omit losslessly promoted text-only messages", func(t *testing.T) {
  1552. reqBody := map[string]any{
  1553. "input": []any{
  1554. map[string]any{"role": "system", "content": "First."},
  1555. map[string]any{
  1556. "role": "system",
  1557. "content": []any{
  1558. map[string]any{"type": "text", "text": "Second "},
  1559. map[string]any{"type": "input_text", "text": "and "},
  1560. map[string]any{"type": "output_text", "text": "third."},
  1561. },
  1562. },
  1563. map[string]any{"role": "user", "content": "hi"},
  1564. },
  1565. "instructions": "Existing.",
  1566. }
  1567. result := extractSystemMessagesFromInput(reqBody, true)
  1568. require.True(t, result)
  1569. require.Equal(t, "First.\n\nSecond and third.\n\nExisting.", reqBody["instructions"])
  1570. input, ok := reqBody["input"].([]any)
  1571. require.True(t, ok)
  1572. require.Len(t, input, 1)
  1573. user, ok := input[0].(map[string]any)
  1574. require.True(t, ok)
  1575. require.Equal(t, "user", user["role"])
  1576. })
  1577. t.Run("omit keeps mixed system content as developer", func(t *testing.T) {
  1578. reqBody := map[string]any{
  1579. "input": []any{
  1580. map[string]any{
  1581. "role": "system",
  1582. "content": []any{
  1583. map[string]any{"type": "input_text", "text": "Inspect this image."},
  1584. map[string]any{"type": "input_image", "image_url": "https://example.com/image.png"},
  1585. },
  1586. },
  1587. map[string]any{"role": "user", "content": "hi"},
  1588. },
  1589. }
  1590. result := extractSystemMessagesFromInput(reqBody, true)
  1591. require.True(t, result)
  1592. require.Equal(t, "Inspect this image.", reqBody["instructions"])
  1593. input, ok := reqBody["input"].([]any)
  1594. require.True(t, ok)
  1595. require.Len(t, input, 2)
  1596. developer, ok := input[0].(map[string]any)
  1597. require.True(t, ok)
  1598. require.Equal(t, "developer", developer["role"])
  1599. require.Len(t, developer["content"], 2)
  1600. })
  1601. }
  1602. // TestApplyCodexOAuthTransform_StripsPromptCacheRetention is a regression
  1603. // test: some clients (e.g. Cursor cloud via the Responses-shape compat path)
  1604. // send prompt_cache_retention, but the ChatGPT internal Codex endpoint
  1605. // rejects it with "Unsupported parameter: prompt_cache_retention".
  1606. func TestApplyCodexOAuthTransform_StripsPromptCacheRetention(t *testing.T) {
  1607. reqBody := map[string]any{
  1608. "model": "gpt-5.1",
  1609. "prompt_cache_retention": "24h",
  1610. "input": []any{
  1611. map[string]any{"role": "user", "content": "hi"},
  1612. },
  1613. }
  1614. applyCodexOAuthTransform(reqBody, false, false)
  1615. _, stillThere := reqBody["prompt_cache_retention"]
  1616. require.False(t, stillThere,
  1617. "prompt_cache_retention must be stripped before forwarding to Codex upstream")
  1618. }
  1619. func TestApplyCodexOAuthTransform_StripsChatGPTInternalUnsupportedFields(t *testing.T) {
  1620. reqBody := map[string]any{
  1621. "model": "gpt-5.4",
  1622. "chat_template_kwargs": map[string]any{"enable_thinking": true},
  1623. "user": "user_123",
  1624. "metadata": map[string]any{"trace_id": "abc"},
  1625. "prompt_cache_retention": "24h",
  1626. "safety_identifier": "sid",
  1627. "stream_options": map[string]any{"include_usage": true},
  1628. "truncation": "auto",
  1629. "stop_sequences": []any{"END"},
  1630. "input": []any{
  1631. map[string]any{"role": "user", "content": "hi"},
  1632. },
  1633. }
  1634. result := applyCodexOAuthTransform(reqBody, true, false)
  1635. require.True(t, result.Modified)
  1636. for _, field := range openAIChatGPTInternalUnsupportedFields {
  1637. require.NotContains(t, reqBody, field)
  1638. }
  1639. }
  1640. func TestApplyCodexOAuthTransform_NormalizesPromptAndCommands(t *testing.T) {
  1641. reqBody := map[string]any{
  1642. "model": "gpt-5.5",
  1643. "prompt": "hello",
  1644. "commands": []any{"unsupported"},
  1645. }
  1646. result := applyCodexOAuthTransform(reqBody, true, false)
  1647. require.True(t, result.Modified)
  1648. require.Equal(t, []any{
  1649. map[string]any{"type": "message", "role": "user", "content": "hello"},
  1650. }, reqBody["input"])
  1651. require.NotContains(t, reqBody, "prompt")
  1652. require.NotContains(t, reqBody, "commands")
  1653. }
  1654. func TestNormalizeOpenAIResponsesImageGenerationTools_StripsGPTImage2InputFidelity(t *testing.T) {
  1655. reqBody := map[string]any{"tools": []any{
  1656. map[string]any{"type": "image_generation", "model": "gpt-image-2-codex", "input_fidelity": "high"},
  1657. map[string]any{"type": "image_generation", "model": "gpt-image-1.5", "input_fidelity": "high"},
  1658. }}
  1659. require.True(t, normalizeOpenAIResponsesImageGenerationTools(reqBody))
  1660. tools, ok := reqBody["tools"].([]any)
  1661. require.True(t, ok)
  1662. require.Len(t, tools, 2)
  1663. first, ok := tools[0].(map[string]any)
  1664. require.True(t, ok)
  1665. second, ok := tools[1].(map[string]any)
  1666. require.True(t, ok)
  1667. require.NotContains(t, first, "input_fidelity")
  1668. require.Equal(t, "high", second["input_fidelity"])
  1669. }
  1670. func TestOpenAIRequestBodyImageGenerationToolNeedsNormalization_GPTImage2InputFidelity(t *testing.T) {
  1671. body := []byte(`{"tools":[{"type":"image_generation","model":"gpt-image-2-codex","input_fidelity":"high"}]}`)
  1672. require.True(t, openAIRequestBodyImageGenerationToolNeedsNormalization(body))
  1673. }
  1674. func TestApplyCodexOAuthTransform_ExtractsSystemMessages(t *testing.T) {
  1675. reqBody := map[string]any{
  1676. "model": "gpt-5.1",
  1677. "input": []any{
  1678. map[string]any{"role": "system", "content": "You are a coding assistant."},
  1679. map[string]any{"role": "user", "content": "Write a function."},
  1680. },
  1681. }
  1682. result := applyCodexOAuthTransform(reqBody, false, false)
  1683. require.True(t, result.Modified)
  1684. input, ok := reqBody["input"].([]any)
  1685. require.True(t, ok)
  1686. require.Len(t, input, 2)
  1687. system, ok := input[0].(map[string]any)
  1688. require.True(t, ok)
  1689. require.Equal(t, "developer", system["role"])
  1690. require.Equal(t, "You are a coding assistant.", system["content"])
  1691. user, ok := input[1].(map[string]any)
  1692. require.True(t, ok)
  1693. require.Equal(t, "user", user["role"])
  1694. require.Equal(t, "You are a coding assistant.", reqBody["instructions"])
  1695. }
  1696. func TestApplyCodexOAuthTransform_JsonObjectKeepsJsonInstructionInInput(t *testing.T) {
  1697. reqBody := map[string]any{
  1698. "model": "gpt-5.4",
  1699. "input": []any{
  1700. map[string]any{
  1701. "role": "system",
  1702. "content": "You are an assistant. Output JSON only.",
  1703. },
  1704. map[string]any{
  1705. "role": "user",
  1706. "content": "symbol data without the keyword",
  1707. },
  1708. },
  1709. "text": map[string]any{
  1710. "format": map[string]any{
  1711. "type": "json_object",
  1712. },
  1713. },
  1714. }
  1715. result := applyCodexOAuthTransform(reqBody, false, false)
  1716. require.True(t, result.Modified)
  1717. instructions, ok := reqBody["instructions"].(string)
  1718. require.True(t, ok)
  1719. require.Contains(t, instructions, "JSON")
  1720. input, ok := reqBody["input"].([]any)
  1721. require.True(t, ok)
  1722. require.Len(t, input, 2)
  1723. developer, ok := input[0].(map[string]any)
  1724. require.True(t, ok)
  1725. require.Equal(t, "developer", developer["role"])
  1726. require.Contains(t, developer["content"], "JSON")
  1727. user, ok := input[1].(map[string]any)
  1728. require.True(t, ok)
  1729. require.Equal(t, "user", user["role"])
  1730. }
  1731. func TestIsInstructionsEmpty(t *testing.T) {
  1732. tests := []struct {
  1733. name string
  1734. reqBody map[string]any
  1735. expected bool
  1736. }{
  1737. {"missing field", map[string]any{}, true},
  1738. {"nil value", map[string]any{"instructions": nil}, true},
  1739. {"empty string", map[string]any{"instructions": ""}, true},
  1740. {"whitespace only", map[string]any{"instructions": " "}, true},
  1741. {"non-string", map[string]any{"instructions": 123}, true},
  1742. {"valid string", map[string]any{"instructions": "hello"}, false},
  1743. }
  1744. for _, tt := range tests {
  1745. t.Run(tt.name, func(t *testing.T) {
  1746. result := isInstructionsEmpty(tt.reqBody)
  1747. require.Equal(t, tt.expected, result)
  1748. })
  1749. }
  1750. }
  1751. // TestFilterCodexInput_PreservesReasoningStripsID covers the core OAuth-path
  1752. // reasoning contract (replaces the earlier "drops reasoning" test, whose
  1753. // premise was wrong). A reasoning item carrying encrypted_content is the
  1754. // official channel for replaying reasoning context across turns under
  1755. // store=false, so it must survive the filter with encrypted_content intact;
  1756. // only its rs_* id is stripped (always, independent of PreserveReferences)
  1757. // because a bare rs_* id replayed under store=false 404s upstream. Contracts
  1758. // 1/2/3, verified end-to-end against chatgpt.com codex (gpt-5.5). See issue
  1759. // #1957.
  1760. func TestFilterCodexInput_PreservesReasoningStripsID(t *testing.T) {
  1761. build := func() []any {
  1762. return []any{
  1763. map[string]any{
  1764. "type": "reasoning",
  1765. "id": "rs_0672f12450da0b9c0169f07220a6c08198b68c2455ced99344",
  1766. "call_id": "remove_non_pair_call_id",
  1767. "encrypted_content": "gAAAAAB-enc-payload",
  1768. "summary": []any{},
  1769. },
  1770. }
  1771. }
  1772. for _, preserve := range []bool{true, false} {
  1773. preserve := preserve
  1774. t.Run(fmt.Sprintf("preserveReferences=%v", preserve), func(t *testing.T) {
  1775. filtered := filterCodexInput(build(), preserve)
  1776. require.Len(t, filtered, 1)
  1777. item, ok := filtered[0].(map[string]any)
  1778. require.True(t, ok)
  1779. // Contract 2: the reasoning item survives the filter.
  1780. require.Equal(t, "reasoning", item["type"])
  1781. // Contract 2: encrypted_content (cross-turn channel) preserved verbatim.
  1782. require.Equal(t, "gAAAAAB-enc-payload", item["encrypted_content"])
  1783. // Contract 1/3: rs_* id stripped unconditionally, even when
  1784. // PreserveReferences=true (id lookup, not the item, triggers the 404).
  1785. _, hasID := item["id"]
  1786. require.False(t, hasID)
  1787. _, hasCallID := item["call_id"]
  1788. require.False(t, hasCallID)
  1789. // summary passed through untouched.
  1790. summary, ok := item["summary"].([]any)
  1791. require.True(t, ok)
  1792. require.Len(t, summary, 0)
  1793. })
  1794. }
  1795. }
  1796. // TestFilterCodexInput_BareReasoningStripsIDBackfillsSummary covers contract 1
  1797. // plus 5: a reasoning item carrying only an rs_* id (no encrypted_content) is
  1798. // kept as an empty shell with the id stripped, and a missing summary is
  1799. // backfilled to [] so upstream does not reject it with 400 "Missing required
  1800. // parameter 'input[N].summary'". Verified against chatgpt.com codex (gpt-5.5).
  1801. func TestFilterCodexInput_BareReasoningStripsIDBackfillsSummary(t *testing.T) {
  1802. input := []any{
  1803. map[string]any{
  1804. "type": "reasoning",
  1805. "id": "rs_0672f12450da0b9c0169f07220a6c08198b68c2455ced99344",
  1806. },
  1807. }
  1808. filtered := filterCodexInput(input, false)
  1809. require.Len(t, filtered, 1)
  1810. item, ok := filtered[0].(map[string]any)
  1811. require.True(t, ok)
  1812. require.Equal(t, "reasoning", item["type"])
  1813. // Contract 1: id stripped.
  1814. _, hasID := item["id"]
  1815. require.False(t, hasID)
  1816. // Contract 5: summary backfilled to an empty array.
  1817. summary, ok := item["summary"].([]any)
  1818. require.True(t, ok)
  1819. require.Len(t, summary, 0)
  1820. }
  1821. // TestFilterCodexInput_ReasoningBackfillsMissingSummary isolates contract 5:
  1822. // even when a reasoning item carries other content (here encrypted_content),
  1823. // a missing summary field is always added as [] before forwarding upstream.
  1824. func TestFilterCodexInput_ReasoningBackfillsMissingSummary(t *testing.T) {
  1825. input := []any{
  1826. map[string]any{
  1827. "type": "reasoning",
  1828. "id": "rs_abc",
  1829. "encrypted_content": "gAAAAAB-enc",
  1830. },
  1831. }
  1832. filtered := filterCodexInput(input, false)
  1833. require.Len(t, filtered, 1)
  1834. item, ok := filtered[0].(map[string]any)
  1835. require.True(t, ok)
  1836. summary, ok := item["summary"].([]any)
  1837. require.True(t, ok)
  1838. require.Len(t, summary, 0)
  1839. // encrypted_content still preserved alongside the backfilled summary.
  1840. require.Equal(t, "gAAAAAB-enc", item["encrypted_content"])
  1841. }
  1842. // TestFilterCodexInput_PreservesReasoningSummaryAndContent verifies that a
  1843. // non-empty summary is not overwritten and that arbitrary reasoning fields
  1844. // (e.g. content) survive verbatim — only the id is removed.
  1845. func TestFilterCodexInput_PreservesReasoningSummaryAndContent(t *testing.T) {
  1846. summary := []any{
  1847. map[string]any{"type": "summary_text", "text": "Considered the options."},
  1848. }
  1849. content := []any{
  1850. map[string]any{"type": "reasoning_text", "text": "internal chain"},
  1851. }
  1852. input := []any{
  1853. map[string]any{
  1854. "type": "reasoning",
  1855. "id": "rs_abc",
  1856. "summary": summary,
  1857. "content": content,
  1858. "encrypted_content": "gAAAAAB-enc",
  1859. },
  1860. }
  1861. filtered := filterCodexInput(input, false)
  1862. require.Len(t, filtered, 1)
  1863. item, ok := filtered[0].(map[string]any)
  1864. require.True(t, ok)
  1865. // Non-empty summary preserved verbatim (not replaced with []).
  1866. require.Equal(t, summary, item["summary"])
  1867. // content preserved verbatim.
  1868. require.Equal(t, content, item["content"])
  1869. require.Equal(t, "gAAAAAB-enc", item["encrypted_content"])
  1870. _, hasID := item["id"]
  1871. require.False(t, hasID)
  1872. }
  1873. // TestFilterCodexInput_PreservesReasoningInMixedInput exercises contract 7:
  1874. // reasoning items are stripped of their rs_* ids but kept (with
  1875. // encrypted_content) while message / function_call / function_call_output
  1876. // items flow through unchanged, with tool-call pairing (call_id) intact.
  1877. func TestFilterCodexInput_PreservesReasoningInMixedInput(t *testing.T) {
  1878. build := func() []any {
  1879. return []any{
  1880. map[string]any{"type": "message", "id": "msg_0", "role": "user", "content": "hi"},
  1881. map[string]any{
  1882. "type": "reasoning",
  1883. "id": "rs_1",
  1884. "encrypted_content": "gAAAAAB-enc-1",
  1885. "summary": []any{},
  1886. },
  1887. map[string]any{
  1888. "type": "reasoning",
  1889. "id": "rs_2",
  1890. "summary": []any{},
  1891. },
  1892. // call_id already in fc_ form so the unrelated call_->fc_
  1893. // normalization does not obscure the pairing assertion.
  1894. map[string]any{"type": "function_call", "id": "fc_1", "call_id": "fc_1", "name": "tool", "arguments": "{}"},
  1895. map[string]any{"type": "function_call_output", "call_id": "fc_1", "output": "{}"},
  1896. }
  1897. }
  1898. for _, preserve := range []bool{true, false} {
  1899. preserve := preserve
  1900. t.Run(fmt.Sprintf("preserveReferences=%v", preserve), func(t *testing.T) {
  1901. filtered := filterCodexInput(build(), preserve)
  1902. // Nothing is dropped: both reasoning items are now preserved.
  1903. require.Len(t, filtered, 5)
  1904. byType := make(map[string][]map[string]any)
  1905. for _, raw := range filtered {
  1906. item, ok := raw.(map[string]any)
  1907. require.True(t, ok)
  1908. typ, _ := item["type"].(string)
  1909. byType[typ] = append(byType[typ], item)
  1910. // No surviving item may carry an rs_* id.
  1911. if id, ok := item["id"].(string); ok {
  1912. require.False(t, strings.HasPrefix(id, "rs_"),
  1913. "no item carrying an rs_* id should survive the filter")
  1914. }
  1915. }
  1916. // Both reasoning items kept, ids stripped, summary present.
  1917. require.Len(t, byType["reasoning"], 2)
  1918. for _, r := range byType["reasoning"] {
  1919. _, hasID := r["id"]
  1920. require.False(t, hasID)
  1921. _, hasSummary := r["summary"]
  1922. require.True(t, hasSummary)
  1923. }
  1924. require.Equal(t, "gAAAAAB-enc-1", byType["reasoning"][0]["encrypted_content"])
  1925. // message / function_call(+output) untouched by reasoning handling.
  1926. require.Len(t, byType["message"], 1)
  1927. // Contract 7: tool-call pairing by call_id is unaffected.
  1928. require.Len(t, byType["function_call"], 1)
  1929. require.Equal(t, "fc_1", byType["function_call"][0]["call_id"])
  1930. require.Len(t, byType["function_call_output"], 1)
  1931. require.Equal(t, "fc_1", byType["function_call_output"][0]["call_id"])
  1932. })
  1933. }
  1934. }