chatcompletions_responses_test.go 57 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/stretchr/testify/assert"
  6. "github.com/stretchr/testify/require"
  7. )
  8. // ---------------------------------------------------------------------------
  9. // ChatCompletionsToResponses tests
  10. // ---------------------------------------------------------------------------
  11. func TestChatCompletionsToResponses_BasicText(t *testing.T) {
  12. req := &ChatCompletionsRequest{
  13. Model: "gpt-4o",
  14. Messages: []ChatMessage{
  15. {Role: "user", Content: json.RawMessage(`"Hello"`)},
  16. },
  17. }
  18. resp, err := ChatCompletionsToResponses(req)
  19. require.NoError(t, err)
  20. assert.Equal(t, "gpt-4o", resp.Model)
  21. assert.True(t, resp.Stream) // always forced true
  22. assert.False(t, *resp.Store)
  23. var items []ResponsesInputItem
  24. require.NoError(t, json.Unmarshal(resp.Input, &items))
  25. require.Len(t, items, 1)
  26. assert.Equal(t, "user", items[0].Role)
  27. }
  28. func TestUsageConversionsPreserveCacheWriteTokens(t *testing.T) {
  29. var responsesUsage ResponsesUsage
  30. require.NoError(t, json.Unmarshal([]byte(`{
  31. "input_tokens":1000,
  32. "output_tokens":50,
  33. "input_tokens_details":{"cached_tokens":100,"cache_write_tokens":200}
  34. }`), &responsesUsage))
  35. require.NotNil(t, responsesUsage.InputTokensDetails)
  36. require.Equal(t, 200, responsesUsage.InputTokensDetails.CacheWriteTokens)
  37. chatUsage := chatUsageFromResponsesUsage(&responsesUsage)
  38. require.NotNil(t, chatUsage.PromptTokensDetails)
  39. require.Equal(t, 100, chatUsage.PromptTokensDetails.CachedTokens)
  40. require.Equal(t, 200, chatUsage.PromptTokensDetails.CacheWriteTokens)
  41. roundTrip := ChatUsageToResponsesUsage(chatUsage)
  42. require.NotNil(t, roundTrip.InputTokensDetails)
  43. require.Equal(t, 200, roundTrip.CacheCreationInputTokens)
  44. require.Equal(t, 200, roundTrip.InputTokensDetails.CacheWriteTokens)
  45. }
  46. func TestResponsesUsageNestedCacheWritePresenceOverridesTopLevelAlias(t *testing.T) {
  47. tests := []struct {
  48. name string
  49. nestedJSON string
  50. want int
  51. }{
  52. {name: "explicit zero", nestedJSON: `{"cache_write_tokens":0}`, want: 0},
  53. {name: "nonzero", nestedJSON: `{"cache_write_tokens":7}`, want: 7},
  54. }
  55. for _, tt := range tests {
  56. t.Run(tt.name, func(t *testing.T) {
  57. var usage ResponsesUsage
  58. payload := []byte(`{"input_tokens":20,"output_tokens":2,"cache_creation_input_tokens":19,"input_tokens_details":` + tt.nestedJSON + `}`)
  59. require.NoError(t, json.Unmarshal(payload, &usage))
  60. require.Equal(t, tt.want, usage.CacheCreationInputTokens)
  61. })
  62. }
  63. }
  64. func TestChatCompletionsToResponses_SystemMessage(t *testing.T) {
  65. req := &ChatCompletionsRequest{
  66. Model: "gpt-4o",
  67. Messages: []ChatMessage{
  68. {Role: "system", Content: json.RawMessage(`"You are helpful."`)},
  69. {Role: "user", Content: json.RawMessage(`"Hi"`)},
  70. },
  71. }
  72. resp, err := ChatCompletionsToResponses(req)
  73. require.NoError(t, err)
  74. var items []ResponsesInputItem
  75. require.NoError(t, json.Unmarshal(resp.Input, &items))
  76. require.Len(t, items, 2)
  77. assert.Equal(t, "system", items[0].Role)
  78. assert.Equal(t, "user", items[1].Role)
  79. }
  80. func TestChatCompletionsToResponses_ToolCalls(t *testing.T) {
  81. req := &ChatCompletionsRequest{
  82. Model: "gpt-4o",
  83. Messages: []ChatMessage{
  84. {Role: "user", Content: json.RawMessage(`"Call the function"`)},
  85. {
  86. Role: "assistant",
  87. ToolCalls: []ChatToolCall{
  88. {
  89. ID: "call_1",
  90. Type: "function",
  91. Function: ChatFunctionCall{
  92. Name: "ping",
  93. Arguments: `{"host":"example.com"}`,
  94. },
  95. },
  96. },
  97. },
  98. {
  99. Role: "tool",
  100. ToolCallID: "call_1",
  101. Content: json.RawMessage(`"pong"`),
  102. },
  103. },
  104. Tools: []ChatTool{
  105. {
  106. Type: "function",
  107. Function: &ChatFunction{
  108. Name: "ping",
  109. Description: "Ping a host",
  110. Parameters: json.RawMessage(`{"type":"object"}`),
  111. },
  112. },
  113. },
  114. }
  115. resp, err := ChatCompletionsToResponses(req)
  116. require.NoError(t, err)
  117. var items []ResponsesInputItem
  118. require.NoError(t, json.Unmarshal(resp.Input, &items))
  119. // user + function_call + function_call_output = 3
  120. // (assistant message with empty content + tool_calls → only function_call items emitted)
  121. require.Len(t, items, 3)
  122. // Check function_call item
  123. assert.Equal(t, "function_call", items[1].Type)
  124. assert.Equal(t, "call_1", items[1].CallID)
  125. assert.Empty(t, items[1].ID)
  126. assert.Equal(t, "ping", items[1].Name)
  127. // Check function_call_output item
  128. assert.Equal(t, "function_call_output", items[2].Type)
  129. assert.Equal(t, "call_1", items[2].CallID)
  130. assert.Equal(t, "pong", items[2].Output)
  131. // Check tools
  132. require.Len(t, resp.Tools, 1)
  133. assert.Equal(t, "function", resp.Tools[0].Type)
  134. assert.Equal(t, "ping", resp.Tools[0].Name)
  135. }
  136. func TestChatCompletionsToResponses_ToolStrict(t *testing.T) {
  137. strictTrue := true
  138. strictFalse := false
  139. tests := []struct {
  140. name string
  141. strict *bool
  142. want bool
  143. }{
  144. {name: "defaults omitted strict to false", want: false},
  145. {name: "preserves explicit true", strict: &strictTrue, want: true},
  146. {name: "preserves explicit false", strict: &strictFalse, want: false},
  147. }
  148. for _, tt := range tests {
  149. t.Run(tt.name, func(t *testing.T) {
  150. req := &ChatCompletionsRequest{
  151. Model: "gpt-4o",
  152. Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
  153. Tools: []ChatTool{{
  154. Type: "function",
  155. Function: &ChatFunction{
  156. Name: "lookup",
  157. Strict: tt.strict,
  158. },
  159. }},
  160. }
  161. resp, err := ChatCompletionsToResponses(req)
  162. require.NoError(t, err)
  163. require.Len(t, resp.Tools, 1)
  164. require.NotNil(t, resp.Tools[0].Strict)
  165. assert.Equal(t, tt.want, *resp.Tools[0].Strict)
  166. payload, err := json.Marshal(resp)
  167. require.NoError(t, err)
  168. var serialized struct {
  169. Tools []map[string]json.RawMessage `json:"tools"`
  170. }
  171. require.NoError(t, json.Unmarshal(payload, &serialized))
  172. require.Len(t, serialized.Tools, 1)
  173. strictJSON, ok := serialized.Tools[0]["strict"]
  174. require.True(t, ok, "strict must be present in the Responses payload")
  175. assert.JSONEq(t, string(mustMarshalJSON(t, tt.want)), string(strictJSON))
  176. })
  177. }
  178. }
  179. func TestChatCompletionsToResponses_LegacyFunctionDefaultsStrictFalse(t *testing.T) {
  180. req := &ChatCompletionsRequest{
  181. Model: "gpt-4o",
  182. Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
  183. Functions: []ChatFunction{{
  184. Name: "lookup",
  185. }},
  186. }
  187. resp, err := ChatCompletionsToResponses(req)
  188. require.NoError(t, err)
  189. require.Len(t, resp.Tools, 1)
  190. require.NotNil(t, resp.Tools[0].Strict)
  191. assert.False(t, *resp.Tools[0].Strict)
  192. payload, err := json.Marshal(resp)
  193. require.NoError(t, err)
  194. assert.Contains(t, string(payload), `"strict":false`)
  195. }
  196. func TestResponsesTool_StrictFalseIsSerialized(t *testing.T) {
  197. strict := false
  198. payload, err := json.Marshal(ResponsesTool{
  199. Type: "function",
  200. Strict: &strict,
  201. })
  202. require.NoError(t, err)
  203. assert.JSONEq(t, `{"type":"function","strict":false}`, string(payload))
  204. }
  205. func mustMarshalJSON(t *testing.T, value any) []byte {
  206. t.Helper()
  207. data, err := json.Marshal(value)
  208. require.NoError(t, err)
  209. return data
  210. }
  211. func TestChatCompletionsToResponses_MaxTokens(t *testing.T) {
  212. t.Run("max_tokens", func(t *testing.T) {
  213. maxTokens := 100
  214. req := &ChatCompletionsRequest{
  215. Model: "gpt-4o",
  216. MaxTokens: &maxTokens,
  217. Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
  218. }
  219. resp, err := ChatCompletionsToResponses(req)
  220. require.NoError(t, err)
  221. require.NotNil(t, resp.MaxOutputTokens)
  222. // Below minMaxOutputTokens (128), should be clamped
  223. assert.Equal(t, minMaxOutputTokens, *resp.MaxOutputTokens)
  224. })
  225. t.Run("max_completion_tokens_preferred", func(t *testing.T) {
  226. maxTokens := 100
  227. maxCompletion := 500
  228. req := &ChatCompletionsRequest{
  229. Model: "gpt-4o",
  230. MaxTokens: &maxTokens,
  231. MaxCompletionTokens: &maxCompletion,
  232. Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
  233. }
  234. resp, err := ChatCompletionsToResponses(req)
  235. require.NoError(t, err)
  236. require.NotNil(t, resp.MaxOutputTokens)
  237. assert.Equal(t, 500, *resp.MaxOutputTokens)
  238. })
  239. }
  240. func TestChatCompletionsToResponses_ReasoningEffort(t *testing.T) {
  241. req := &ChatCompletionsRequest{
  242. Model: "gpt-4o",
  243. ReasoningEffort: "high",
  244. Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
  245. }
  246. resp, err := ChatCompletionsToResponses(req)
  247. require.NoError(t, err)
  248. require.NotNil(t, resp.Reasoning)
  249. assert.Equal(t, "high", resp.Reasoning.Effort)
  250. assert.Equal(t, "auto", resp.Reasoning.Summary)
  251. }
  252. func TestChatCompletionsToResponses_ResponseFormatJsonObject(t *testing.T) {
  253. req := &ChatCompletionsRequest{
  254. Model: "gpt-4o",
  255. Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Return JSON"`)}},
  256. ResponseFormat: json.RawMessage(`{"type":"json_object"}`),
  257. }
  258. resp, err := ChatCompletionsToResponses(req)
  259. require.NoError(t, err)
  260. require.NotNil(t, resp.Text)
  261. assert.JSONEq(t, `{"type":"json_object"}`, string(resp.Text.Format))
  262. payload, err := json.Marshal(resp)
  263. require.NoError(t, err)
  264. var serialized struct {
  265. Text ResponsesText `json:"text"`
  266. }
  267. require.NoError(t, json.Unmarshal(payload, &serialized))
  268. assert.JSONEq(t, `{"type":"json_object"}`, string(serialized.Text.Format))
  269. }
  270. func TestChatCompletionsToResponses_ResponseFormatJsonSchema(t *testing.T) {
  271. req := &ChatCompletionsRequest{
  272. Model: "gpt-4o",
  273. Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Return structured JSON"`)}},
  274. ResponseFormat: json.RawMessage(`{
  275. "type":"json_schema",
  276. "json_schema":{
  277. "name":"answer",
  278. "schema":{
  279. "type":"object",
  280. "properties":{"ok":{"type":"boolean"}},
  281. "required":["ok"],
  282. "additionalProperties":false
  283. },
  284. "strict":true
  285. }
  286. }`),
  287. }
  288. resp, err := ChatCompletionsToResponses(req)
  289. require.NoError(t, err)
  290. require.NotNil(t, resp.Text)
  291. assert.JSONEq(t, `{
  292. "type":"json_schema",
  293. "name":"answer",
  294. "schema":{
  295. "type":"object",
  296. "properties":{"ok":{"type":"boolean"}},
  297. "required":["ok"],
  298. "additionalProperties":false
  299. },
  300. "strict":true
  301. }`, string(resp.Text.Format))
  302. }
  303. func TestChatCompletionsToResponses_ImageURL(t *testing.T) {
  304. content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc123"}}]`
  305. req := &ChatCompletionsRequest{
  306. Model: "gpt-4o",
  307. Messages: []ChatMessage{
  308. {Role: "user", Content: json.RawMessage(content)},
  309. },
  310. }
  311. resp, err := ChatCompletionsToResponses(req)
  312. require.NoError(t, err)
  313. var items []ResponsesInputItem
  314. require.NoError(t, json.Unmarshal(resp.Input, &items))
  315. require.Len(t, items, 1)
  316. var parts []ResponsesContentPart
  317. require.NoError(t, json.Unmarshal(items[0].Content, &parts))
  318. require.Len(t, parts, 2)
  319. assert.Equal(t, "input_text", parts[0].Type)
  320. assert.Equal(t, "Describe this", parts[0].Text)
  321. assert.Equal(t, "input_image", parts[1].Type)
  322. assert.Equal(t, "data:image/png;base64,abc123", parts[1].ImageURL)
  323. }
  324. func TestChatCompletionsToResponses_EmptyBase64ImageURLSkipped(t *testing.T) {
  325. content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,"}}]`
  326. req := &ChatCompletionsRequest{
  327. Model: "gpt-4o",
  328. Messages: []ChatMessage{
  329. {Role: "user", Content: json.RawMessage(content)},
  330. },
  331. }
  332. resp, err := ChatCompletionsToResponses(req)
  333. require.NoError(t, err)
  334. var items []ResponsesInputItem
  335. require.NoError(t, json.Unmarshal(resp.Input, &items))
  336. require.Len(t, items, 1)
  337. var parts []ResponsesContentPart
  338. require.NoError(t, json.Unmarshal(items[0].Content, &parts))
  339. require.Len(t, parts, 1)
  340. assert.Equal(t, "input_text", parts[0].Type)
  341. assert.Equal(t, "Describe this", parts[0].Text)
  342. }
  343. func TestChatCompletionsToResponses_WhitespaceOnlyBase64ImageURLSkipped(t *testing.T) {
  344. content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64, "}}]`
  345. req := &ChatCompletionsRequest{
  346. Model: "gpt-4o",
  347. Messages: []ChatMessage{
  348. {Role: "user", Content: json.RawMessage(content)},
  349. },
  350. }
  351. resp, err := ChatCompletionsToResponses(req)
  352. require.NoError(t, err)
  353. var items []ResponsesInputItem
  354. require.NoError(t, json.Unmarshal(resp.Input, &items))
  355. require.Len(t, items, 1)
  356. var parts []ResponsesContentPart
  357. require.NoError(t, json.Unmarshal(items[0].Content, &parts))
  358. require.Len(t, parts, 1)
  359. assert.Equal(t, "input_text", parts[0].Type)
  360. assert.Equal(t, "Describe this", parts[0].Text)
  361. }
  362. func TestChatCompletionsToResponses_FilePartFileData(t *testing.T) {
  363. content := `[{"type":"text","text":"Summarize the attached document"},{"type":"file","file":{"filename":"document.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQ="}}]`
  364. req := &ChatCompletionsRequest{
  365. Model: "gpt-4o",
  366. Messages: []ChatMessage{
  367. {Role: "user", Content: json.RawMessage(content)},
  368. },
  369. }
  370. resp, err := ChatCompletionsToResponses(req)
  371. require.NoError(t, err)
  372. var items []ResponsesInputItem
  373. require.NoError(t, json.Unmarshal(resp.Input, &items))
  374. require.Len(t, items, 1)
  375. var parts []ResponsesContentPart
  376. require.NoError(t, json.Unmarshal(items[0].Content, &parts))
  377. require.Len(t, parts, 2)
  378. assert.Equal(t, "input_text", parts[0].Type)
  379. assert.Equal(t, "Summarize the attached document", parts[0].Text)
  380. assert.Equal(t, "input_file", parts[1].Type)
  381. assert.Equal(t, "document.pdf", parts[1].Filename)
  382. assert.Equal(t, "data:application/pdf;base64,JVBERi0xLjQ=", parts[1].FileData)
  383. assert.Empty(t, parts[1].FileID)
  384. }
  385. func TestChatCompletionsToResponses_FilePartFileID(t *testing.T) {
  386. content := `[{"type":"file","file":{"file_id":"file-abc123"}}]`
  387. req := &ChatCompletionsRequest{
  388. Model: "gpt-4o",
  389. Messages: []ChatMessage{
  390. {Role: "user", Content: json.RawMessage(content)},
  391. },
  392. }
  393. resp, err := ChatCompletionsToResponses(req)
  394. require.NoError(t, err)
  395. var items []ResponsesInputItem
  396. require.NoError(t, json.Unmarshal(resp.Input, &items))
  397. require.Len(t, items, 1)
  398. var parts []ResponsesContentPart
  399. require.NoError(t, json.Unmarshal(items[0].Content, &parts))
  400. require.Len(t, parts, 1)
  401. assert.Equal(t, "input_file", parts[0].Type)
  402. assert.Equal(t, "file-abc123", parts[0].FileID)
  403. assert.Empty(t, parts[0].FileData)
  404. }
  405. func TestChatCompletionsToResponses_EmptyFilePartSkipped(t *testing.T) {
  406. // A file part with neither file_data nor file_id carries nothing the
  407. // Responses API can use; dropping it (like empty image URLs) avoids an
  408. // upstream 400 on an empty input_file part.
  409. content := `[{"type":"text","text":"Describe this"},{"type":"file","file":{"filename":"empty.pdf"}}]`
  410. req := &ChatCompletionsRequest{
  411. Model: "gpt-4o",
  412. Messages: []ChatMessage{
  413. {Role: "user", Content: json.RawMessage(content)},
  414. },
  415. }
  416. resp, err := ChatCompletionsToResponses(req)
  417. require.NoError(t, err)
  418. var items []ResponsesInputItem
  419. require.NoError(t, json.Unmarshal(resp.Input, &items))
  420. require.Len(t, items, 1)
  421. var parts []ResponsesContentPart
  422. require.NoError(t, json.Unmarshal(items[0].Content, &parts))
  423. require.Len(t, parts, 1)
  424. assert.Equal(t, "input_text", parts[0].Type)
  425. }
  426. func TestChatCompletionsToResponses_EmptyContentNeverNull(t *testing.T) {
  427. // Regression for #2515: the upstream Responses API rejects an input item
  428. // whose content field is JSON null. Any chat-completions message that
  429. // yields no usable content parts must serialize content as a string.
  430. cases := []struct {
  431. name string
  432. content json.RawMessage
  433. }{
  434. {"null content", json.RawMessage(`null`)},
  435. {"empty array content", json.RawMessage(`[]`)},
  436. {"only empty text part", json.RawMessage(`[{"type":"text","text":""}]`)},
  437. {"only empty base64 image part", json.RawMessage(`[{"type":"image_url","image_url":{"url":"data:image/png;base64,"}}]`)},
  438. }
  439. for _, tc := range cases {
  440. t.Run(tc.name, func(t *testing.T) {
  441. req := &ChatCompletionsRequest{
  442. Model: "gpt-5.5",
  443. Messages: []ChatMessage{
  444. {Role: "user", Content: tc.content},
  445. },
  446. }
  447. resp, err := ChatCompletionsToResponses(req)
  448. require.NoError(t, err)
  449. assert.NotContains(t, string(resp.Input), `"content":null`,
  450. "converted input must not contain a null content field")
  451. var items []ResponsesInputItem
  452. require.NoError(t, json.Unmarshal(resp.Input, &items))
  453. require.Len(t, items, 1)
  454. assert.Equal(t, `""`, string(items[0].Content),
  455. "content must be an empty string, not null")
  456. })
  457. }
  458. }
  459. func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMessageText(t *testing.T) {
  460. content := json.RawMessage(`""`)
  461. resp := &ChatCompletionsResponse{
  462. ID: "chatcmpl_deepseek_reasoning_only",
  463. Object: "chat.completion",
  464. Model: "deepseek-reasoner",
  465. Choices: []ChatChoice{{
  466. Index: 0,
  467. Message: ChatMessage{
  468. Role: "assistant",
  469. Content: content,
  470. ReasoningContent: "reasoning-only answer",
  471. },
  472. FinishReason: "stop",
  473. }},
  474. }
  475. out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil)
  476. require.Len(t, out.Output, 2)
  477. require.Equal(t, "reasoning", out.Output[0].Type)
  478. require.Equal(t, "message", out.Output[1].Type)
  479. require.Len(t, out.Output[1].Content, 1)
  480. assert.Equal(t, "reasoning-only answer", out.Output[1].Content[0].Text)
  481. }
  482. func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFallbackToMessageText(t *testing.T) {
  483. content := json.RawMessage(`""`)
  484. resp := &ChatCompletionsResponse{
  485. ID: "chatcmpl_deepseek_reasoning_tool",
  486. Object: "chat.completion",
  487. Model: "deepseek-reasoner",
  488. Choices: []ChatChoice{{
  489. Index: 0,
  490. Message: ChatMessage{
  491. Role: "assistant",
  492. Content: content,
  493. ReasoningContent: "call a tool",
  494. ToolCalls: []ChatToolCall{{
  495. ID: "call_a",
  496. Type: "function",
  497. Function: ChatFunctionCall{
  498. Name: "exec",
  499. Arguments: `{}`,
  500. },
  501. }},
  502. },
  503. FinishReason: "tool_calls",
  504. }},
  505. }
  506. out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil)
  507. require.Len(t, out.Output, 2)
  508. require.Equal(t, "reasoning", out.Output[0].Type)
  509. require.Equal(t, "function_call", out.Output[1].Type)
  510. assert.Equal(t, "exec", out.Output[1].Name)
  511. }
  512. func TestChatCompletionsToResponses_SystemArrayContent(t *testing.T) {
  513. req := &ChatCompletionsRequest{
  514. Model: "gpt-4o",
  515. Messages: []ChatMessage{
  516. {Role: "system", Content: json.RawMessage(`[{"type":"text","text":"You are a careful visual assistant."}]`)},
  517. {Role: "user", Content: json.RawMessage(`[{"type":"text","text":"Describe this image"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc123"}}]`)},
  518. },
  519. }
  520. resp, err := ChatCompletionsToResponses(req)
  521. require.NoError(t, err)
  522. var items []ResponsesInputItem
  523. require.NoError(t, json.Unmarshal(resp.Input, &items))
  524. require.Len(t, items, 2)
  525. var systemParts []ResponsesContentPart
  526. require.NoError(t, json.Unmarshal(items[0].Content, &systemParts))
  527. require.Len(t, systemParts, 1)
  528. assert.Equal(t, "input_text", systemParts[0].Type)
  529. assert.Equal(t, "You are a careful visual assistant.", systemParts[0].Text)
  530. var userParts []ResponsesContentPart
  531. require.NoError(t, json.Unmarshal(items[1].Content, &userParts))
  532. require.Len(t, userParts, 2)
  533. assert.Equal(t, "input_image", userParts[1].Type)
  534. assert.Equal(t, "data:image/png;base64,abc123", userParts[1].ImageURL)
  535. }
  536. func TestChatCompletionsToResponses_LegacyFunctions(t *testing.T) {
  537. req := &ChatCompletionsRequest{
  538. Model: "gpt-4o",
  539. Messages: []ChatMessage{
  540. {Role: "user", Content: json.RawMessage(`"Hi"`)},
  541. },
  542. Functions: []ChatFunction{
  543. {
  544. Name: "get_weather",
  545. Description: "Get weather",
  546. Parameters: json.RawMessage(`{"type":"object"}`),
  547. },
  548. },
  549. FunctionCall: json.RawMessage(`{"name":"get_weather"}`),
  550. }
  551. resp, err := ChatCompletionsToResponses(req)
  552. require.NoError(t, err)
  553. require.Len(t, resp.Tools, 1)
  554. assert.Equal(t, "function", resp.Tools[0].Type)
  555. assert.Equal(t, "get_weather", resp.Tools[0].Name)
  556. // tool_choice should be converted
  557. require.NotNil(t, resp.ToolChoice)
  558. var tc map[string]any
  559. require.NoError(t, json.Unmarshal(resp.ToolChoice, &tc))
  560. assert.Equal(t, "function", tc["type"])
  561. assert.Equal(t, "get_weather", tc["name"])
  562. assert.NotContains(t, tc, "function")
  563. }
  564. func TestChatCompletionsToResponses_ServiceTier(t *testing.T) {
  565. req := &ChatCompletionsRequest{
  566. Model: "gpt-4o",
  567. ServiceTier: "flex",
  568. Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
  569. }
  570. resp, err := ChatCompletionsToResponses(req)
  571. require.NoError(t, err)
  572. assert.Equal(t, "flex", resp.ServiceTier)
  573. }
  574. func TestChatCompletionsToResponses_ParallelToolCalls(t *testing.T) {
  575. for _, value := range []bool{false, true} {
  576. req := &ChatCompletionsRequest{
  577. Model: "gpt-4o",
  578. ParallelToolCalls: &value,
  579. Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
  580. }
  581. resp, err := ChatCompletionsToResponses(req)
  582. require.NoError(t, err)
  583. require.NotNil(t, resp.ParallelToolCalls)
  584. assert.Equal(t, value, *resp.ParallelToolCalls)
  585. payload, err := json.Marshal(resp)
  586. require.NoError(t, err)
  587. assert.Contains(t, string(payload), `"parallel_tool_calls":`+string(mustMarshalJSON(t, value)))
  588. }
  589. }
  590. // ---------------------------------------------------------------------------
  591. // temperature / top_p stripping for reasoning models
  592. // ---------------------------------------------------------------------------
  593. func TestChatCompletionsToResponses_TemperatureStrippedForReasoningModel(t *testing.T) {
  594. temp := 0.7
  595. req := &ChatCompletionsRequest{
  596. Model: "gpt-5.2",
  597. Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
  598. Temperature: &temp,
  599. TopP: &temp,
  600. }
  601. resp, err := ChatCompletionsToResponses(req)
  602. require.NoError(t, err)
  603. assert.Nil(t, resp.Temperature, "reasoning model: temperature must be stripped")
  604. assert.Nil(t, resp.TopP, "reasoning model: top_p must be stripped")
  605. // Must not appear in the serialised request body sent to the upstream.
  606. b, err := json.Marshal(resp)
  607. require.NoError(t, err)
  608. assert.NotContains(t, string(b), `"temperature"`)
  609. assert.NotContains(t, string(b), `"top_p"`)
  610. }
  611. func TestChatCompletionsToResponses_TemperaturePreservedForNonReasoningModel(t *testing.T) {
  612. temp := 0.7
  613. req := &ChatCompletionsRequest{
  614. Model: "gpt-4o",
  615. Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
  616. Temperature: &temp,
  617. TopP: &temp,
  618. }
  619. resp, err := ChatCompletionsToResponses(req)
  620. require.NoError(t, err)
  621. require.NotNil(t, resp.Temperature, "non-reasoning model: temperature must be preserved")
  622. assert.InDelta(t, 0.7, *resp.Temperature, 1e-9)
  623. require.NotNil(t, resp.TopP, "non-reasoning model: top_p must be preserved")
  624. assert.InDelta(t, 0.7, *resp.TopP, 1e-9)
  625. }
  626. func TestChatCompletionsToResponses_AssistantWithTextAndToolCalls(t *testing.T) {
  627. req := &ChatCompletionsRequest{
  628. Model: "gpt-4o",
  629. Messages: []ChatMessage{
  630. {Role: "user", Content: json.RawMessage(`"Do something"`)},
  631. {
  632. Role: "assistant",
  633. Content: json.RawMessage(`"Let me call a function."`),
  634. ToolCalls: []ChatToolCall{
  635. {
  636. ID: "call_abc",
  637. Type: "function",
  638. Function: ChatFunctionCall{
  639. Name: "do_thing",
  640. Arguments: `{}`,
  641. },
  642. },
  643. },
  644. },
  645. },
  646. }
  647. resp, err := ChatCompletionsToResponses(req)
  648. require.NoError(t, err)
  649. var items []ResponsesInputItem
  650. require.NoError(t, json.Unmarshal(resp.Input, &items))
  651. // user + assistant message (with text) + function_call
  652. require.Len(t, items, 3)
  653. assert.Equal(t, "user", items[0].Role)
  654. assert.Equal(t, "assistant", items[1].Role)
  655. assert.Equal(t, "function_call", items[2].Type)
  656. assert.Empty(t, items[2].ID)
  657. }
  658. func TestChatCompletionsToResponses_AssistantArrayContentPreserved(t *testing.T) {
  659. req := &ChatCompletionsRequest{
  660. Model: "gpt-4o",
  661. Messages: []ChatMessage{
  662. {Role: "user", Content: json.RawMessage(`"Hi"`)},
  663. {Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"A"},{"type":"text","text":"B"}]`)},
  664. },
  665. }
  666. resp, err := ChatCompletionsToResponses(req)
  667. require.NoError(t, err)
  668. var items []ResponsesInputItem
  669. require.NoError(t, json.Unmarshal(resp.Input, &items))
  670. require.Len(t, items, 2)
  671. assert.Equal(t, "assistant", items[1].Role)
  672. var parts []ResponsesContentPart
  673. require.NoError(t, json.Unmarshal(items[1].Content, &parts))
  674. require.Len(t, parts, 1)
  675. assert.Equal(t, "output_text", parts[0].Type)
  676. assert.Equal(t, "AB", parts[0].Text)
  677. }
  678. func TestChatCompletionsToResponses_AssistantThinkingTagPreserved(t *testing.T) {
  679. req := &ChatCompletionsRequest{
  680. Model: "gpt-4o",
  681. Messages: []ChatMessage{
  682. {Role: "user", Content: json.RawMessage(`"Hi"`)},
  683. {Role: "assistant", Content: json.RawMessage(`[{"type":"thinking","thinking":"internal plan"},{"type":"text","text":"final answer"}]`)},
  684. },
  685. }
  686. resp, err := ChatCompletionsToResponses(req)
  687. require.NoError(t, err)
  688. var items []ResponsesInputItem
  689. require.NoError(t, json.Unmarshal(resp.Input, &items))
  690. require.Len(t, items, 2)
  691. var parts []ResponsesContentPart
  692. require.NoError(t, json.Unmarshal(items[1].Content, &parts))
  693. require.Len(t, parts, 1)
  694. assert.Equal(t, "output_text", parts[0].Type)
  695. assert.Contains(t, parts[0].Text, "<thinking>internal plan</thinking>")
  696. assert.Contains(t, parts[0].Text, "final answer")
  697. }
  698. func TestChatCompletionsToResponses_AssistantReasoningContentPreserved(t *testing.T) {
  699. req := &ChatCompletionsRequest{
  700. Model: "gpt-4o",
  701. Messages: []ChatMessage{
  702. {Role: "user", Content: json.RawMessage(`"Hi"`)},
  703. {
  704. Role: "assistant",
  705. ReasoningContent: "internal plan",
  706. Content: json.RawMessage(`"final answer"`),
  707. },
  708. },
  709. }
  710. resp, err := ChatCompletionsToResponses(req)
  711. require.NoError(t, err)
  712. var items []ResponsesInputItem
  713. require.NoError(t, json.Unmarshal(resp.Input, &items))
  714. require.Len(t, items, 2)
  715. var parts []ResponsesContentPart
  716. require.NoError(t, json.Unmarshal(items[1].Content, &parts))
  717. require.Len(t, parts, 1)
  718. assert.Equal(t, "output_text", parts[0].Type)
  719. assert.Contains(t, parts[0].Text, "<thinking>internal plan</thinking>")
  720. assert.Contains(t, parts[0].Text, "final answer")
  721. }
  722. // ---------------------------------------------------------------------------
  723. // ResponsesToChatCompletions tests
  724. // ---------------------------------------------------------------------------
  725. func TestResponsesToChatCompletions_BasicText(t *testing.T) {
  726. resp := &ResponsesResponse{
  727. ID: "resp_123",
  728. Status: "completed",
  729. Output: []ResponsesOutput{
  730. {
  731. Type: "message",
  732. Content: []ResponsesContentPart{
  733. {Type: "output_text", Text: "Hello, world!"},
  734. },
  735. },
  736. },
  737. Usage: &ResponsesUsage{
  738. InputTokens: 10,
  739. OutputTokens: 5,
  740. TotalTokens: 15,
  741. },
  742. }
  743. chat := ResponsesToChatCompletions(resp, "gpt-4o")
  744. assert.Equal(t, "chat.completion", chat.Object)
  745. assert.Equal(t, "gpt-4o", chat.Model)
  746. require.Len(t, chat.Choices, 1)
  747. assert.Equal(t, "stop", chat.Choices[0].FinishReason)
  748. var content string
  749. require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &content))
  750. assert.Equal(t, "Hello, world!", content)
  751. require.NotNil(t, chat.Usage)
  752. assert.Equal(t, 10, chat.Usage.PromptTokens)
  753. assert.Equal(t, 5, chat.Usage.CompletionTokens)
  754. assert.Equal(t, 15, chat.Usage.TotalTokens)
  755. }
  756. func TestResponsesToChatCompletions_ToolCalls(t *testing.T) {
  757. resp := &ResponsesResponse{
  758. ID: "resp_456",
  759. Status: "completed",
  760. Output: []ResponsesOutput{
  761. {
  762. Type: "function_call",
  763. CallID: "call_xyz",
  764. Name: "get_weather",
  765. Arguments: `{"city":"NYC"}`,
  766. },
  767. },
  768. }
  769. chat := ResponsesToChatCompletions(resp, "gpt-4o")
  770. require.Len(t, chat.Choices, 1)
  771. assert.Equal(t, "tool_calls", chat.Choices[0].FinishReason)
  772. msg := chat.Choices[0].Message
  773. require.Len(t, msg.ToolCalls, 1)
  774. assert.Equal(t, "call_xyz", msg.ToolCalls[0].ID)
  775. assert.Equal(t, "function", msg.ToolCalls[0].Type)
  776. assert.Equal(t, "get_weather", msg.ToolCalls[0].Function.Name)
  777. assert.Equal(t, `{"city":"NYC"}`, msg.ToolCalls[0].Function.Arguments)
  778. }
  779. func TestResponsesToChatCompletions_Reasoning(t *testing.T) {
  780. resp := &ResponsesResponse{
  781. ID: "resp_789",
  782. Status: "completed",
  783. Output: []ResponsesOutput{
  784. {
  785. Type: "reasoning",
  786. Summary: []ResponsesSummary{
  787. {Type: "summary_text", Text: "I thought about it."},
  788. },
  789. },
  790. {
  791. Type: "message",
  792. Content: []ResponsesContentPart{
  793. {Type: "output_text", Text: "The answer is 42."},
  794. },
  795. },
  796. },
  797. }
  798. chat := ResponsesToChatCompletions(resp, "gpt-4o")
  799. require.Len(t, chat.Choices, 1)
  800. var content string
  801. require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &content))
  802. assert.Equal(t, "The answer is 42.", content)
  803. assert.Equal(t, "I thought about it.", chat.Choices[0].Message.ReasoningContent)
  804. }
  805. func TestChatCompletionsToResponses_ToolArrayContent(t *testing.T) {
  806. req := &ChatCompletionsRequest{
  807. Model: "gpt-4o",
  808. Messages: []ChatMessage{
  809. {Role: "user", Content: json.RawMessage(`"Use the tool"`)},
  810. {
  811. Role: "assistant",
  812. ToolCalls: []ChatToolCall{
  813. {
  814. ID: "call_1",
  815. Type: "function",
  816. Function: ChatFunctionCall{
  817. Name: "inspect_image",
  818. Arguments: `{}`,
  819. },
  820. },
  821. },
  822. },
  823. {
  824. Role: "tool",
  825. ToolCallID: "call_1",
  826. Content: json.RawMessage(
  827. `[{"type":"text","text":"image width: 100"},{"type":"image_url","image_url":{"url":"data:image/png;base64,ignored"}},{"type":"text","text":"; image height: 200"}]`,
  828. ),
  829. },
  830. },
  831. }
  832. resp, err := ChatCompletionsToResponses(req)
  833. require.NoError(t, err)
  834. var items []ResponsesInputItem
  835. require.NoError(t, json.Unmarshal(resp.Input, &items))
  836. require.Len(t, items, 3)
  837. assert.Equal(t, "function_call_output", items[2].Type)
  838. assert.Equal(t, "call_1", items[2].CallID)
  839. assert.Equal(t, "image width: 100; image height: 200", items[2].Output)
  840. }
  841. func TestResponsesToChatCompletions_Incomplete(t *testing.T) {
  842. resp := &ResponsesResponse{
  843. ID: "resp_inc",
  844. Status: "incomplete",
  845. IncompleteDetails: &ResponsesIncompleteDetails{Reason: "max_output_tokens"},
  846. Output: []ResponsesOutput{
  847. {
  848. Type: "message",
  849. Content: []ResponsesContentPart{
  850. {Type: "output_text", Text: "partial..."},
  851. },
  852. },
  853. },
  854. }
  855. chat := ResponsesToChatCompletions(resp, "gpt-4o")
  856. require.Len(t, chat.Choices, 1)
  857. assert.Equal(t, "length", chat.Choices[0].FinishReason)
  858. }
  859. func TestResponsesToChatCompletions_CachedTokens(t *testing.T) {
  860. resp := &ResponsesResponse{
  861. ID: "resp_cache",
  862. Status: "completed",
  863. Output: []ResponsesOutput{
  864. {
  865. Type: "message",
  866. Content: []ResponsesContentPart{{Type: "output_text", Text: "cached"}},
  867. },
  868. },
  869. Usage: &ResponsesUsage{
  870. InputTokens: 100,
  871. OutputTokens: 10,
  872. TotalTokens: 110,
  873. InputTokensDetails: &ResponsesInputTokensDetails{
  874. CachedTokens: 80,
  875. },
  876. },
  877. }
  878. chat := ResponsesToChatCompletions(resp, "gpt-4o")
  879. require.NotNil(t, chat.Usage)
  880. require.NotNil(t, chat.Usage.PromptTokensDetails)
  881. assert.Equal(t, 80, chat.Usage.PromptTokensDetails.CachedTokens)
  882. }
  883. func TestResponsesToChatCompletions_ReasoningTokens(t *testing.T) {
  884. resp := &ResponsesResponse{
  885. ID: "resp_reasoning",
  886. Status: "completed",
  887. Output: []ResponsesOutput{
  888. {
  889. Type: "message",
  890. Content: []ResponsesContentPart{{Type: "output_text", Text: "ping"}},
  891. },
  892. },
  893. Usage: &ResponsesUsage{
  894. InputTokens: 24,
  895. OutputTokens: 33,
  896. TotalTokens: 57,
  897. OutputTokensDetails: &ResponsesOutputTokensDetails{
  898. ReasoningTokens: 32,
  899. },
  900. },
  901. }
  902. chat := ResponsesToChatCompletions(resp, "gpt-5.5")
  903. require.NotNil(t, chat.Usage)
  904. assert.Equal(t, 33, chat.Usage.CompletionTokens)
  905. require.NotNil(t, chat.Usage.CompletionTokensDetails)
  906. assert.Equal(t, 32, chat.Usage.CompletionTokensDetails.ReasoningTokens)
  907. }
  908. func TestResponsesToChatCompletions_AllTokenDetailsPassThrough(t *testing.T) {
  909. // Covers the full OpenAI CompletionUsage detail field set so future audio
  910. // and prediction-outputs responses propagate without further changes.
  911. resp := &ResponsesResponse{
  912. ID: "resp_full_details",
  913. Status: "completed",
  914. Output: []ResponsesOutput{
  915. {
  916. Type: "message",
  917. Content: []ResponsesContentPart{{Type: "output_text", Text: "x"}},
  918. },
  919. },
  920. Usage: &ResponsesUsage{
  921. InputTokens: 100,
  922. OutputTokens: 50,
  923. TotalTokens: 150,
  924. InputTokensDetails: &ResponsesInputTokensDetails{
  925. CachedTokens: 60,
  926. AudioTokens: 4,
  927. },
  928. OutputTokensDetails: &ResponsesOutputTokensDetails{
  929. ReasoningTokens: 30,
  930. AudioTokens: 2,
  931. AcceptedPredictionTokens: 10,
  932. RejectedPredictionTokens: 3,
  933. },
  934. },
  935. }
  936. chat := ResponsesToChatCompletions(resp, "gpt-5.5")
  937. require.NotNil(t, chat.Usage)
  938. require.NotNil(t, chat.Usage.PromptTokensDetails)
  939. assert.Equal(t, 60, chat.Usage.PromptTokensDetails.CachedTokens)
  940. assert.Equal(t, 4, chat.Usage.PromptTokensDetails.AudioTokens)
  941. require.NotNil(t, chat.Usage.CompletionTokensDetails)
  942. assert.Equal(t, 30, chat.Usage.CompletionTokensDetails.ReasoningTokens)
  943. assert.Equal(t, 2, chat.Usage.CompletionTokensDetails.AudioTokens)
  944. assert.Equal(t, 10, chat.Usage.CompletionTokensDetails.AcceptedPredictionTokens)
  945. assert.Equal(t, 3, chat.Usage.CompletionTokensDetails.RejectedPredictionTokens)
  946. raw, err := json.Marshal(chat.Usage)
  947. require.NoError(t, err)
  948. assert.Contains(t, string(raw), `"prompt_tokens_details"`)
  949. assert.Contains(t, string(raw), `"completion_tokens_details"`)
  950. assert.Contains(t, string(raw), `"reasoning_tokens":30`)
  951. assert.Contains(t, string(raw), `"accepted_prediction_tokens":10`)
  952. }
  953. func TestResponsesToChatCompletions_NoReasoningTokensWhenZero(t *testing.T) {
  954. // Non-reasoning models do not return reasoning_tokens. The mapping must
  955. // omit completion_tokens_details entirely rather than emitting a zero-valued
  956. // field, so non-reasoning responses stay clean.
  957. resp := &ResponsesResponse{
  958. ID: "resp_no_reasoning",
  959. Status: "completed",
  960. Output: []ResponsesOutput{
  961. {
  962. Type: "message",
  963. Content: []ResponsesContentPart{{Type: "output_text", Text: "hi"}},
  964. },
  965. },
  966. Usage: &ResponsesUsage{
  967. InputTokens: 10,
  968. OutputTokens: 5,
  969. TotalTokens: 15,
  970. OutputTokensDetails: &ResponsesOutputTokensDetails{
  971. ReasoningTokens: 0,
  972. },
  973. },
  974. }
  975. chat := ResponsesToChatCompletions(resp, "gpt-4o")
  976. require.NotNil(t, chat.Usage)
  977. assert.Nil(t, chat.Usage.CompletionTokensDetails)
  978. raw, err := json.Marshal(chat.Usage)
  979. require.NoError(t, err)
  980. assert.NotContains(t, string(raw), "completion_tokens_details")
  981. assert.NotContains(t, string(raw), "reasoning_tokens")
  982. }
  983. func TestResponsesToChatCompletions_WebSearch(t *testing.T) {
  984. resp := &ResponsesResponse{
  985. ID: "resp_ws",
  986. Status: "completed",
  987. Output: []ResponsesOutput{
  988. {
  989. Type: "web_search_call",
  990. Action: &WebSearchAction{Type: "search", Query: "test"},
  991. },
  992. {
  993. Type: "message",
  994. Content: []ResponsesContentPart{{Type: "output_text", Text: "search results"}},
  995. },
  996. },
  997. }
  998. chat := ResponsesToChatCompletions(resp, "gpt-4o")
  999. require.Len(t, chat.Choices, 1)
  1000. assert.Equal(t, "stop", chat.Choices[0].FinishReason)
  1001. var content string
  1002. require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &content))
  1003. assert.Equal(t, "search results", content)
  1004. }
  1005. // ---------------------------------------------------------------------------
  1006. // Streaming: ResponsesEventToChatChunks tests
  1007. // ---------------------------------------------------------------------------
  1008. func TestResponsesEventToChatChunks_TextDelta(t *testing.T) {
  1009. state := NewResponsesEventToChatState()
  1010. state.Model = "gpt-4o"
  1011. // response.created → role chunk
  1012. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1013. Type: "response.created",
  1014. Response: &ResponsesResponse{
  1015. ID: "resp_stream",
  1016. },
  1017. }, state)
  1018. require.Len(t, chunks, 1)
  1019. assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role)
  1020. assert.True(t, state.SentRole)
  1021. // response.output_text.delta → content chunk
  1022. chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1023. Type: "response.output_text.delta",
  1024. Delta: "Hello",
  1025. }, state)
  1026. require.Len(t, chunks, 1)
  1027. require.NotNil(t, chunks[0].Choices[0].Delta.Content)
  1028. assert.Equal(t, "Hello", *chunks[0].Choices[0].Delta.Content)
  1029. }
  1030. func TestResponsesEventToChatChunks_ToolCallDelta(t *testing.T) {
  1031. state := NewResponsesEventToChatState()
  1032. state.Model = "gpt-4o"
  1033. state.SentRole = true
  1034. // response.output_item.added (function_call) — output_index=1 (e.g. after a message item at 0)
  1035. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1036. Type: "response.output_item.added",
  1037. OutputIndex: 1,
  1038. Item: &ResponsesOutput{
  1039. Type: "function_call",
  1040. CallID: "call_1",
  1041. Name: "get_weather",
  1042. },
  1043. }, state)
  1044. require.Len(t, chunks, 1)
  1045. require.Len(t, chunks[0].Choices[0].Delta.ToolCalls, 1)
  1046. tc := chunks[0].Choices[0].Delta.ToolCalls[0]
  1047. assert.Equal(t, "call_1", tc.ID)
  1048. assert.Equal(t, "get_weather", tc.Function.Name)
  1049. require.NotNil(t, tc.Index)
  1050. assert.Equal(t, 0, *tc.Index)
  1051. // response.function_call_arguments.delta — uses output_index (NOT call_id) to find tool
  1052. chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1053. Type: "response.function_call_arguments.delta",
  1054. OutputIndex: 1, // matches the output_index from output_item.added above
  1055. Delta: `{"city":`,
  1056. }, state)
  1057. require.Len(t, chunks, 1)
  1058. tc = chunks[0].Choices[0].Delta.ToolCalls[0]
  1059. require.NotNil(t, tc.Index)
  1060. assert.Equal(t, 0, *tc.Index, "argument delta must use same index as the tool call")
  1061. assert.Equal(t, `{"city":`, tc.Function.Arguments)
  1062. // Add a second function call at output_index=2
  1063. chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1064. Type: "response.output_item.added",
  1065. OutputIndex: 2,
  1066. Item: &ResponsesOutput{
  1067. Type: "function_call",
  1068. CallID: "call_2",
  1069. Name: "get_time",
  1070. },
  1071. }, state)
  1072. require.Len(t, chunks, 1)
  1073. tc = chunks[0].Choices[0].Delta.ToolCalls[0]
  1074. require.NotNil(t, tc.Index)
  1075. assert.Equal(t, 1, *tc.Index, "second tool call should get index 1")
  1076. // Argument delta for second tool call
  1077. chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1078. Type: "response.function_call_arguments.delta",
  1079. OutputIndex: 2,
  1080. Delta: `{"tz":"UTC"}`,
  1081. }, state)
  1082. require.Len(t, chunks, 1)
  1083. tc = chunks[0].Choices[0].Delta.ToolCalls[0]
  1084. require.NotNil(t, tc.Index)
  1085. assert.Equal(t, 1, *tc.Index, "second tool arg delta must use index 1")
  1086. // Argument delta for first tool call (interleaved)
  1087. chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1088. Type: "response.function_call_arguments.delta",
  1089. OutputIndex: 1,
  1090. Delta: `"Tokyo"}`,
  1091. }, state)
  1092. require.Len(t, chunks, 1)
  1093. tc = chunks[0].Choices[0].Delta.ToolCalls[0]
  1094. require.NotNil(t, tc.Index)
  1095. assert.Equal(t, 0, *tc.Index, "first tool arg delta must still use index 0")
  1096. }
  1097. func TestResponsesEventToChatChunks_Completed(t *testing.T) {
  1098. state := NewResponsesEventToChatState()
  1099. state.Model = "gpt-4o"
  1100. state.IncludeUsage = true
  1101. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1102. Type: "response.completed",
  1103. Response: &ResponsesResponse{
  1104. Status: "completed",
  1105. Usage: &ResponsesUsage{
  1106. InputTokens: 50,
  1107. OutputTokens: 20,
  1108. TotalTokens: 70,
  1109. InputTokensDetails: &ResponsesInputTokensDetails{
  1110. CachedTokens: 30,
  1111. },
  1112. },
  1113. },
  1114. }, state)
  1115. // finish chunk + usage chunk
  1116. require.Len(t, chunks, 2)
  1117. // First chunk: finish_reason
  1118. require.NotNil(t, chunks[0].Choices[0].FinishReason)
  1119. assert.Equal(t, "stop", *chunks[0].Choices[0].FinishReason)
  1120. // Second chunk: usage
  1121. require.NotNil(t, chunks[1].Usage)
  1122. assert.Equal(t, 50, chunks[1].Usage.PromptTokens)
  1123. assert.Equal(t, 20, chunks[1].Usage.CompletionTokens)
  1124. assert.Equal(t, 70, chunks[1].Usage.TotalTokens)
  1125. require.NotNil(t, chunks[1].Usage.PromptTokensDetails)
  1126. assert.Equal(t, 30, chunks[1].Usage.PromptTokensDetails.CachedTokens)
  1127. }
  1128. func TestResponsesEventToChatChunks_CompletedWithReasoningTokens(t *testing.T) {
  1129. state := NewResponsesEventToChatState()
  1130. state.Model = "gpt-5.5"
  1131. state.IncludeUsage = true
  1132. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1133. Type: "response.completed",
  1134. Response: &ResponsesResponse{
  1135. Status: "completed",
  1136. Usage: &ResponsesUsage{
  1137. InputTokens: 24,
  1138. OutputTokens: 33,
  1139. TotalTokens: 57,
  1140. OutputTokensDetails: &ResponsesOutputTokensDetails{
  1141. ReasoningTokens: 32,
  1142. },
  1143. },
  1144. },
  1145. }, state)
  1146. require.Len(t, chunks, 2)
  1147. require.NotNil(t, chunks[1].Usage)
  1148. require.NotNil(t, chunks[1].Usage.CompletionTokensDetails)
  1149. assert.Equal(t, 32, chunks[1].Usage.CompletionTokensDetails.ReasoningTokens)
  1150. }
  1151. func TestResponsesEventToChatChunks_ResponseDone(t *testing.T) {
  1152. state := NewResponsesEventToChatState()
  1153. state.Model = "gpt-4o"
  1154. state.IncludeUsage = true
  1155. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1156. Type: "response.done",
  1157. Response: &ResponsesResponse{
  1158. Status: "completed",
  1159. Usage: &ResponsesUsage{InputTokens: 13, OutputTokens: 7},
  1160. },
  1161. }, state)
  1162. require.Len(t, chunks, 2)
  1163. require.NotNil(t, chunks[0].Choices[0].FinishReason)
  1164. assert.Equal(t, "stop", *chunks[0].Choices[0].FinishReason)
  1165. require.NotNil(t, chunks[1].Usage)
  1166. assert.Equal(t, 13, chunks[1].Usage.PromptTokens)
  1167. assert.Equal(t, 7, chunks[1].Usage.CompletionTokens)
  1168. assert.Nil(t, FinalizeResponsesChatStream(state))
  1169. }
  1170. func TestResponsesEventToChatChunks_TopLevelTerminalUsage(t *testing.T) {
  1171. state := NewResponsesEventToChatState()
  1172. state.Model = "gpt-4o"
  1173. state.IncludeUsage = true
  1174. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1175. Type: "response.completed",
  1176. Response: &ResponsesResponse{
  1177. Status: "completed",
  1178. },
  1179. Usage: &ResponsesUsage{
  1180. InputTokens: 21,
  1181. OutputTokens: 9,
  1182. InputTokensDetails: &ResponsesInputTokensDetails{
  1183. CachedTokens: 4,
  1184. },
  1185. },
  1186. }, state)
  1187. require.Len(t, chunks, 2)
  1188. require.NotNil(t, chunks[1].Usage)
  1189. assert.Equal(t, 21, chunks[1].Usage.PromptTokens)
  1190. assert.Equal(t, 9, chunks[1].Usage.CompletionTokens)
  1191. require.NotNil(t, chunks[1].Usage.PromptTokensDetails)
  1192. assert.Equal(t, 4, chunks[1].Usage.PromptTokensDetails.CachedTokens)
  1193. }
  1194. func TestResponsesEventToChatChunks_ResponseDoneIncomplete(t *testing.T) {
  1195. state := NewResponsesEventToChatState()
  1196. state.Model = "gpt-4o"
  1197. state.IncludeUsage = true
  1198. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1199. Type: "response.done",
  1200. Response: &ResponsesResponse{
  1201. Status: "incomplete",
  1202. IncompleteDetails: &ResponsesIncompleteDetails{Reason: "max_output_tokens"},
  1203. Usage: &ResponsesUsage{InputTokens: 13, OutputTokens: 7},
  1204. },
  1205. }, state)
  1206. require.Len(t, chunks, 2)
  1207. require.NotNil(t, chunks[0].Choices[0].FinishReason)
  1208. assert.Equal(t, "length", *chunks[0].Choices[0].FinishReason)
  1209. require.NotNil(t, chunks[1].Usage)
  1210. assert.Equal(t, 13, chunks[1].Usage.PromptTokens)
  1211. assert.Equal(t, 7, chunks[1].Usage.CompletionTokens)
  1212. assert.Nil(t, FinalizeResponsesChatStream(state))
  1213. }
  1214. func TestResponsesEventToChatChunks_CompletedWithToolCalls(t *testing.T) {
  1215. state := NewResponsesEventToChatState()
  1216. state.Model = "gpt-4o"
  1217. state.SawToolCall = true
  1218. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1219. Type: "response.completed",
  1220. Response: &ResponsesResponse{
  1221. Status: "completed",
  1222. },
  1223. }, state)
  1224. require.Len(t, chunks, 1)
  1225. require.NotNil(t, chunks[0].Choices[0].FinishReason)
  1226. assert.Equal(t, "tool_calls", *chunks[0].Choices[0].FinishReason)
  1227. }
  1228. func TestResponsesEventToChatChunks_ReasoningDelta(t *testing.T) {
  1229. state := NewResponsesEventToChatState()
  1230. state.Model = "gpt-4o"
  1231. state.SentRole = true
  1232. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1233. Type: "response.reasoning_summary_text.delta",
  1234. Delta: "Thinking...",
  1235. }, state)
  1236. require.Len(t, chunks, 1)
  1237. require.NotNil(t, chunks[0].Choices[0].Delta.ReasoningContent)
  1238. assert.Equal(t, "Thinking...", *chunks[0].Choices[0].Delta.ReasoningContent)
  1239. chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1240. Type: "response.reasoning_summary_text.done",
  1241. }, state)
  1242. require.Len(t, chunks, 0)
  1243. }
  1244. func TestResponsesEventToChatChunks_ReasoningThenTextAutoCloseTag(t *testing.T) {
  1245. state := NewResponsesEventToChatState()
  1246. state.Model = "gpt-4o"
  1247. state.SentRole = true
  1248. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1249. Type: "response.reasoning_summary_text.delta",
  1250. Delta: "plan",
  1251. }, state)
  1252. require.Len(t, chunks, 1)
  1253. require.NotNil(t, chunks[0].Choices[0].Delta.ReasoningContent)
  1254. assert.Equal(t, "plan", *chunks[0].Choices[0].Delta.ReasoningContent)
  1255. chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1256. Type: "response.output_text.delta",
  1257. Delta: "answer",
  1258. }, state)
  1259. require.Len(t, chunks, 1)
  1260. require.NotNil(t, chunks[0].Choices[0].Delta.Content)
  1261. assert.Equal(t, "answer", *chunks[0].Choices[0].Delta.Content)
  1262. }
  1263. func TestFinalizeResponsesChatStream(t *testing.T) {
  1264. state := NewResponsesEventToChatState()
  1265. state.Model = "gpt-4o"
  1266. state.IncludeUsage = true
  1267. state.Usage = &ChatUsage{
  1268. PromptTokens: 100,
  1269. CompletionTokens: 50,
  1270. TotalTokens: 150,
  1271. }
  1272. chunks := FinalizeResponsesChatStream(state)
  1273. require.Len(t, chunks, 2)
  1274. // Finish chunk
  1275. require.NotNil(t, chunks[0].Choices[0].FinishReason)
  1276. assert.Equal(t, "stop", *chunks[0].Choices[0].FinishReason)
  1277. // Usage chunk
  1278. require.NotNil(t, chunks[1].Usage)
  1279. assert.Equal(t, 100, chunks[1].Usage.PromptTokens)
  1280. // Idempotent: second call returns nil
  1281. assert.Nil(t, FinalizeResponsesChatStream(state))
  1282. }
  1283. func TestFinalizeResponsesChatStream_AfterCompleted(t *testing.T) {
  1284. // If response.completed already emitted the finish chunk, FinalizeResponsesChatStream
  1285. // must be a no-op (prevents double finish_reason being sent to the client).
  1286. state := NewResponsesEventToChatState()
  1287. state.Model = "gpt-4o"
  1288. state.IncludeUsage = true
  1289. // Simulate response.completed
  1290. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1291. Type: "response.completed",
  1292. Response: &ResponsesResponse{
  1293. Status: "completed",
  1294. Usage: &ResponsesUsage{
  1295. InputTokens: 10,
  1296. OutputTokens: 5,
  1297. TotalTokens: 15,
  1298. },
  1299. },
  1300. }, state)
  1301. require.NotEmpty(t, chunks) // finish + usage chunks
  1302. // Now FinalizeResponsesChatStream should return nil — already finalized.
  1303. assert.Nil(t, FinalizeResponsesChatStream(state))
  1304. }
  1305. func TestChatChunkToSSE(t *testing.T) {
  1306. chunk := ChatCompletionsChunk{
  1307. ID: "chatcmpl-test",
  1308. Object: "chat.completion.chunk",
  1309. Created: 1700000000,
  1310. Model: "gpt-4o",
  1311. Choices: []ChatChunkChoice{
  1312. {
  1313. Index: 0,
  1314. Delta: ChatDelta{Role: "assistant"},
  1315. FinishReason: nil,
  1316. },
  1317. },
  1318. }
  1319. sse, err := ChatChunkToSSE(chunk)
  1320. require.NoError(t, err)
  1321. assert.Contains(t, sse, "data: ")
  1322. assert.Contains(t, sse, "chatcmpl-test")
  1323. assert.Contains(t, sse, "assistant")
  1324. assert.True(t, len(sse) > 10)
  1325. }
  1326. // ---------------------------------------------------------------------------
  1327. // Stream round-trip test
  1328. // ---------------------------------------------------------------------------
  1329. func TestChatCompletionsStreamRoundTrip(t *testing.T) {
  1330. // Simulate: client sends chat completions request, upstream returns Responses SSE events.
  1331. // Verify that the streaming state machine produces correct chat completions chunks.
  1332. state := NewResponsesEventToChatState()
  1333. state.Model = "gpt-4o"
  1334. state.IncludeUsage = true
  1335. var allChunks []ChatCompletionsChunk
  1336. // 1. response.created
  1337. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1338. Type: "response.created",
  1339. Response: &ResponsesResponse{ID: "resp_rt"},
  1340. }, state)
  1341. allChunks = append(allChunks, chunks...)
  1342. // 2. text deltas
  1343. for _, text := range []string{"Hello", ", ", "world", "!"} {
  1344. chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1345. Type: "response.output_text.delta",
  1346. Delta: text,
  1347. }, state)
  1348. allChunks = append(allChunks, chunks...)
  1349. }
  1350. // 3. response.completed
  1351. chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1352. Type: "response.completed",
  1353. Response: &ResponsesResponse{
  1354. Status: "completed",
  1355. Usage: &ResponsesUsage{
  1356. InputTokens: 10,
  1357. OutputTokens: 4,
  1358. TotalTokens: 14,
  1359. },
  1360. },
  1361. }, state)
  1362. allChunks = append(allChunks, chunks...)
  1363. // Verify: role chunk + 4 text chunks + finish chunk + usage chunk = 7
  1364. require.Len(t, allChunks, 7)
  1365. // First chunk has role
  1366. assert.Equal(t, "assistant", allChunks[0].Choices[0].Delta.Role)
  1367. // Text chunks
  1368. var fullText string
  1369. for i := 1; i <= 4; i++ {
  1370. require.NotNil(t, allChunks[i].Choices[0].Delta.Content)
  1371. fullText += *allChunks[i].Choices[0].Delta.Content
  1372. }
  1373. assert.Equal(t, "Hello, world!", fullText)
  1374. // Finish chunk
  1375. require.NotNil(t, allChunks[5].Choices[0].FinishReason)
  1376. assert.Equal(t, "stop", *allChunks[5].Choices[0].FinishReason)
  1377. // Usage chunk
  1378. require.NotNil(t, allChunks[6].Usage)
  1379. assert.Equal(t, 10, allChunks[6].Usage.PromptTokens)
  1380. assert.Equal(t, 4, allChunks[6].Usage.CompletionTokens)
  1381. // All chunks share the same ID
  1382. for _, c := range allChunks {
  1383. assert.Equal(t, "resp_rt", c.ID)
  1384. }
  1385. }
  1386. // ---------------------------------------------------------------------------
  1387. // BufferedResponseAccumulator tests
  1388. // ---------------------------------------------------------------------------
  1389. func TestBufferedResponseAccumulator_TextOnly(t *testing.T) {
  1390. acc := NewBufferedResponseAccumulator()
  1391. acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"})
  1392. acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: ", world!"})
  1393. assert.True(t, acc.HasContent())
  1394. output := acc.BuildOutput()
  1395. require.Len(t, output, 1)
  1396. assert.Equal(t, "message", output[0].Type)
  1397. assert.Equal(t, "assistant", output[0].Role)
  1398. require.Len(t, output[0].Content, 1)
  1399. assert.Equal(t, "output_text", output[0].Content[0].Type)
  1400. assert.Equal(t, "Hello, world!", output[0].Content[0].Text)
  1401. }
  1402. func TestBufferedResponseAccumulator_ToolCalls(t *testing.T) {
  1403. acc := NewBufferedResponseAccumulator()
  1404. // Add function call at output_index=1
  1405. acc.ProcessEvent(&ResponsesStreamEvent{
  1406. Type: "response.output_item.added",
  1407. OutputIndex: 1,
  1408. Item: &ResponsesOutput{
  1409. Type: "function_call",
  1410. CallID: "call_abc",
  1411. Name: "get_weather",
  1412. },
  1413. })
  1414. acc.ProcessEvent(&ResponsesStreamEvent{
  1415. Type: "response.function_call_arguments.delta",
  1416. OutputIndex: 1,
  1417. Delta: `{"city":`,
  1418. })
  1419. acc.ProcessEvent(&ResponsesStreamEvent{
  1420. Type: "response.function_call_arguments.delta",
  1421. OutputIndex: 1,
  1422. Delta: `"NYC"}`,
  1423. })
  1424. assert.True(t, acc.HasContent())
  1425. output := acc.BuildOutput()
  1426. require.Len(t, output, 1)
  1427. assert.Equal(t, "function_call", output[0].Type)
  1428. assert.Equal(t, "call_abc", output[0].CallID)
  1429. assert.Equal(t, "get_weather", output[0].Name)
  1430. assert.Equal(t, `{"city":"NYC"}`, output[0].Arguments)
  1431. }
  1432. func TestResponsesEventToChatChunks_FunctionArgumentsDoneWithoutDeltas(t *testing.T) {
  1433. state := NewResponsesEventToChatState()
  1434. state.Model = "gpt-5-codex"
  1435. state.SentRole = true
  1436. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1437. Type: "response.output_item.added",
  1438. OutputIndex: 1,
  1439. Item: &ResponsesOutput{
  1440. Type: "function_call",
  1441. CallID: "call_lookup",
  1442. Name: "lookup_item",
  1443. Arguments: "",
  1444. },
  1445. }, state)
  1446. require.Len(t, chunks, 1)
  1447. chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1448. Type: "response.function_call_arguments.done",
  1449. OutputIndex: 1,
  1450. CallID: "call_lookup",
  1451. Name: "lookup_item",
  1452. Arguments: `{"id":1}`,
  1453. }, state)
  1454. require.Len(t, chunks, 1)
  1455. require.Len(t, chunks[0].Choices[0].Delta.ToolCalls, 1)
  1456. assert.Equal(t, `{"id":1}`, chunks[0].Choices[0].Delta.ToolCalls[0].Function.Arguments)
  1457. }
  1458. func TestResponsesEventToChatChunks_FunctionArgumentsDoneDoesNotDuplicateDeltas(t *testing.T) {
  1459. state := NewResponsesEventToChatState()
  1460. state.Model = "gpt-5-codex"
  1461. state.SentRole = true
  1462. ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1463. Type: "response.output_item.added",
  1464. OutputIndex: 0,
  1465. Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
  1466. }, state)
  1467. chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1468. Type: "response.function_call_arguments.delta",
  1469. OutputIndex: 0,
  1470. Delta: `{"id":1}`,
  1471. }, state)
  1472. require.Len(t, chunks, 1)
  1473. chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
  1474. Type: "response.function_call_arguments.done",
  1475. OutputIndex: 0,
  1476. Arguments: `{"id":1}`,
  1477. }, state)
  1478. assert.Empty(t, chunks)
  1479. }
  1480. func TestBufferedResponseAccumulator_FunctionArgumentsDoneWithoutDeltas(t *testing.T) {
  1481. acc := NewBufferedResponseAccumulator()
  1482. acc.ProcessEvent(&ResponsesStreamEvent{
  1483. Type: "response.output_item.added",
  1484. OutputIndex: 1,
  1485. Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
  1486. })
  1487. acc.ProcessEvent(&ResponsesStreamEvent{
  1488. Type: "response.function_call_arguments.done",
  1489. OutputIndex: 1,
  1490. Arguments: `{"id":1}`,
  1491. })
  1492. resp := &ResponsesResponse{Output: []ResponsesOutput{{
  1493. Type: "function_call",
  1494. CallID: "call_lookup",
  1495. Name: "lookup_item",
  1496. Arguments: "",
  1497. }}}
  1498. acc.SupplementResponseOutput(resp)
  1499. require.Len(t, resp.Output, 1)
  1500. assert.Equal(t, `{"id":1}`, resp.Output[0].Arguments)
  1501. }
  1502. func TestBufferedResponseAccumulator_FunctionArgumentsDoneReplacesDeltas(t *testing.T) {
  1503. acc := NewBufferedResponseAccumulator()
  1504. acc.ProcessEvent(&ResponsesStreamEvent{
  1505. Type: "response.output_item.added",
  1506. OutputIndex: 0,
  1507. Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
  1508. })
  1509. acc.ProcessEvent(&ResponsesStreamEvent{
  1510. Type: "response.function_call_arguments.delta",
  1511. OutputIndex: 0,
  1512. Delta: `{"id":1}`,
  1513. })
  1514. acc.ProcessEvent(&ResponsesStreamEvent{
  1515. Type: "response.function_call_arguments.done",
  1516. OutputIndex: 0,
  1517. Arguments: `{"id":1}`,
  1518. })
  1519. output := acc.BuildOutput()
  1520. require.Len(t, output, 1)
  1521. assert.Equal(t, `{"id":1}`, output[0].Arguments)
  1522. }
  1523. func TestBufferedResponseAccumulator_Reasoning(t *testing.T) {
  1524. acc := NewBufferedResponseAccumulator()
  1525. acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "Step 1: "})
  1526. acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "think about it"})
  1527. assert.True(t, acc.HasContent())
  1528. output := acc.BuildOutput()
  1529. require.Len(t, output, 1)
  1530. assert.Equal(t, "reasoning", output[0].Type)
  1531. require.Len(t, output[0].Summary, 1)
  1532. assert.Equal(t, "summary_text", output[0].Summary[0].Type)
  1533. assert.Equal(t, "Step 1: think about it", output[0].Summary[0].Text)
  1534. }
  1535. func TestBufferedResponseAccumulator_Mixed(t *testing.T) {
  1536. acc := NewBufferedResponseAccumulator()
  1537. // Reasoning first
  1538. acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "I thought about it."})
  1539. // Then text
  1540. acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "The answer is 42."})
  1541. // Then a tool call
  1542. acc.ProcessEvent(&ResponsesStreamEvent{
  1543. Type: "response.output_item.added",
  1544. OutputIndex: 2,
  1545. Item: &ResponsesOutput{
  1546. Type: "function_call",
  1547. CallID: "call_1",
  1548. Name: "verify",
  1549. },
  1550. })
  1551. acc.ProcessEvent(&ResponsesStreamEvent{
  1552. Type: "response.function_call_arguments.delta",
  1553. OutputIndex: 2,
  1554. Delta: `{}`,
  1555. })
  1556. assert.True(t, acc.HasContent())
  1557. output := acc.BuildOutput()
  1558. // Order: reasoning → message → function_calls
  1559. require.Len(t, output, 3)
  1560. assert.Equal(t, "reasoning", output[0].Type)
  1561. assert.Equal(t, "message", output[1].Type)
  1562. assert.Equal(t, "function_call", output[2].Type)
  1563. assert.Equal(t, "The answer is 42.", output[1].Content[0].Text)
  1564. assert.Equal(t, "verify", output[2].Name)
  1565. }
  1566. func TestBufferedResponseAccumulator_SupplementEmptyOutput(t *testing.T) {
  1567. acc := NewBufferedResponseAccumulator()
  1568. acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"})
  1569. resp := &ResponsesResponse{
  1570. ID: "resp_1",
  1571. Status: "completed",
  1572. Output: nil, // empty output
  1573. Usage: &ResponsesUsage{InputTokens: 10, OutputTokens: 5},
  1574. }
  1575. acc.SupplementResponseOutput(resp)
  1576. require.Len(t, resp.Output, 1)
  1577. assert.Equal(t, "message", resp.Output[0].Type)
  1578. assert.Equal(t, "Hello", resp.Output[0].Content[0].Text)
  1579. // Usage should be untouched
  1580. assert.Equal(t, 10, resp.Usage.InputTokens)
  1581. }
  1582. func TestBufferedResponseAccumulator_NoSupplementWhenOutputExists(t *testing.T) {
  1583. acc := NewBufferedResponseAccumulator()
  1584. acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "from deltas"})
  1585. resp := &ResponsesResponse{
  1586. ID: "resp_2",
  1587. Status: "completed",
  1588. Output: []ResponsesOutput{
  1589. {
  1590. Type: "message",
  1591. Content: []ResponsesContentPart{
  1592. {Type: "output_text", Text: "from terminal event"},
  1593. },
  1594. },
  1595. },
  1596. }
  1597. acc.SupplementResponseOutput(resp)
  1598. // Output should NOT be overwritten
  1599. require.Len(t, resp.Output, 1)
  1600. assert.Equal(t, "from terminal event", resp.Output[0].Content[0].Text)
  1601. }
  1602. func TestBufferedResponseAccumulator_EmptyDeltas(t *testing.T) {
  1603. acc := NewBufferedResponseAccumulator()
  1604. // Process events with empty delta — should not accumulate
  1605. acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: ""})
  1606. acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.created"})
  1607. assert.False(t, acc.HasContent())
  1608. resp := &ResponsesResponse{ID: "resp_3", Status: "completed"}
  1609. acc.SupplementResponseOutput(resp)
  1610. assert.Nil(t, resp.Output)
  1611. }
  1612. func TestBufferedResponseAccumulator_IgnoresNonFunctionCallItems(t *testing.T) {
  1613. acc := NewBufferedResponseAccumulator()
  1614. // output_item.added with type "message" should be ignored
  1615. acc.ProcessEvent(&ResponsesStreamEvent{
  1616. Type: "response.output_item.added",
  1617. OutputIndex: 0,
  1618. Item: &ResponsesOutput{Type: "message"},
  1619. })
  1620. assert.False(t, acc.HasContent())
  1621. }