Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/codex-stream-tool-name.md
Original file line number Diff line number Diff line change
@@ -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` 整轮拒绝。
21 changes: 20 additions & 1 deletion internal/providers/codex/sse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
86 changes: 86 additions & 0 deletions internal/providers/codex/sse_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading