fix(apicompat): preserve arguments from done events

This commit is contained in:
David Stone
2026-09-04 21:18:36 -06:00
parent b1748c4ea9
commit 6271c517da
2 changed files with 178 additions and 11 deletions
@@ -1621,6 +1621,108 @@ func TestBufferedResponseAccumulator_ToolCalls(t *testing.T) {
assert.Equal(t, `{"city":"NYC"}`, output[0].Arguments)
}
func TestResponsesEventToChatChunks_FunctionArgumentsDoneWithoutDeltas(t *testing.T) {
state := NewResponsesEventToChatState()
state.Model = "gpt-5-codex"
state.SentRole = true
chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{
Type: "function_call",
CallID: "call_lookup",
Name: "lookup_item",
Arguments: "",
},
}, state)
require.Len(t, chunks, 1)
chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 1,
CallID: "call_lookup",
Name: "lookup_item",
Arguments: `{"id":1}`,
}, state)
require.Len(t, chunks, 1)
require.Len(t, chunks[0].Choices[0].Delta.ToolCalls, 1)
assert.Equal(t, `{"id":1}`, chunks[0].Choices[0].Delta.ToolCalls[0].Function.Arguments)
}
func TestResponsesEventToChatChunks_FunctionArgumentsDoneDoesNotDuplicateDeltas(t *testing.T) {
state := NewResponsesEventToChatState()
state.Model = "gpt-5-codex"
state.SentRole = true
ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
}, state)
chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `{"id":1}`,
}, state)
require.Len(t, chunks, 1)
chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"id":1}`,
}, state)
assert.Empty(t, chunks)
}
func TestBufferedResponseAccumulator_FunctionArgumentsDoneWithoutDeltas(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 1,
Arguments: `{"id":1}`,
})
resp := &ResponsesResponse{Output: []ResponsesOutput{{
Type: "function_call",
CallID: "call_lookup",
Name: "lookup_item",
Arguments: "",
}}}
acc.SupplementResponseOutput(resp)
require.Len(t, resp.Output, 1)
assert.Equal(t, `{"id":1}`, resp.Output[0].Arguments)
}
func TestBufferedResponseAccumulator_FunctionArgumentsDoneReplacesDeltas(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `{"id":1}`,
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"id":1}`,
})
output := acc.BuildOutput()
require.Len(t, output, 1)
assert.Equal(t, `{"id":1}`, output[0].Arguments)
}
func TestBufferedResponseAccumulator_Reasoning(t *testing.T) {
acc := NewBufferedResponseAccumulator()
@@ -126,6 +126,7 @@ type ResponsesEventToChatState struct {
Finalized bool // true after finish chunk has been emitted
NextToolCallIndex int // next sequential tool_call index to assign
OutputIndexToToolIndex map[int]int // Responses output_index → Chat tool_calls index
OutputIndexToArguments map[int]string
IncludeUsage bool
Usage *ChatUsage
}
@@ -136,6 +137,7 @@ func NewResponsesEventToChatState() *ResponsesEventToChatState {
ID: generateChatCmplID(),
Created: time.Now().Unix(),
OutputIndexToToolIndex: make(map[int]int),
OutputIndexToArguments: make(map[int]string),
}
}
@@ -154,6 +156,8 @@ func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEvent
// 均按 OutputIndex 累加到对应工具调用。
"response.custom_tool_call_input.delta":
return resToChatHandleFuncArgsDelta(evt, state)
case "response.function_call_arguments.done", "response.custom_tool_call_input.done":
return resToChatHandleFuncArgsDone(evt, state)
case "response.reasoning_summary_text.delta",
// 原始推理文本增量(真实 Codex 客户端消费的 reasoning_text.delta),
// 与 reasoning summary 一样映射为 reasoning_content。
@@ -277,6 +281,7 @@ func resToChatHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
if !ok {
return nil
}
state.OutputIndexToArguments[evt.OutputIndex] += evt.Delta
return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{
ToolCalls: []ChatToolCall{{
@@ -288,6 +293,33 @@ func resToChatHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
})}
}
func resToChatHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
idx, ok := state.OutputIndexToToolIndex[evt.OutputIndex]
if !ok {
return nil
}
completed := evt.Arguments
if evt.Type == "response.custom_tool_call_input.done" {
completed = evt.Input
}
current := state.OutputIndexToArguments[evt.OutputIndex]
if completed == "" || !strings.HasPrefix(completed, current) || completed == current {
return nil
}
remainder := completed[len(current):]
state.OutputIndexToArguments[evt.OutputIndex] = completed
return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
Function: ChatFunctionCall{
Arguments: remainder,
},
}},
})}
}
func resToChatHandleReasoningDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
if evt.Delta == "" {
return nil
@@ -453,9 +485,10 @@ func generateChatCmplID() string {
// ---------------------------------------------------------------------------
type bufferedFuncCall struct {
CallID string
Name string
Args strings.Builder
OutputIndex int
CallID string
Name string
Args strings.Builder
}
// BufferedResponseAccumulator collects content from Responses SSE delta events
@@ -489,8 +522,9 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent)
idx := len(a.funcCalls)
a.outputIndexToFuncIdx[event.OutputIndex] = idx
a.funcCalls = append(a.funcCalls, bufferedFuncCall{
CallID: event.Item.CallID,
Name: event.Item.Name,
OutputIndex: event.OutputIndex,
CallID: event.Item.CallID,
Name: event.Item.Name,
})
}
case "response.function_call_arguments.delta", "response.custom_tool_call_input.delta":
@@ -499,6 +533,17 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent)
_, _ = a.funcCalls[idx].Args.WriteString(event.Delta)
}
}
case "response.function_call_arguments.done", "response.custom_tool_call_input.done":
completed := event.Arguments
if event.Type == "response.custom_tool_call_input.done" {
completed = event.Input
}
if completed != "" {
if idx, ok := a.outputIndexToFuncIdx[event.OutputIndex]; ok {
a.funcCalls[idx].Args.Reset()
_, _ = a.funcCalls[idx].Args.WriteString(completed)
}
}
case "response.reasoning_summary_text.delta", "response.reasoning_text.delta":
if event.Delta != "" {
_, _ = a.reasoning.WriteString(event.Delta)
@@ -550,15 +595,35 @@ func (a *BufferedResponseAccumulator) BuildOutput() []ResponsesOutput {
return out
}
// SupplementResponseOutput fills resp.Output from accumulated delta content
// when the terminal event delivered an empty output array. If resp.Output is
// already populated, this is a no-op (preserves backward compatibility).
// SupplementResponseOutput fills resp.Output from accumulated stream content
// when the terminal event delivered an empty output array. It also fills empty
// function-call arguments from authoritative argument-done events.
func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesResponse) {
if resp == nil || len(resp.Output) > 0 {
if resp == nil {
return
}
if !a.HasContent() {
if len(resp.Output) == 0 {
if a.HasContent() {
resp.Output = a.BuildOutput()
}
return
}
resp.Output = a.BuildOutput()
for outputIndex := range resp.Output {
item := &resp.Output[outputIndex]
if item.Type != "function_call" || item.Arguments != "" {
continue
}
for funcIndex := range a.funcCalls {
call := &a.funcCalls[funcIndex]
matchesCallID := item.CallID != "" && item.CallID == call.CallID
if !matchesCallID && call.OutputIndex != outputIndex {
continue
}
if call.Args.Len() > 0 {
item.Arguments = call.Args.String()
}
break
}
}
}