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:
@@ -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++
|
||||
|
||||
Reference in New Issue
Block a user