chatcompletions_responses_stream_lifecycle_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "strings"
  5. "testing"
  6. "github.com/stretchr/testify/require"
  7. )
  8. func collectStreamEvents(t *testing.T, chunks []string) []ResponsesStreamEvent {
  9. t.Helper()
  10. state := NewChatCompletionsToResponsesStreamState("deepseek-v4-pro")
  11. var events []ResponsesStreamEvent
  12. for _, payload := range chunks {
  13. var chunk ChatCompletionsChunk
  14. require.NoError(t, json.Unmarshal([]byte(payload), &chunk))
  15. events = append(events, ChatCompletionsChunkToResponsesEvents(&chunk, state)...)
  16. }
  17. events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
  18. return events
  19. }
  20. // TestStream_ReasoningOpensItemBeforeDelta guards the bug where a strict client
  21. // (Codex) drops reasoning deltas that reference an item not yet opened.
  22. func TestStream_ReasoningOpensItemBeforeDelta(t *testing.T) {
  23. events := collectStreamEvents(t, []string{
  24. `{"choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}`,
  25. `{"choices":[{"index":0,"delta":{"reasoning_content":"think"}}]}`,
  26. `{"choices":[{"index":0,"delta":{"content":"hello"}}]}`,
  27. `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
  28. })
  29. open := map[int]string{} // output_index -> item type
  30. for _, e := range events {
  31. switch e.Type {
  32. case "response.output_item.added":
  33. require.NotNil(t, e.Item)
  34. open[e.OutputIndex] = e.Item.Type
  35. case "response.reasoning_summary_text.delta":
  36. require.Equalf(t, "reasoning", open[e.OutputIndex], "reasoning delta before its item was opened")
  37. case "response.output_text.delta":
  38. require.Equalf(t, "message", open[e.OutputIndex], "text delta before its item was opened")
  39. }
  40. }
  41. }
  42. func TestStream_ReasoningOnlySynthesizesVisibleText(t *testing.T) {
  43. events := collectStreamEvents(t, []string{
  44. `{"choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}`,
  45. `{"choices":[{"index":0,"delta":{"reasoning_content":"thinking before final"}}]}`,
  46. `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
  47. })
  48. open := map[int]string{}
  49. var sawTextDelta, sawTextDone, sawMessageDone bool
  50. for _, e := range events {
  51. switch e.Type {
  52. case "response.output_item.added":
  53. require.NotNil(t, e.Item)
  54. open[e.OutputIndex] = e.Item.Type
  55. case "response.output_text.delta":
  56. sawTextDelta = true
  57. require.Equalf(t, "message", open[e.OutputIndex], "fallback text delta before its item was opened")
  58. require.Equal(t, "thinking before final", e.Delta)
  59. case "response.output_text.done":
  60. sawTextDone = true
  61. require.Equal(t, "thinking before final", e.Text)
  62. case "response.output_item.done":
  63. if e.Item != nil && e.Item.Type == "message" {
  64. sawMessageDone = true
  65. require.Equal(t, "thinking before final", e.Item.Content[0].Text)
  66. }
  67. case "response.completed":
  68. require.NotNil(t, e.Response)
  69. require.Equal(t, "incomplete", e.Response.Status)
  70. require.NotNil(t, e.Response.IncompleteDetails)
  71. require.Equal(t, "max_output_tokens", e.Response.IncompleteDetails.Reason)
  72. require.Len(t, e.Response.Output, 2)
  73. require.Equal(t, "reasoning", e.Response.Output[0].Type)
  74. require.Equal(t, "message", e.Response.Output[1].Type)
  75. require.Equal(t, "thinking before final", e.Response.Output[1].Content[0].Text)
  76. }
  77. }
  78. require.True(t, sawTextDelta, "reasoning-only stream must produce visible text delta")
  79. require.True(t, sawTextDone, "reasoning-only stream must close visible text part")
  80. require.True(t, sawMessageDone, "reasoning-only stream must close synthesized message item")
  81. }
  82. func TestStream_ReasoningOnlyBlankDoesNotSynthesizeVisibleText(t *testing.T) {
  83. events := collectStreamEvents(t, []string{
  84. `{"choices":[{"index":0,"delta":{"reasoning_content":" "}}]}`,
  85. `{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
  86. })
  87. for _, e := range events {
  88. require.NotEqual(t, "response.output_text.delta", e.Type)
  89. if e.Type == "response.completed" {
  90. require.NotNil(t, e.Response)
  91. require.Len(t, e.Response.Output, 2)
  92. require.Equal(t, "reasoning", e.Response.Output[0].Type)
  93. require.Equal(t, "message", e.Response.Output[1].Type)
  94. require.Equal(t, "", e.Response.Output[1].Content[0].Text)
  95. }
  96. }
  97. }
  98. func TestStream_ReasoningThenContentDoesNotDuplicateFallbackText(t *testing.T) {
  99. events := collectStreamEvents(t, []string{
  100. `{"choices":[{"index":0,"delta":{"reasoning_content":"private plan"}}]}`,
  101. `{"choices":[{"index":0,"delta":{"content":"final answer"}}]}`,
  102. `{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
  103. })
  104. var textDeltas []string
  105. for _, e := range events {
  106. switch e.Type {
  107. case "response.output_text.delta":
  108. textDeltas = append(textDeltas, e.Delta)
  109. case "response.completed":
  110. require.NotNil(t, e.Response)
  111. require.Len(t, e.Response.Output, 2)
  112. require.Equal(t, "private plan", e.Response.Output[0].Summary[0].Text)
  113. require.Equal(t, "final answer", e.Response.Output[1].Content[0].Text)
  114. }
  115. }
  116. require.Equal(t, []string{"final answer"}, textDeltas)
  117. }
  118. func TestStream_ReasoningThenToolCallDoesNotSynthesizeVisibleText(t *testing.T) {
  119. events := collectStreamEvents(t, []string{
  120. `{"choices":[{"index":0,"delta":{"reasoning_content":"call a tool"}}]}`,
  121. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{}"}}]}}]}`,
  122. `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
  123. })
  124. for _, e := range events {
  125. require.NotEqual(t, "response.output_text.delta", e.Type)
  126. if e.Type == "response.completed" {
  127. require.NotNil(t, e.Response)
  128. require.Len(t, e.Response.Output, 2)
  129. require.Equal(t, "reasoning", e.Response.Output[0].Type)
  130. require.Equal(t, "function_call", e.Response.Output[1].Type)
  131. }
  132. }
  133. }
  134. // TestStream_ToolCallLifecycleComplete guards that a tool call is fully closed
  135. // (function_call_arguments.done + output_item.done with full arguments), which
  136. // codex needs to execute the call.
  137. func TestStream_ToolCallLifecycleComplete(t *testing.T) {
  138. events := collectStreamEvents(t, []string{
  139. `{"choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"plan"}}]}`,
  140. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":""}}]}}]}`,
  141. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"cmd\":\"ls\"}"}}]}}]}`,
  142. `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
  143. })
  144. var sawAdded, sawArgsDone, sawItemDone bool
  145. for _, e := range events {
  146. switch e.Type {
  147. case "response.output_item.added":
  148. if e.Item != nil && e.Item.Type == "function_call" {
  149. sawAdded = true
  150. }
  151. case "response.function_call_arguments.done":
  152. sawArgsDone = true
  153. require.Equal(t, `{"cmd":"ls"}`, e.Arguments)
  154. case "response.output_item.done":
  155. if e.Item != nil && e.Item.Type == "function_call" {
  156. sawItemDone = true
  157. require.Equal(t, `{"cmd":"ls"}`, e.Item.Arguments)
  158. require.Equal(t, "call_a", e.Item.CallID)
  159. }
  160. }
  161. }
  162. require.True(t, sawAdded, "function_call output_item.added missing")
  163. require.True(t, sawArgsDone, "function_call_arguments.done missing")
  164. require.True(t, sawItemDone, "function_call output_item.done missing")
  165. }
  166. // TestStream_ToolCallArgumentsInFirstChunkNotDoubled guards the GLM/Zhipu shape
  167. // where a single tool_call delta chunk carries id+name+arguments together.
  168. // Earlier code copied the whole tool_call (including arguments) into state and
  169. // then accumulated the same chunk's arguments again, producing a doubled,
  170. // invalid JSON like {"cmd":"ls"}{"cmd":"ls"} that breaks Codex tool parsing
  171. // ("trailing characters").
  172. func TestStream_ToolCallArgumentsInFirstChunkNotDoubled(t *testing.T) {
  173. events := collectStreamEvents(t, []string{
  174. `{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`,
  175. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{\"cmd\":\"ls\"}"}}]}}]}`,
  176. `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
  177. })
  178. var argsDelta strings.Builder
  179. var sawArgsDone, sawItemDone bool
  180. for _, e := range events {
  181. switch e.Type {
  182. case "response.function_call_arguments.delta":
  183. _, _ = argsDelta.WriteString(e.Delta)
  184. case "response.function_call_arguments.done":
  185. sawArgsDone = true
  186. require.Equal(t, `{"cmd":"ls"}`, e.Arguments)
  187. case "response.output_item.done":
  188. if e.Item != nil && e.Item.Type == "function_call" {
  189. sawItemDone = true
  190. require.Equal(t, `{"cmd":"ls"}`, e.Item.Arguments)
  191. }
  192. }
  193. }
  194. require.True(t, sawArgsDone, "function_call_arguments.done missing")
  195. require.True(t, sawItemDone, "function_call output_item.done missing")
  196. // Accumulated deltas must equal the final arguments exactly (no duplication).
  197. require.Equal(t, `{"cmd":"ls"}`, argsDelta.String())
  198. }
  199. func TestStream_InvalidToolArgumentsAreRejectedBeforeFinalize(t *testing.T) {
  200. idx := 0
  201. state := NewChatCompletionsToResponsesStreamState("deepseek-v4-flash")
  202. chunk := &ChatCompletionsChunk{
  203. Choices: []ChatChunkChoice{
  204. {
  205. Index: 0,
  206. Delta: ChatDelta{
  207. ToolCalls: []ChatToolCall{
  208. {
  209. Index: &idx,
  210. ID: "call_bad",
  211. Type: "function",
  212. Function: ChatFunctionCall{
  213. Name: "exec_command",
  214. Arguments: `{"cmd": "ssh root@HOST`,
  215. },
  216. },
  217. },
  218. },
  219. },
  220. },
  221. }
  222. ChatCompletionsChunkToResponsesEvents(chunk, state)
  223. err := state.ValidateToolCallArguments()
  224. require.ErrorContains(t, err, "invalid JSON")
  225. }
  226. func TestStream_ValidToolCallAtOutputLimitKeepsIncompleteResponse(t *testing.T) {
  227. idx := 0
  228. state := NewChatCompletionsToResponsesStreamState("deepseek-v4-flash")
  229. chunk := &ChatCompletionsChunk{
  230. Choices: []ChatChunkChoice{
  231. {
  232. Index: 0,
  233. Delta: ChatDelta{
  234. ToolCalls: []ChatToolCall{
  235. {
  236. Index: &idx,
  237. ID: "call_at_limit",
  238. Type: "function",
  239. Function: ChatFunctionCall{
  240. Name: "exec_command",
  241. Arguments: `{}`,
  242. },
  243. },
  244. },
  245. },
  246. },
  247. },
  248. }
  249. ChatCompletionsChunkToResponsesEvents(chunk, state)
  250. state.FinishReason = "length"
  251. require.NoError(t, state.ValidateToolCallArguments())
  252. events := FinalizeChatCompletionsResponsesStream(state)
  253. var sawArgsDone, sawIncomplete bool
  254. for _, event := range events {
  255. switch event.Type {
  256. case "response.function_call_arguments.done":
  257. sawArgsDone = true
  258. require.Equal(t, `{}`, event.Arguments)
  259. case "response.completed":
  260. require.NotNil(t, event.Response)
  261. sawIncomplete = event.Response.Status == "incomplete"
  262. }
  263. }
  264. require.True(t, sawArgsDone)
  265. require.True(t, sawIncomplete)
  266. }
  267. // TestStream_SSEWireComplete drives the full stream through SSE encoding and
  268. // asserts the function_call events carry complete fields on the wire.
  269. func TestStream_SSEWireComplete(t *testing.T) {
  270. events := collectStreamEvents(t, []string{
  271. `{"choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"plan"}}]}`,
  272. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{}"}}]}}]}`,
  273. `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
  274. })
  275. var addedLine string
  276. for _, e := range events {
  277. sse, err := ResponsesEventToSSE(e)
  278. require.NoError(t, err)
  279. if e.Type == "response.output_item.added" && e.Item != nil && e.Item.Type == "function_call" {
  280. addedLine = sse
  281. }
  282. }
  283. require.NotEmpty(t, addedLine)
  284. // The function_call added event must carry arguments:"" on the wire.
  285. require.True(t, strings.Contains(addedLine, `"arguments":""`), "added line missing arguments: %s", addedLine)
  286. require.Contains(t, addedLine, `"call_id":"call_a"`)
  287. }