| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310 |
- package apicompat
- import (
- "encoding/json"
- "strings"
- "testing"
- "github.com/stretchr/testify/require"
- )
- func collectStreamEvents(t *testing.T, chunks []string) []ResponsesStreamEvent {
- t.Helper()
- state := NewChatCompletionsToResponsesStreamState("deepseek-v4-pro")
- var events []ResponsesStreamEvent
- for _, payload := range chunks {
- var chunk ChatCompletionsChunk
- require.NoError(t, json.Unmarshal([]byte(payload), &chunk))
- events = append(events, ChatCompletionsChunkToResponsesEvents(&chunk, state)...)
- }
- events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
- return events
- }
- // TestStream_ReasoningOpensItemBeforeDelta guards the bug where a strict client
- // (Codex) drops reasoning deltas that reference an item not yet opened.
- func TestStream_ReasoningOpensItemBeforeDelta(t *testing.T) {
- events := collectStreamEvents(t, []string{
- `{"choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}`,
- `{"choices":[{"index":0,"delta":{"reasoning_content":"think"}}]}`,
- `{"choices":[{"index":0,"delta":{"content":"hello"}}]}`,
- `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
- })
- open := map[int]string{} // output_index -> item type
- for _, e := range events {
- switch e.Type {
- case "response.output_item.added":
- require.NotNil(t, e.Item)
- open[e.OutputIndex] = e.Item.Type
- case "response.reasoning_summary_text.delta":
- require.Equalf(t, "reasoning", open[e.OutputIndex], "reasoning delta before its item was opened")
- case "response.output_text.delta":
- require.Equalf(t, "message", open[e.OutputIndex], "text delta before its item was opened")
- }
- }
- }
- func TestStream_ReasoningOnlySynthesizesVisibleText(t *testing.T) {
- events := collectStreamEvents(t, []string{
- `{"choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}`,
- `{"choices":[{"index":0,"delta":{"reasoning_content":"thinking before final"}}]}`,
- `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
- })
- open := map[int]string{}
- var sawTextDelta, sawTextDone, sawMessageDone bool
- for _, e := range events {
- switch e.Type {
- case "response.output_item.added":
- require.NotNil(t, e.Item)
- open[e.OutputIndex] = e.Item.Type
- case "response.output_text.delta":
- sawTextDelta = true
- require.Equalf(t, "message", open[e.OutputIndex], "fallback text delta before its item was opened")
- require.Equal(t, "thinking before final", e.Delta)
- case "response.output_text.done":
- sawTextDone = true
- require.Equal(t, "thinking before final", e.Text)
- case "response.output_item.done":
- if e.Item != nil && e.Item.Type == "message" {
- sawMessageDone = true
- require.Equal(t, "thinking before final", e.Item.Content[0].Text)
- }
- case "response.completed":
- require.NotNil(t, e.Response)
- require.Equal(t, "incomplete", e.Response.Status)
- require.NotNil(t, e.Response.IncompleteDetails)
- require.Equal(t, "max_output_tokens", e.Response.IncompleteDetails.Reason)
- require.Len(t, e.Response.Output, 2)
- require.Equal(t, "reasoning", e.Response.Output[0].Type)
- require.Equal(t, "message", e.Response.Output[1].Type)
- require.Equal(t, "thinking before final", e.Response.Output[1].Content[0].Text)
- }
- }
- require.True(t, sawTextDelta, "reasoning-only stream must produce visible text delta")
- require.True(t, sawTextDone, "reasoning-only stream must close visible text part")
- require.True(t, sawMessageDone, "reasoning-only stream must close synthesized message item")
- }
- func TestStream_ReasoningOnlyBlankDoesNotSynthesizeVisibleText(t *testing.T) {
- events := collectStreamEvents(t, []string{
- `{"choices":[{"index":0,"delta":{"reasoning_content":" "}}]}`,
- `{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
- })
- for _, e := range events {
- require.NotEqual(t, "response.output_text.delta", e.Type)
- if e.Type == "response.completed" {
- require.NotNil(t, e.Response)
- require.Len(t, e.Response.Output, 2)
- require.Equal(t, "reasoning", e.Response.Output[0].Type)
- require.Equal(t, "message", e.Response.Output[1].Type)
- require.Equal(t, "", e.Response.Output[1].Content[0].Text)
- }
- }
- }
- func TestStream_ReasoningThenContentDoesNotDuplicateFallbackText(t *testing.T) {
- events := collectStreamEvents(t, []string{
- `{"choices":[{"index":0,"delta":{"reasoning_content":"private plan"}}]}`,
- `{"choices":[{"index":0,"delta":{"content":"final answer"}}]}`,
- `{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
- })
- var textDeltas []string
- for _, e := range events {
- switch e.Type {
- case "response.output_text.delta":
- textDeltas = append(textDeltas, e.Delta)
- case "response.completed":
- require.NotNil(t, e.Response)
- require.Len(t, e.Response.Output, 2)
- require.Equal(t, "private plan", e.Response.Output[0].Summary[0].Text)
- require.Equal(t, "final answer", e.Response.Output[1].Content[0].Text)
- }
- }
- require.Equal(t, []string{"final answer"}, textDeltas)
- }
- func TestStream_ReasoningThenToolCallDoesNotSynthesizeVisibleText(t *testing.T) {
- events := collectStreamEvents(t, []string{
- `{"choices":[{"index":0,"delta":{"reasoning_content":"call a tool"}}]}`,
- `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{}"}}]}}]}`,
- `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
- })
- for _, e := range events {
- require.NotEqual(t, "response.output_text.delta", e.Type)
- if e.Type == "response.completed" {
- require.NotNil(t, e.Response)
- require.Len(t, e.Response.Output, 2)
- require.Equal(t, "reasoning", e.Response.Output[0].Type)
- require.Equal(t, "function_call", e.Response.Output[1].Type)
- }
- }
- }
- // TestStream_ToolCallLifecycleComplete guards that a tool call is fully closed
- // (function_call_arguments.done + output_item.done with full arguments), which
- // codex needs to execute the call.
- func TestStream_ToolCallLifecycleComplete(t *testing.T) {
- events := collectStreamEvents(t, []string{
- `{"choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"plan"}}]}`,
- `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":""}}]}}]}`,
- `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"cmd\":\"ls\"}"}}]}}]}`,
- `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
- })
- var sawAdded, sawArgsDone, sawItemDone bool
- for _, e := range events {
- switch e.Type {
- case "response.output_item.added":
- if e.Item != nil && e.Item.Type == "function_call" {
- sawAdded = true
- }
- case "response.function_call_arguments.done":
- sawArgsDone = true
- require.Equal(t, `{"cmd":"ls"}`, e.Arguments)
- case "response.output_item.done":
- if e.Item != nil && e.Item.Type == "function_call" {
- sawItemDone = true
- require.Equal(t, `{"cmd":"ls"}`, e.Item.Arguments)
- require.Equal(t, "call_a", e.Item.CallID)
- }
- }
- }
- require.True(t, sawAdded, "function_call output_item.added missing")
- require.True(t, sawArgsDone, "function_call_arguments.done missing")
- require.True(t, sawItemDone, "function_call output_item.done missing")
- }
- // TestStream_ToolCallArgumentsInFirstChunkNotDoubled guards the GLM/Zhipu shape
- // where a single tool_call delta chunk carries id+name+arguments together.
- // Earlier code copied the whole tool_call (including arguments) into state and
- // then accumulated the same chunk's arguments again, producing a doubled,
- // invalid JSON like {"cmd":"ls"}{"cmd":"ls"} that breaks Codex tool parsing
- // ("trailing characters").
- func TestStream_ToolCallArgumentsInFirstChunkNotDoubled(t *testing.T) {
- events := collectStreamEvents(t, []string{
- `{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`,
- `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{\"cmd\":\"ls\"}"}}]}}]}`,
- `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
- })
- var argsDelta strings.Builder
- var sawArgsDone, sawItemDone bool
- for _, e := range events {
- switch e.Type {
- case "response.function_call_arguments.delta":
- _, _ = argsDelta.WriteString(e.Delta)
- case "response.function_call_arguments.done":
- sawArgsDone = true
- require.Equal(t, `{"cmd":"ls"}`, e.Arguments)
- case "response.output_item.done":
- if e.Item != nil && e.Item.Type == "function_call" {
- sawItemDone = true
- require.Equal(t, `{"cmd":"ls"}`, e.Item.Arguments)
- }
- }
- }
- require.True(t, sawArgsDone, "function_call_arguments.done missing")
- require.True(t, sawItemDone, "function_call output_item.done missing")
- // Accumulated deltas must equal the final arguments exactly (no duplication).
- require.Equal(t, `{"cmd":"ls"}`, argsDelta.String())
- }
- func TestStream_InvalidToolArgumentsAreRejectedBeforeFinalize(t *testing.T) {
- idx := 0
- state := NewChatCompletionsToResponsesStreamState("deepseek-v4-flash")
- chunk := &ChatCompletionsChunk{
- Choices: []ChatChunkChoice{
- {
- Index: 0,
- Delta: ChatDelta{
- ToolCalls: []ChatToolCall{
- {
- Index: &idx,
- ID: "call_bad",
- Type: "function",
- Function: ChatFunctionCall{
- Name: "exec_command",
- Arguments: `{"cmd": "ssh root@HOST`,
- },
- },
- },
- },
- },
- },
- }
- ChatCompletionsChunkToResponsesEvents(chunk, state)
- err := state.ValidateToolCallArguments()
- require.ErrorContains(t, err, "invalid JSON")
- }
- func TestStream_ValidToolCallAtOutputLimitKeepsIncompleteResponse(t *testing.T) {
- idx := 0
- state := NewChatCompletionsToResponsesStreamState("deepseek-v4-flash")
- chunk := &ChatCompletionsChunk{
- Choices: []ChatChunkChoice{
- {
- Index: 0,
- Delta: ChatDelta{
- ToolCalls: []ChatToolCall{
- {
- Index: &idx,
- ID: "call_at_limit",
- Type: "function",
- Function: ChatFunctionCall{
- Name: "exec_command",
- Arguments: `{}`,
- },
- },
- },
- },
- },
- },
- }
- ChatCompletionsChunkToResponsesEvents(chunk, state)
- state.FinishReason = "length"
- require.NoError(t, state.ValidateToolCallArguments())
- events := FinalizeChatCompletionsResponsesStream(state)
- var sawArgsDone, sawIncomplete bool
- for _, event := range events {
- switch event.Type {
- case "response.function_call_arguments.done":
- sawArgsDone = true
- require.Equal(t, `{}`, event.Arguments)
- case "response.completed":
- require.NotNil(t, event.Response)
- sawIncomplete = event.Response.Status == "incomplete"
- }
- }
- require.True(t, sawArgsDone)
- require.True(t, sawIncomplete)
- }
- // TestStream_SSEWireComplete drives the full stream through SSE encoding and
- // asserts the function_call events carry complete fields on the wire.
- func TestStream_SSEWireComplete(t *testing.T) {
- events := collectStreamEvents(t, []string{
- `{"choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"plan"}}]}`,
- `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{}"}}]}}]}`,
- `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
- })
- var addedLine string
- for _, e := range events {
- sse, err := ResponsesEventToSSE(e)
- require.NoError(t, err)
- if e.Type == "response.output_item.added" && e.Item != nil && e.Item.Type == "function_call" {
- addedLine = sse
- }
- }
- require.NotEmpty(t, addedLine)
- // The function_call added event must carry arguments:"" on the wire.
- require.True(t, strings.Contains(addedLine, `"arguments":""`), "added line missing arguments: %s", addedLine)
- require.Contains(t, addedLine, `"call_id":"call_a"`)
- }
|