diff --git a/relaykit/dto/openai_response.go b/relaykit/dto/openai_response.go index 945d0a869904..7a0e8ded9d95 100644 --- a/relaykit/dto/openai_response.go +++ b/relaykit/dto/openai_response.go @@ -123,7 +123,7 @@ type ToolCallResponse struct { // Index is not nil only in chat completion chunk object Index *int `json:"index,omitempty"` ID string `json:"id,omitempty"` - Type any `json:"type"` + Type any `json:"type,omitempty"` Function FunctionResponse `json:"function"` } diff --git a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp_test.go b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp_test.go index 49efa07d163e..eaec34d55ecc 100644 --- a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp_test.go +++ b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_resp_test.go @@ -1,6 +1,7 @@ package oairesponses import ( + "encoding/json" "testing" "github.com/QuantumNous/new-api/relaykit/dto" @@ -246,6 +247,135 @@ func TestResponsesStreamEventToChatChunksCustomToolAndReasoning(t *testing.T) { assert.Equal(t, "content_filter", *chunks[4].Choices[0].FinishReason) } +func TestResponsesStreamEventToChatChunksEmitsToolMetadataOnlyOnce(t *testing.T) { + state := newTestResponsesStreamState() + outputIndex := 0 + + itemChunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemAdded, + OutputIndex: &outputIndex, + Item: &dto.ResponsesOutput{ + Type: responsesOutputTypeFunctionCall, + ID: "fc_1", + CallId: "call_1", + Name: "show_widget", + }, + }) + firstArgsChunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{ + Type: responsesEventFunctionArgsDelta, + OutputIndex: &outputIndex, + Delta: `{"widget_code":"`, + }) + secondArgsChunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{ + Type: responsesEventFunctionArgsDelta, + OutputIndex: &outputIndex, + Delta: `"}`, + }) + + require.Len(t, itemChunks, 2) + require.Len(t, firstArgsChunks, 1) + require.Len(t, secondArgsChunks, 1) + + first := itemChunks[1].Choices[0].Delta.ToolCalls[0] + assert.Equal(t, "call_1", first.ID) + assert.Equal(t, "function", first.Type) + assert.Equal(t, "show_widget", first.Function.Name) + + for i, chunks := range [][]dto.ChatCompletionsStreamResponse{firstArgsChunks, secondArgsChunks} { + continuation := chunks[0].Choices[0].Delta.ToolCalls[0] + require.NotNil(t, continuation.Index) + assert.Equal(t, 0, *continuation.Index) + assert.Equal(t, []string{`{"widget_code":"`, `"}`}[i], continuation.Function.Arguments) + assert.Empty(t, continuation.ID) + assert.Nil(t, continuation.Type) + assert.Empty(t, continuation.Function.Name) + + encoded, err := json.Marshal(continuation) + require.NoError(t, err) + assert.NotContains(t, string(encoded), `"id"`) + assert.NotContains(t, string(encoded), `"type"`) + assert.NotContains(t, string(encoded), `"name"`) + } +} + +func TestResponsesStreamEventToChatChunksEmitsMetadataForEachParallelTool(t *testing.T) { + state := newTestResponsesStreamState() + firstOutputIndex := 0 + secondOutputIndex := 1 + + firstChunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemAdded, + OutputIndex: &firstOutputIndex, + Item: &dto.ResponsesOutput{ + Type: responsesOutputTypeFunctionCall, + CallId: "call_1", + Name: "first_tool", + }, + }) + secondChunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemAdded, + OutputIndex: &secondOutputIndex, + Item: &dto.ResponsesOutput{ + Type: responsesOutputTypeFunctionCall, + CallId: "call_2", + Name: "second_tool", + }, + }) + firstArgsChunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{ + Type: responsesEventFunctionArgsDelta, + OutputIndex: &firstOutputIndex, + Delta: `{"first":`, + }) + secondArgsChunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{ + Type: responsesEventFunctionArgsDelta, + OutputIndex: &secondOutputIndex, + Delta: `{"second":`, + }) + firstArgsEndChunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{ + Type: responsesEventFunctionArgsDelta, + OutputIndex: &firstOutputIndex, + Delta: `"one"}`, + }) + secondArgsEndChunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{ + Type: responsesEventFunctionArgsDelta, + OutputIndex: &secondOutputIndex, + Delta: `"two"}`, + }) + + require.Len(t, firstChunks, 2) + require.Len(t, secondChunks, 1) + + first := firstChunks[1].Choices[0].Delta.ToolCalls[0] + second := secondChunks[0].Choices[0].Delta.ToolCalls[0] + assert.Equal(t, "call_1", first.ID) + assert.Equal(t, "function", first.Type) + assert.Equal(t, 0, *first.Index) + assert.Equal(t, "call_2", second.ID) + assert.Equal(t, "function", second.Type) + assert.Equal(t, 1, *second.Index) + + continuations := []struct { + chunks []dto.ChatCompletionsStreamResponse + index int + arguments string + }{ + {firstArgsChunks, 0, `{"first":`}, + {secondArgsChunks, 1, `{"second":`}, + {firstArgsEndChunks, 0, `"one"}`}, + {secondArgsEndChunks, 1, `"two"}`}, + } + for _, tt := range continuations { + require.Len(t, tt.chunks, 1) + continuation := tt.chunks[0].Choices[0].Delta.ToolCalls[0] + require.NotNil(t, continuation.Index) + assert.Equal(t, tt.index, *continuation.Index) + assert.Equal(t, tt.arguments, continuation.Function.Arguments) + assert.Empty(t, continuation.ID) + assert.Nil(t, continuation.Type) + assert.Empty(t, continuation.Function.Name) + } +} + func TestResponsesStreamEventToChatChunksUsesTerminalDoneOutput(t *testing.T) { state := newTestResponsesStreamState() chunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{ diff --git a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_stream_resp.go b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_stream_resp.go index 6026e3899eeb..769ec4c27f57 100644 --- a/relaykit/relayconvert/internal/oai_responses/to_oai_chat_stream_resp.go +++ b/relaykit/relayconvert/internal/oai_responses/to_oai_chat_stream_resp.go @@ -397,25 +397,25 @@ func (s *ResponsesToChatStreamState) toolDelta(tool *responsesStreamTool, explic } chunks := s.ensureStart() - callID := strings.TrimSpace(tool.CallID) - if callID == "" { - callID = tool.Key - } responseTool := dto.ToolCallResponse{ - ID: callID, - Type: "function", Function: dto.FunctionResponse{ Arguments: argsDelta, }, } responseTool.SetIndex(tool.Index) + if !tool.Sent { + callID := strings.TrimSpace(tool.CallID) + if callID == "" { + callID = tool.Key + } + responseTool.ID = callID + responseTool.Type = "function" + tool.Sent = true + } if !tool.NameSent && tool.Name != "" { responseTool.Function.Name = tool.Name tool.NameSent = true } - if !tool.Sent { - tool.Sent = true - } if argsDelta != "" { tool.ArgsSentAt += len(argsDelta) s.usageText.WriteString(argsDelta)