From 531e6f621f7ae9af3b78affe056d5cecad80f03b Mon Sep 17 00:00:00 2001 From: yoruuuchan <1587761204@qq.com> Date: Mon, 26 Oct 2026 12:22:37 +0900 Subject: [PATCH] fix(codex): keep the tool name on streamed tool calls The Responses stream carries a function call's name on response.output_item.added, while the function_call_arguments events that follow carry only the item id. The relay parsed the item for type and status only, so the chunk announcing the call went out with an empty name: clients could not dispatch it, and a client that replayed that empty name in history had every later request rejected with "Invalid 'input[n].name': empty string". Record the name and call_id from the item and use them when the call is announced. Non-tool items are skipped so a reasoning item neither consumes a tool-call index nor emits a chunk. --- .../unreleased/codex-stream-tool-name.md | 7 ++ internal/providers/codex/sse.go | 21 ++++- internal/providers/codex/sse_test.go | 86 +++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 changelog/unreleased/codex-stream-tool-name.md create mode 100644 internal/providers/codex/sse_test.go diff --git a/changelog/unreleased/codex-stream-tool-name.md b/changelog/unreleased/codex-stream-tool-name.md new file mode 100644 index 00000000..690cd1e6 --- /dev/null +++ b/changelog/unreleased/codex-stream-tool-name.md @@ -0,0 +1,7 @@ +### English + +- Codex streaming keeps the function name on tool calls: it is read from `response.output_item.added` instead of the argument events, which carry only the item id. Clients no longer receive a tool call whose `name` is empty, and a session that recorded one no longer has every later request rejected with `Invalid 'input[n].name': empty string`. + +### 中文 + +- Codex 流式工具调用保留函数名:名字改从 `response.output_item.added` 的 item 读取,参数事件本身只带 item id。客户端不会再收到 `name` 为空的工具调用,之前记录过这种调用的会话也不会再被 `Invalid 'input[n].name': empty string` 整轮拒绝。 diff --git a/internal/providers/codex/sse.go b/internal/providers/codex/sse.go index b8d43c25..c5549a33 100644 --- a/internal/providers/codex/sse.go +++ b/internal/providers/codex/sse.go @@ -197,13 +197,32 @@ func (c *streamConverter) handleEvent(event, payload string) { c.emitReasoning(d) } case "response.output_item.added", "response.output_item.done": + // The tool name rides on the item; the argument events that follow + // carry only the item id. Record it here so the announced tool_call is + // never sent with an empty name: a client cannot dispatch a call whose + // name is empty, and replaying that empty name in history makes the + // upstream reject the whole request. var item struct { Type string `json:"type"` - Status string `json:"status"` + ID string `json:"id"` + CallID string `json:"call_id"` + Name string `json:"name"` } if json.Unmarshal(obj["item"], &item) != nil { return } + if item.Type != "function_call" && item.Type != "custom_tool_call" { + return + } + var outputIndex int + _ = json.Unmarshal(obj["output_index"], &outputIndex) + call := c.toolCall(outputIndex, item.ID) + if item.Name != "" && call.name == "" { + call.name = item.Name + } + if item.CallID != "" { + call.id = item.CallID + } case "response.function_call_arguments.delta": var delta struct { Delta string `json:"delta"` diff --git a/internal/providers/codex/sse_test.go b/internal/providers/codex/sse_test.go new file mode 100644 index 00000000..cf10423a --- /dev/null +++ b/internal/providers/codex/sse_test.go @@ -0,0 +1,86 @@ +package codex + +import ( + "io" + "strings" + "testing" +) + +// A Codex stream announces the tool name on response.output_item.added and then +// sends the arguments in separate events that carry only the item id. The relay +// must announce the tool call with that name: a client cannot dispatch a call +// whose name is empty, and replaying the empty name in history makes the +// upstream reject the request with "Invalid 'input[n].name': empty string". +func TestRewriteStreamKeepsToolNameFromOutputItem(t *testing.T) { + sse := `event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"get_weather","arguments":""}} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_1","delta":"{\"city\":\"Tokyo\"}"} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","output_index":0,"item_id":"fc_1","arguments":"{\"city\":\"Tokyo\"}"} + +event: response.completed +data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":10,"output_tokens":5}}} + +` + + stream, err := rewriteStream(strings.NewReader(sse), "gpt-6.1-sol") + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(stream.Reader) + if err != nil { + t.Fatal(err) + } + out := string(body) + + if !strings.Contains(out, `"name":"get_weather"`) { + t.Fatalf("tool call lost its name: %s", out) + } + if !strings.Contains(out, `"id":"call_1"`) { + t.Fatalf("tool call lost its call id: %s", out) + } + if !strings.Contains(out, `"arguments":"{\"city\":\"Tokyo\"}"`) { + t.Fatalf("tool call lost its arguments: %s", out) + } +} + +// An item that is not a tool call must not consume a tool-call index or emit a +// chunk, otherwise a reasoning item would shift every later call. +func TestRewriteStreamIgnoresNonToolOutputItems(t *testing.T) { + sse := `event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_1","summary":[]}} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc_2","call_id":"call_2","name":"read_file","arguments":""}} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","output_index":1,"item_id":"fc_2","arguments":"{\"path\":\"a.txt\"}"} + +event: response.completed +data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":1,"output_tokens":1}}} + +` + + stream, err := rewriteStream(strings.NewReader(sse), "gpt-6.1-sol") + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(stream.Reader) + if err != nil { + t.Fatal(err) + } + out := string(body) + + if strings.Contains(out, `"name":"rs_1"`) || strings.Contains(out, `"name":""`) { + t.Fatalf("non-tool item leaked into tool_calls: %s", out) + } + if !strings.Contains(out, `"name":"read_file"`) { + t.Fatalf("tool call lost its name: %s", out) + } + if !strings.Contains(out, `"index":0`) { + t.Fatalf("tool call index should start at 0: %s", out) + } +}