* fix(openai): harden Chat-to-Responses compatibility Add a shared Responses-to-Chat stream state machine and use it from the OpenAI relay path. Preserve assistant text alongside tool calls, bind tool argument deltas by output_index, map incomplete finish reasons, support reasoning/custom tool events, and buffer upstream SSE for non-stream Chat clients. Add deterministic service tests and relay SSE tests for the conversion path. Related to #5745. * refactor: rename openaicompat to relayconvert for improved clarity * feat(gemini): support responses request conversion * feat: add responses to chat conversion support * fix: harden responses chat conversion edge cases
574 lines
20 KiB
Go
574 lines
20 KiB
Go
package relayconvert
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/QuantumNous/new-api/dto"
|
|
"github.com/samber/lo"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing.T) {
|
|
req := &dto.GeneralOpenAIRequest{
|
|
Model: "gpt-test",
|
|
N: lo.ToPtr(1),
|
|
Messages: []dto.Message{
|
|
{Role: "system", Content: "system rules"},
|
|
{Role: "developer", Content: "developer rules"},
|
|
{Role: "user", Content: []any{
|
|
map[string]any{"type": "text", "text": "look"},
|
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.test/a.png"}},
|
|
}},
|
|
assistantMessageWithTool("partial text", "call_1", "lookup", `{"q":"x"}`),
|
|
{Role: "tool", ToolCallId: "call_1", Content: "tool result"},
|
|
},
|
|
}
|
|
|
|
got, err := ChatCompletionsRequestToResponsesRequest(req)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "gpt-test", got.Model)
|
|
assert.Equal(t, `"system rules\n\ndeveloper rules"`, string(got.Instructions))
|
|
assert.Equal(t, "input_image", gjson.GetBytes(got.Input, "0.content.1.type").String())
|
|
assert.Equal(t, "function_call", gjson.GetBytes(got.Input, "2.type").String())
|
|
assert.Equal(t, "call_1", gjson.GetBytes(got.Input, "2.call_id").String())
|
|
assert.Equal(t, "function_call_output", gjson.GetBytes(got.Input, "3.type").String())
|
|
}
|
|
|
|
func TestChatCompletionsRequestToResponsesRequestRejectsMultipleChoices(t *testing.T) {
|
|
_, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
|
|
Model: "gpt-test",
|
|
N: lo.ToPtr(2),
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "n>1")
|
|
}
|
|
|
|
func TestResponsesResponseToChatCompletionsPreservesTextAndToolCalls(t *testing.T) {
|
|
resp := &dto.OpenAIResponsesResponse{
|
|
ID: "resp_1",
|
|
CreatedAt: 123,
|
|
Model: "gpt-test",
|
|
Status: []byte(`"completed"`),
|
|
Output: []dto.ResponsesOutput{
|
|
{
|
|
Type: responsesOutputTypeMessage,
|
|
Role: "assistant",
|
|
Content: []dto.ResponsesOutputContent{
|
|
{Type: "output_text", Text: "I will call a tool."},
|
|
},
|
|
},
|
|
{
|
|
Type: responsesOutputTypeFunctionCall,
|
|
ID: "fc_1",
|
|
CallId: "call_1",
|
|
Name: "lookup",
|
|
Arguments: []byte(`{"q":"x"}`),
|
|
},
|
|
},
|
|
Usage: &dto.Usage{InputTokens: 3, OutputTokens: 4, TotalTokens: 7},
|
|
}
|
|
|
|
chat, usage, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, usage)
|
|
|
|
require.Len(t, chat.Choices, 1)
|
|
assert.Equal(t, "tool_calls", chat.Choices[0].FinishReason)
|
|
assert.Equal(t, "I will call a tool.", chat.Choices[0].Message.StringContent())
|
|
toolCalls := chat.Choices[0].Message.ParseToolCalls()
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Equal(t, "call_1", toolCalls[0].ID)
|
|
assert.Equal(t, "lookup", toolCalls[0].Function.Name)
|
|
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
|
assert.Equal(t, 7, usage.TotalTokens)
|
|
}
|
|
|
|
func TestResponsesResponseToChatCompletionsPreservesReasoningSummary(t *testing.T) {
|
|
resp := &dto.OpenAIResponsesResponse{
|
|
ID: "resp_1",
|
|
Model: "gpt-test",
|
|
Status: []byte(`"completed"`),
|
|
Output: []dto.ResponsesOutput{
|
|
{
|
|
Type: responsesOutputTypeReasoning,
|
|
Content: []dto.ResponsesOutputContent{
|
|
{Type: "summary_text", Text: "first summary"},
|
|
{Type: "summary_text", Text: "\n\nsecond summary"},
|
|
},
|
|
},
|
|
{
|
|
Type: responsesOutputTypeMessage,
|
|
Role: "assistant",
|
|
Content: []dto.ResponsesOutputContent{
|
|
{Type: "output_text", Text: "final"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "first summary\n\nsecond summary", chat.Choices[0].Message.GetReasoningContent())
|
|
assert.Equal(t, "final", chat.Choices[0].Message.StringContent())
|
|
}
|
|
|
|
func TestResponsesFinishReasonFromIncompleteStatus(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
reason string
|
|
want string
|
|
}{
|
|
{name: "max output", reason: responsesIncompleteReasonMaxTokens, want: "length"},
|
|
{name: "content filter", reason: responsesIncompleteReasonContentFilter, want: "content_filter"},
|
|
{name: "unknown", reason: "other", want: "length"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, ok := ResponsesFinishReasonFromStatus(&dto.OpenAIResponsesResponse{
|
|
Status: []byte(`"incomplete"`),
|
|
IncompleteDetails: &dto.IncompleteDetails{Reason: tt.reason},
|
|
})
|
|
require.True(t, ok)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamEventToChatChunksUsesOutputIndexForToolArguments(t *testing.T) {
|
|
state := newTestResponsesStreamState()
|
|
outputIndex := 1
|
|
|
|
var chunks []dto.ChatCompletionsStreamResponse
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventOutputTextDelta, Delta: "text before tool"})...)
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventFunctionArgsDelta,
|
|
OutputIndex: &outputIndex,
|
|
Delta: `{"cmd":"ls"}`,
|
|
})...)
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventOutputItemAdded,
|
|
OutputIndex: &outputIndex,
|
|
Item: &dto.ResponsesOutput{
|
|
Type: responsesOutputTypeFunctionCall,
|
|
ID: "fc_1",
|
|
CallId: "call_1",
|
|
Name: "exec",
|
|
},
|
|
})...)
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventCompleted,
|
|
Response: &dto.OpenAIResponsesResponse{
|
|
Status: []byte(`"completed"`),
|
|
Usage: &dto.Usage{InputTokens: 1, OutputTokens: 2, TotalTokens: 3},
|
|
},
|
|
})...)
|
|
|
|
require.Len(t, chunks, 4)
|
|
assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role)
|
|
assert.Equal(t, "text before tool", chunks[1].Choices[0].Delta.GetContentString())
|
|
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
|
|
require.NotNil(t, tool.Index)
|
|
assert.Equal(t, 0, *tool.Index)
|
|
assert.Equal(t, "call_1", tool.ID)
|
|
assert.Equal(t, "exec", tool.Function.Name)
|
|
assert.Equal(t, `{"cmd":"ls"}`, tool.Function.Arguments)
|
|
require.NotNil(t, chunks[3].Choices[0].FinishReason)
|
|
assert.Equal(t, "tool_calls", *chunks[3].Choices[0].FinishReason)
|
|
assert.Equal(t, 3, state.Usage.TotalTokens)
|
|
}
|
|
|
|
func TestResponsesStreamEventToChatChunksDoesNotDuplicatePendingArgsWithOutputIndexAndItemID(t *testing.T) {
|
|
state := newTestResponsesStreamState()
|
|
outputIndex := 1
|
|
|
|
var chunks []dto.ChatCompletionsStreamResponse
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventFunctionArgsDelta,
|
|
OutputIndex: &outputIndex,
|
|
ItemID: "fc_1",
|
|
Delta: `{"q":"x"}`,
|
|
})...)
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventOutputItemAdded,
|
|
OutputIndex: &outputIndex,
|
|
ItemID: "fc_1",
|
|
Item: &dto.ResponsesOutput{
|
|
Type: responsesOutputTypeFunctionCall,
|
|
ID: "fc_1",
|
|
CallId: "call_1",
|
|
Name: "lookup",
|
|
},
|
|
})...)
|
|
|
|
require.Len(t, chunks, 2)
|
|
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
|
|
assert.Equal(t, "call_1", tool.ID)
|
|
assert.Equal(t, "lookup", tool.Function.Name)
|
|
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
|
|
assert.Empty(t, state.pendingArgsByOutputIndex)
|
|
assert.Empty(t, state.pendingArgsByItemID)
|
|
}
|
|
|
|
func TestResponsesStreamEventToChatChunksDrainsItemOnlyPendingArgsWhenOutputIndexArrives(t *testing.T) {
|
|
state := newTestResponsesStreamState()
|
|
outputIndex := 1
|
|
|
|
var chunks []dto.ChatCompletionsStreamResponse
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventFunctionArgsDelta,
|
|
ItemID: "fc_1",
|
|
Delta: `{"q":"x"}`,
|
|
})...)
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventOutputItemAdded,
|
|
OutputIndex: &outputIndex,
|
|
ItemID: "fc_1",
|
|
Item: &dto.ResponsesOutput{
|
|
Type: responsesOutputTypeFunctionCall,
|
|
CallId: "call_1",
|
|
Name: "lookup",
|
|
},
|
|
})...)
|
|
|
|
require.Len(t, chunks, 2)
|
|
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
|
|
assert.Equal(t, "call_1", tool.ID)
|
|
assert.Equal(t, "lookup", tool.Function.Name)
|
|
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
|
|
assert.Empty(t, state.pendingArgsByOutputIndex)
|
|
assert.Empty(t, state.pendingArgsByItemID)
|
|
}
|
|
|
|
func TestResponsesStreamEventToChatChunksCustomToolAndReasoning(t *testing.T) {
|
|
state := newTestResponsesStreamState()
|
|
outputIndex := 0
|
|
|
|
chunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventReasoningTextDelta,
|
|
Delta: "thinking",
|
|
})
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventOutputItemAdded,
|
|
OutputIndex: &outputIndex,
|
|
Item: &dto.ResponsesOutput{
|
|
Type: responsesOutputTypeCustomToolCall,
|
|
ID: "ct_1",
|
|
CallId: "call_custom",
|
|
Name: "apply_patch",
|
|
},
|
|
})...)
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventCustomToolInputDelta,
|
|
OutputIndex: &outputIndex,
|
|
Delta: "patch body",
|
|
})...)
|
|
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventIncomplete,
|
|
Response: &dto.OpenAIResponsesResponse{
|
|
IncompleteDetails: &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter},
|
|
},
|
|
})...)
|
|
|
|
require.Len(t, chunks, 5)
|
|
assert.Equal(t, "thinking", chunks[1].Choices[0].Delta.GetReasoningContent())
|
|
assert.Equal(t, "apply_patch", chunks[2].Choices[0].Delta.ToolCalls[0].Function.Name)
|
|
assert.Equal(t, "patch body", chunks[3].Choices[0].Delta.ToolCalls[0].Function.Arguments)
|
|
require.NotNil(t, chunks[4].Choices[0].FinishReason)
|
|
assert.Equal(t, "content_filter", *chunks[4].Choices[0].FinishReason)
|
|
}
|
|
|
|
func TestResponsesStreamEventToChatChunksUsesTerminalDoneOutput(t *testing.T) {
|
|
state := newTestResponsesStreamState()
|
|
chunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
|
Type: responsesEventDone,
|
|
Response: &dto.OpenAIResponsesResponse{
|
|
Status: []byte(`"completed"`),
|
|
Output: []dto.ResponsesOutput{
|
|
{
|
|
Type: responsesOutputTypeMessage,
|
|
Role: "assistant",
|
|
Content: []dto.ResponsesOutputContent{
|
|
{Type: "output_text", Text: "terminal text"},
|
|
},
|
|
},
|
|
{
|
|
Type: responsesOutputTypeFunctionCall,
|
|
ID: "fc_1",
|
|
CallId: "call_1",
|
|
Name: "lookup",
|
|
Arguments: []byte(`{"q":"x"}`),
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
require.Len(t, chunks, 4)
|
|
assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role)
|
|
assert.Equal(t, "terminal text", chunks[1].Choices[0].Delta.GetContentString())
|
|
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
|
|
assert.Equal(t, "lookup", tool.Function.Name)
|
|
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
|
|
require.NotNil(t, chunks[3].Choices[0].FinishReason)
|
|
assert.Equal(t, "tool_calls", *chunks[3].Choices[0].FinishReason)
|
|
}
|
|
|
|
func TestFinalizeResponsesToChatStreamFlushesPendingDeltaOnlyArguments(t *testing.T) {
|
|
state := newTestResponsesStreamState()
|
|
outputIndex := 2
|
|
_, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{
|
|
Type: responsesEventFunctionArgsDelta,
|
|
OutputIndex: &outputIndex,
|
|
Delta: `{"pending":true}`,
|
|
}, state)
|
|
require.NoError(t, err)
|
|
|
|
chunks := FinalizeResponsesToChatStream(state)
|
|
require.Len(t, chunks, 3)
|
|
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
|
|
assert.Equal(t, "call_output_2", tool.ID)
|
|
assert.Equal(t, `{"pending":true}`, tool.Function.Arguments)
|
|
require.NotNil(t, chunks[2].Choices[0].FinishReason)
|
|
assert.Equal(t, "tool_calls", *chunks[2].Choices[0].FinishReason)
|
|
}
|
|
|
|
func TestResponsesStreamEventToChatChunksFailedEventReturnsError(t *testing.T) {
|
|
_, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{Type: responsesEventFailed}, newTestResponsesStreamState())
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestResponsesBufferedAccumulatorSupplementsEmptyTerminalOutput(t *testing.T) {
|
|
acc := NewResponsesBufferedAccumulator()
|
|
outputIndex := 1
|
|
acc.ProcessEvent(&dto.ResponsesStreamResponse{Type: responsesEventOutputTextDelta, Delta: "buffered text"})
|
|
acc.ProcessEvent(&dto.ResponsesStreamResponse{
|
|
Type: responsesEventOutputItemAdded,
|
|
OutputIndex: &outputIndex,
|
|
Item: &dto.ResponsesOutput{
|
|
Type: responsesOutputTypeFunctionCall,
|
|
ID: "fc_1",
|
|
CallId: "call_1",
|
|
Name: "lookup",
|
|
},
|
|
})
|
|
acc.ProcessEvent(&dto.ResponsesStreamResponse{
|
|
Type: responsesEventFunctionArgsDelta,
|
|
OutputIndex: &outputIndex,
|
|
Delta: `{"q":"x"}`,
|
|
})
|
|
|
|
resp := &dto.OpenAIResponsesResponse{
|
|
Status: []byte(`"completed"`),
|
|
Model: "gpt-test",
|
|
}
|
|
acc.SupplementResponseOutput(resp)
|
|
|
|
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "buffered text", chat.Choices[0].Message.StringContent())
|
|
toolCalls := chat.Choices[0].Message.ParseToolCalls()
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
|
}
|
|
|
|
func TestResponsesBufferedAccumulatorDoesNotDuplicatePendingArgsWithOutputIndexAndItemID(t *testing.T) {
|
|
acc := NewResponsesBufferedAccumulator()
|
|
outputIndex := 1
|
|
acc.ProcessEvent(&dto.ResponsesStreamResponse{
|
|
Type: responsesEventFunctionArgsDelta,
|
|
OutputIndex: &outputIndex,
|
|
ItemID: "fc_1",
|
|
Delta: `{"q":"x"}`,
|
|
})
|
|
acc.ProcessEvent(&dto.ResponsesStreamResponse{
|
|
Type: responsesEventOutputItemAdded,
|
|
OutputIndex: &outputIndex,
|
|
ItemID: "fc_1",
|
|
Item: &dto.ResponsesOutput{
|
|
Type: responsesOutputTypeFunctionCall,
|
|
ID: "fc_1",
|
|
CallId: "call_1",
|
|
Name: "lookup",
|
|
},
|
|
})
|
|
|
|
resp := &dto.OpenAIResponsesResponse{
|
|
Status: []byte(`"completed"`),
|
|
Model: "gpt-test",
|
|
}
|
|
acc.SupplementResponseOutput(resp)
|
|
|
|
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
|
|
require.NoError(t, err)
|
|
toolCalls := chat.Choices[0].Message.ParseToolCalls()
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
|
assert.Empty(t, acc.pendingByOutputIndex)
|
|
assert.Empty(t, acc.pendingByItemID)
|
|
}
|
|
|
|
func TestChatCompletionsResponseToResponsesPreservesTextToolCallsAndUsage(t *testing.T) {
|
|
chat := &dto.OpenAITextResponse{
|
|
Id: "chatcmpl_1",
|
|
Model: "gpt-test",
|
|
Created: 456,
|
|
Choices: []dto.OpenAITextResponseChoice{
|
|
{
|
|
Message: assistantMessageWithTool("I will call.", "call_1", "lookup", `{"q":"x"}`),
|
|
FinishReason: "tool_calls",
|
|
},
|
|
},
|
|
Usage: dto.Usage{PromptTokens: 3, CompletionTokens: 5, TotalTokens: 8},
|
|
}
|
|
|
|
resp, usage, err := ChatCompletionsResponseToResponsesResponse(chat, "resp_1")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, usage)
|
|
|
|
assert.Equal(t, "resp_1", resp.ID)
|
|
assert.Equal(t, "response", resp.Object)
|
|
assert.Equal(t, `"completed"`, string(resp.Status))
|
|
assert.Equal(t, 3, resp.Usage.InputTokens)
|
|
assert.Equal(t, 5, resp.Usage.OutputTokens)
|
|
require.Len(t, resp.Output, 2)
|
|
assert.Equal(t, responsesOutputTypeMessage, resp.Output[0].Type)
|
|
assert.Equal(t, "I will call.", resp.Output[0].Content[0].Text)
|
|
assert.Equal(t, responsesOutputTypeFunctionCall, resp.Output[1].Type)
|
|
assert.Equal(t, "call_1", resp.Output[1].CallId)
|
|
assert.Equal(t, "lookup", resp.Output[1].Name)
|
|
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments))
|
|
}
|
|
|
|
func TestChatCompletionsResponseToResponsesMapsIncompleteFinishReasons(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
finishReason string
|
|
wantReason string
|
|
}{
|
|
{name: "length", finishReason: "length", wantReason: responsesIncompleteReasonMaxTokens},
|
|
{name: "content filter", finishReason: "content_filter", wantReason: responsesIncompleteReasonContentFilter},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
resp, _, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
|
|
Id: "chatcmpl_1",
|
|
Model: "gpt-test",
|
|
Choices: []dto.OpenAITextResponseChoice{
|
|
{
|
|
Message: dto.Message{Role: "assistant", Content: "partial"},
|
|
FinishReason: tt.finishReason,
|
|
},
|
|
},
|
|
}, "resp_1")
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, `"incomplete"`, string(resp.Status))
|
|
require.NotNil(t, resp.IncompleteDetails)
|
|
assert.Equal(t, tt.wantReason, resp.IncompleteDetails.Reason)
|
|
require.Len(t, resp.Output, 1)
|
|
assert.Equal(t, "incomplete", resp.Output[0].Status)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsStreamToResponsesEventsAggregatesUsageAndToolArgs(t *testing.T) {
|
|
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
|
state.Created = 123
|
|
toolIndex := 0
|
|
|
|
var events []ChatToResponsesStreamEvent
|
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
Id: "chatcmpl_1",
|
|
Model: "gpt-test",
|
|
Created: 123,
|
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
|
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}},
|
|
},
|
|
})...)
|
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
|
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("hello")}},
|
|
},
|
|
})...)
|
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
|
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
|
|
{Index: &toolIndex, ID: "call_1", Type: "function", Function: dto.FunctionResponse{Name: "lookup"}},
|
|
}}},
|
|
},
|
|
})...)
|
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
|
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
|
|
{Index: &toolIndex, Function: dto.FunctionResponse{Arguments: `{"q":"x"}`}},
|
|
}}},
|
|
},
|
|
})...)
|
|
finishReason := "tool_calls"
|
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
|
{Index: 0, FinishReason: &finishReason},
|
|
},
|
|
})...)
|
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 4, TotalTokens: 6},
|
|
})...)
|
|
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
|
|
|
require.Len(t, events, 10)
|
|
assert.Equal(t, responsesEventCreated, events[0].Type)
|
|
assert.Equal(t, responsesEventOutputTextDelta, events[2].Type)
|
|
assert.Equal(t, "hello", events[2].Payload.Delta)
|
|
assert.Equal(t, responsesEventFunctionArgsDelta, events[4].Type)
|
|
assert.Equal(t, `{"q":"x"}`, events[4].Payload.Delta)
|
|
assert.Equal(t, responsesEventCompleted, events[9].Type)
|
|
require.NotNil(t, events[9].Payload.Response)
|
|
assert.Equal(t, 6, events[9].Payload.Response.Usage.TotalTokens)
|
|
require.Len(t, events[9].Payload.Response.Output, 2)
|
|
assert.Equal(t, "hello", events[9].Payload.Response.Output[0].Content[0].Text)
|
|
assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments))
|
|
}
|
|
|
|
func assistantMessageWithTool(content string, id string, name string, args string) dto.Message {
|
|
msg := dto.Message{Role: "assistant", Content: content}
|
|
msg.SetToolCalls([]dto.ToolCallRequest{
|
|
{
|
|
ID: id,
|
|
Type: "function",
|
|
Function: dto.FunctionRequest{
|
|
Name: name,
|
|
Arguments: args,
|
|
},
|
|
},
|
|
})
|
|
return msg
|
|
}
|
|
|
|
func newTestResponsesStreamState() *ResponsesToChatStreamState {
|
|
state := NewResponsesToChatStreamState("gpt-test", false)
|
|
state.ID = "chatcmpl_test"
|
|
state.Created = 123
|
|
return state
|
|
}
|
|
|
|
func mustStreamChunks(t *testing.T, state *ResponsesToChatStreamState, event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
|
|
t.Helper()
|
|
chunks, err := ResponsesStreamEventToChatChunks(event, state)
|
|
require.NoError(t, err)
|
|
return chunks
|
|
}
|
|
|
|
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
|
|
t.Helper()
|
|
events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
|
|
require.NoError(t, err)
|
|
return events
|
|
}
|