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) + } +}