fix: prevent duplicate tool calls in Responses-to-Chat streaming (#6225)

When a function call was already registered under its output_index key
via response.output_item.added, the synthetic events built from the
terminal response.completed output carry no output_index and resolve to
a different item-based key. ensureToolForEvent then created a second
tool index and resent the full arguments, so Chat Completions clients
received the same tool call twice.

Reuse the tool registered under itemIDToKey/callIDToKey before creating
a new one, and alias the new key to the existing tool.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Niku
2026-07-18 12:50:02 +08:00
committed by GitHub
co-authored by Claude Fable 5
parent 923a17ca8a
commit 1086038f5f
2 changed files with 75 additions and 0 deletions
@@ -281,6 +281,64 @@ func TestResponsesStreamEventToChatChunksUsesTerminalDoneOutput(t *testing.T) {
assert.Equal(t, "tool_calls", *chunks[3].Choices[0].FinishReason)
}
func TestResponsesStreamEventToChatChunksDoesNotResendToolOnTerminalOutput(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 0
var chunks []dto.ChatCompletionsStreamResponse
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
},
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
Delta: `{"q":"x"}`,
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventCompleted,
Response: &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
Arguments: []byte(`{"q":"x"}`),
},
},
},
})...)
totalArgs := ""
toolIndexes := map[int]bool{}
var finishReason string
for _, chunk := range chunks {
for _, choice := range chunk.Choices {
for _, tc := range choice.Delta.ToolCalls {
require.NotNil(t, tc.Index)
toolIndexes[*tc.Index] = true
totalArgs += tc.Function.Arguments
}
if choice.FinishReason != nil {
finishReason = *choice.FinishReason
}
}
}
assert.Equal(t, map[int]bool{0: true}, toolIndexes)
assert.Equal(t, `{"q":"x"}`, totalArgs)
assert.Equal(t, "tool_calls", finishReason)
}
func TestFinalizeResponsesToChatStreamFlushesPendingDeltaOnlyArguments(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 2
@@ -269,6 +269,23 @@ func (s *ResponsesToChatStreamState) ensureToolForEvent(event *dto.ResponsesStre
}
tool := s.toolByKey[key]
if tool == nil {
if itemID := responseStreamEventItemID(event); itemID != "" {
if existingKey := s.itemIDToKey[itemID]; existingKey != "" {
tool = s.toolByKey[existingKey]
}
}
if tool == nil {
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
if existingKey := s.callIDToKey[callID]; existingKey != "" {
tool = s.toolByKey[existingKey]
}
}
}
if tool != nil {
s.toolByKey[key] = tool
}
}
if tool == nil {
tool = &responsesStreamTool{Key: key, Index: s.nextToolIndex}
s.nextToolIndex++