mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 18:46:46 +08:00
When converting a Chat Completions stream into Responses events, the first
tool_call delta chunk was copied wholesale into stream state (including
function.arguments), then the same chunk's arguments were accumulated again by
the shared `+=` block. For OpenAI this is harmless because its first tool_call
chunk carries empty arguments, but upstreams that pack id+name+arguments into a
single chunk (e.g. GLM/Zhipu) end up with doubled arguments such as
{"cmd":"ls"}{"cmd":"ls"}. Codex then fails to parse the tool call with
"trailing characters", breaking every tool invocation.
Reset the copied arguments so the shared accumulator counts them exactly once,
keeping the emitted delta and the final done/arguments consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
239 lines
9.9 KiB
Go
239 lines
9.9 KiB
Go
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())
|
|
}
|
|
|
|
// 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"`)
|
|
}
|