From 29122e30514f9af95ed8b7c9f8b6aa5984b0b55f Mon Sep 17 00:00:00 2001 From: visa2 Date: Fri, 26 Jun 2026 00:04:23 +0800 Subject: [PATCH] fix(apicompat): avoid doubling tool_call arguments from single-chunk upstreams 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 --- .../chatcompletions_responses_bridge.go | 7 ++++ ...letions_responses_stream_lifecycle_test.go | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index f03e0bdce3..f0570b58ec 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -737,6 +737,13 @@ func ChatCompletionsChunkToResponsesEvents( copyCall.ID = generateItemID() } copyCall.Type = "function" + // Arguments are accumulated by the shared block below so the + // emitted delta and the stored value stay in sync. Some upstreams + // (e.g. GLM/Zhipu) pack id+name+arguments into the first tool_call + // chunk; without this reset the first chunk's arguments would be + // counted twice (once from this copy, once from the += below), + // producing a doubled, invalid JSON like {"a":1}{"a":1}. + copyCall.Function.Arguments = "" state.ToolCalls[idx] = ©Call stored = ©Call itemID := generateItemID() diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go index b71d1ba78c..baa4e4b2a5 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go @@ -179,6 +179,41 @@ func TestStream_ToolCallLifecycleComplete(t *testing.T) { 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) {