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)
|
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) {
|
func TestFinalizeResponsesToChatStreamFlushesPendingDeltaOnlyArguments(t *testing.T) {
|
||||||
state := newTestResponsesStreamState()
|
state := newTestResponsesStreamState()
|
||||||
outputIndex := 2
|
outputIndex := 2
|
||||||
|
|||||||
@@ -269,6 +269,23 @@ func (s *ResponsesToChatStreamState) ensureToolForEvent(event *dto.ResponsesStre
|
|||||||
}
|
}
|
||||||
|
|
||||||
tool := s.toolByKey[key]
|
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 {
|
if tool == nil {
|
||||||
tool = &responsesStreamTool{Key: key, Index: s.nextToolIndex}
|
tool = &responsesStreamTool{Key: key, Index: s.nextToolIndex}
|
||||||
s.nextToolIndex++
|
s.nextToolIndex++
|
||||||
|
|||||||
Reference in New Issue
Block a user