mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 01:56:53 +00:00
feat: support Responses to Chat (#5787)
* 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
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func normalizeChatImageURLToString(v any) any {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
return vv
|
||||
case map[string]any:
|
||||
if url := common.Interface2String(vv["url"]); url != "" {
|
||||
return url
|
||||
}
|
||||
return v
|
||||
case dto.MessageImageUrl:
|
||||
if vv.Url != "" {
|
||||
return vv.Url
|
||||
}
|
||||
return v
|
||||
case *dto.MessageImageUrl:
|
||||
if vv != nil && vv.Url != "" {
|
||||
return vv.Url
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func convertChatResponseFormatToResponsesText(reqFormat *dto.ResponseFormat) json.RawMessage {
|
||||
if reqFormat == nil || strings.TrimSpace(reqFormat.Type) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
format := map[string]any{
|
||||
"type": reqFormat.Type,
|
||||
}
|
||||
|
||||
if reqFormat.Type == "json_schema" && len(reqFormat.JsonSchema) > 0 {
|
||||
var chatSchema map[string]any
|
||||
if err := common.Unmarshal(reqFormat.JsonSchema, &chatSchema); err == nil {
|
||||
for key, value := range chatSchema {
|
||||
if key == "type" {
|
||||
continue
|
||||
}
|
||||
format[key] = value
|
||||
}
|
||||
|
||||
if nested, ok := format["json_schema"].(map[string]any); ok {
|
||||
for key, value := range nested {
|
||||
if _, exists := format[key]; !exists {
|
||||
format[key] = value
|
||||
}
|
||||
}
|
||||
delete(format, "json_schema")
|
||||
}
|
||||
} else {
|
||||
format["json_schema"] = reqFormat.JsonSchema
|
||||
}
|
||||
}
|
||||
|
||||
textRaw, _ := common.Marshal(map[string]any{
|
||||
"format": format,
|
||||
})
|
||||
return textRaw
|
||||
}
|
||||
|
||||
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
if req.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
if lo.FromPtrOr(req.N, 1) > 1 {
|
||||
return nil, fmt.Errorf("n>1 is not supported in responses compatibility mode")
|
||||
}
|
||||
|
||||
var instructionsParts []string
|
||||
inputItems := make([]map[string]any, 0, len(req.Messages))
|
||||
|
||||
for _, msg := range req.Messages {
|
||||
role := strings.TrimSpace(msg.Role)
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if role == "tool" || role == "function" {
|
||||
callID := strings.TrimSpace(msg.ToolCallId)
|
||||
|
||||
var output any
|
||||
if msg.Content == nil {
|
||||
output = ""
|
||||
} else if msg.IsStringContent() {
|
||||
output = msg.StringContent()
|
||||
} else {
|
||||
if b, err := common.Marshal(msg.Content); err == nil {
|
||||
output = string(b)
|
||||
} else {
|
||||
output = fmt.Sprintf("%v", msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
if callID == "" {
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"role": "user",
|
||||
"content": fmt.Sprintf("[tool_output_missing_call_id] %v", output),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call_output",
|
||||
"call_id": callID,
|
||||
"output": output,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Prefer mapping system/developer messages into `instructions`.
|
||||
if role == "system" || role == "developer" {
|
||||
if msg.Content == nil {
|
||||
continue
|
||||
}
|
||||
if msg.IsStringContent() {
|
||||
if s := strings.TrimSpace(msg.StringContent()); s != "" {
|
||||
instructionsParts = append(instructionsParts, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := msg.ParseContent()
|
||||
var sb strings.Builder
|
||||
for _, part := range parts {
|
||||
if part.Type == dto.ContentTypeText && strings.TrimSpace(part.Text) != "" {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
if s := strings.TrimSpace(sb.String()); s != "" {
|
||||
instructionsParts = append(instructionsParts, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
item := map[string]any{
|
||||
"role": role,
|
||||
}
|
||||
|
||||
if msg.Content == nil {
|
||||
item["content"] = ""
|
||||
inputItems = append(inputItems, item)
|
||||
|
||||
if role == "assistant" {
|
||||
for _, tc := range msg.ParseToolCalls() {
|
||||
if strings.TrimSpace(tc.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if tc.Type != "" && tc.Type != "function" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(tc.Function.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": tc.ID,
|
||||
"name": name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.IsStringContent() {
|
||||
item["content"] = msg.StringContent()
|
||||
inputItems = append(inputItems, item)
|
||||
|
||||
if role == "assistant" {
|
||||
for _, tc := range msg.ParseToolCalls() {
|
||||
if strings.TrimSpace(tc.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if tc.Type != "" && tc.Type != "function" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(tc.Function.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": tc.ID,
|
||||
"name": name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
parts := msg.ParseContent()
|
||||
contentParts := make([]map[string]any, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
switch part.Type {
|
||||
case dto.ContentTypeText:
|
||||
textType := "input_text"
|
||||
if role == "assistant" {
|
||||
textType = "output_text"
|
||||
}
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": textType,
|
||||
"text": part.Text,
|
||||
})
|
||||
case dto.ContentTypeImageURL:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_image",
|
||||
"image_url": normalizeChatImageURLToString(part.ImageUrl),
|
||||
})
|
||||
case dto.ContentTypeInputAudio:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_audio",
|
||||
"input_audio": part.InputAudio,
|
||||
})
|
||||
case dto.ContentTypeFile:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_file",
|
||||
"file": part.File,
|
||||
})
|
||||
case dto.ContentTypeVideoUrl:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_video",
|
||||
"video_url": part.VideoUrl,
|
||||
})
|
||||
default:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": part.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
item["content"] = contentParts
|
||||
inputItems = append(inputItems, item)
|
||||
|
||||
if role == "assistant" {
|
||||
for _, tc := range msg.ParseToolCalls() {
|
||||
if strings.TrimSpace(tc.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if tc.Type != "" && tc.Type != "function" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(tc.Function.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": tc.ID,
|
||||
"name": name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inputRaw, err := common.Marshal(inputItems)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var instructionsRaw json.RawMessage
|
||||
if len(instructionsParts) > 0 {
|
||||
instructions := strings.Join(instructionsParts, "\n\n")
|
||||
instructionsRaw, _ = common.Marshal(instructions)
|
||||
}
|
||||
|
||||
var toolsRaw json.RawMessage
|
||||
if req.Tools != nil {
|
||||
tools := make([]map[string]any, 0, len(req.Tools))
|
||||
for _, tool := range req.Tools {
|
||||
switch tool.Type {
|
||||
case "function":
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"name": tool.Function.Name,
|
||||
"description": tool.Function.Description,
|
||||
"parameters": tool.Function.Parameters,
|
||||
})
|
||||
default:
|
||||
// Best-effort: keep original tool shape for unknown types.
|
||||
var m map[string]any
|
||||
if b, err := common.Marshal(tool); err == nil {
|
||||
_ = common.Unmarshal(b, &m)
|
||||
}
|
||||
if len(m) == 0 {
|
||||
m = map[string]any{"type": tool.Type}
|
||||
}
|
||||
tools = append(tools, m)
|
||||
}
|
||||
}
|
||||
toolsRaw, _ = common.Marshal(tools)
|
||||
}
|
||||
|
||||
var toolChoiceRaw json.RawMessage
|
||||
if req.ToolChoice != nil {
|
||||
switch v := req.ToolChoice.(type) {
|
||||
case string:
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
default:
|
||||
var m map[string]any
|
||||
if b, err := common.Marshal(v); err == nil {
|
||||
_ = common.Unmarshal(b, &m)
|
||||
}
|
||||
if m == nil {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
} else if t, _ := m["type"].(string); t == "function" {
|
||||
// Chat: {"type":"function","function":{"name":"..."}}
|
||||
// Responses: {"type":"function","name":"..."}
|
||||
if name, ok := m["name"].(string); ok && name != "" {
|
||||
toolChoiceRaw, _ = common.Marshal(map[string]any{
|
||||
"type": "function",
|
||||
"name": name,
|
||||
})
|
||||
} else if fn, ok := m["function"].(map[string]any); ok {
|
||||
if name, ok := fn["name"].(string); ok && name != "" {
|
||||
toolChoiceRaw, _ = common.Marshal(map[string]any{
|
||||
"type": "function",
|
||||
"name": name,
|
||||
})
|
||||
} else {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
}
|
||||
} else {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
}
|
||||
} else {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parallelToolCallsRaw json.RawMessage
|
||||
if req.ParallelTooCalls != nil {
|
||||
parallelToolCallsRaw, _ = common.Marshal(*req.ParallelTooCalls)
|
||||
}
|
||||
|
||||
textRaw := convertChatResponseFormatToResponsesText(req.ResponseFormat)
|
||||
|
||||
maxOutputTokens := lo.FromPtrOr(req.MaxTokens, uint(0))
|
||||
maxCompletionTokens := lo.FromPtrOr(req.MaxCompletionTokens, uint(0))
|
||||
if maxCompletionTokens > maxOutputTokens {
|
||||
maxOutputTokens = maxCompletionTokens
|
||||
}
|
||||
// OpenAI Responses API rejects max_output_tokens < 16 when explicitly provided.
|
||||
//if maxOutputTokens > 0 && maxOutputTokens < 16 {
|
||||
// maxOutputTokens = 16
|
||||
//}
|
||||
|
||||
var topP *float64
|
||||
if req.TopP != nil {
|
||||
topP = common.GetPointer(lo.FromPtr(req.TopP))
|
||||
}
|
||||
|
||||
out := &dto.OpenAIResponsesRequest{
|
||||
Model: req.Model,
|
||||
Input: inputRaw,
|
||||
Instructions: instructionsRaw,
|
||||
Stream: req.Stream,
|
||||
Temperature: req.Temperature,
|
||||
Text: textRaw,
|
||||
ToolChoice: toolChoiceRaw,
|
||||
Tools: toolsRaw,
|
||||
TopP: topP,
|
||||
User: req.User,
|
||||
ParallelToolCalls: parallelToolCallsRaw,
|
||||
Store: req.Store,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
if req.MaxTokens != nil || req.MaxCompletionTokens != nil {
|
||||
out.MaxOutputTokens = lo.ToPtr(maxOutputTokens)
|
||||
}
|
||||
|
||||
if req.ReasoningEffort != "" {
|
||||
out.Reasoning = &dto.Reasoning{
|
||||
Effort: req.ReasoningEffort,
|
||||
Summary: "detailed",
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
chatFinishReasonLength = "length"
|
||||
chatFinishReasonContentFilter = "content_filter"
|
||||
)
|
||||
|
||||
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
|
||||
if resp == nil {
|
||||
return nil, nil, errors.New("response is nil")
|
||||
}
|
||||
|
||||
usage := UsageFromChatUsage(&resp.Usage)
|
||||
out := &dto.OpenAIResponsesResponse{
|
||||
ID: id,
|
||||
Object: "response",
|
||||
CreatedAt: chatCreatedAt(resp.Created),
|
||||
Status: []byte(`"completed"`),
|
||||
Model: resp.Model,
|
||||
Output: make([]dto.ResponsesOutput, 0),
|
||||
Usage: usage,
|
||||
}
|
||||
|
||||
if len(resp.Choices) == 0 {
|
||||
return out, usage, nil
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
if status, details := ResponsesStatusFromChatFinishReason(choice.FinishReason); status != "" {
|
||||
out.Status = []byte(fmt.Sprintf("%q", status))
|
||||
out.IncompleteDetails = details
|
||||
}
|
||||
|
||||
if text := choice.Message.StringContent(); text != "" {
|
||||
out.Output = append(out.Output, dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeMessage,
|
||||
ID: fmt.Sprintf("%s_msg_0", id),
|
||||
Status: responseOutputStatus(out),
|
||||
Role: "assistant",
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{
|
||||
Type: "output_text",
|
||||
Text: text,
|
||||
Annotations: []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
|
||||
out.Output = append(out.Output, dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeReasoning,
|
||||
ID: fmt.Sprintf("%s_reasoning_0", id),
|
||||
Status: responseOutputStatus(out),
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{
|
||||
Type: "summary_text",
|
||||
Text: reasoning,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for i, toolCall := range choice.Message.ParseToolCalls() {
|
||||
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out.Output = append(out.Output, toolOutput)
|
||||
}
|
||||
|
||||
return out, usage, nil
|
||||
}
|
||||
|
||||
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
|
||||
switch strings.TrimSpace(finishReason) {
|
||||
case chatFinishReasonLength:
|
||||
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonMaxTokens}
|
||||
case chatFinishReasonContentFilter:
|
||||
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter}
|
||||
default:
|
||||
return "completed", nil
|
||||
}
|
||||
}
|
||||
|
||||
func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
|
||||
usage := &dto.Usage{}
|
||||
if src == nil {
|
||||
return usage
|
||||
}
|
||||
if src.PromptTokens != 0 {
|
||||
usage.PromptTokens = src.PromptTokens
|
||||
usage.InputTokens = src.PromptTokens
|
||||
}
|
||||
if src.CompletionTokens != 0 {
|
||||
usage.CompletionTokens = src.CompletionTokens
|
||||
usage.OutputTokens = src.CompletionTokens
|
||||
}
|
||||
if src.TotalTokens != 0 {
|
||||
usage.TotalTokens = src.TotalTokens
|
||||
} else {
|
||||
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
|
||||
}
|
||||
if src.PromptTokensDetails.CachedTokens != 0 ||
|
||||
src.PromptTokensDetails.ImageTokens != 0 ||
|
||||
src.PromptTokensDetails.AudioTokens != 0 ||
|
||||
src.PromptTokensDetails.CachedCreationTokens != 0 ||
|
||||
src.PromptTokensDetails.TextTokens != 0 {
|
||||
details := src.PromptTokensDetails
|
||||
usage.InputTokensDetails = &details
|
||||
}
|
||||
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
|
||||
src.CompletionTokenDetails.TextTokens != 0 ||
|
||||
src.CompletionTokenDetails.AudioTokens != 0 ||
|
||||
src.CompletionTokenDetails.ImageTokens != 0 {
|
||||
usage.CompletionTokenDetails = src.CompletionTokenDetails
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
type ChatToResponsesStreamEvent struct {
|
||||
Type string
|
||||
Payload dto.ResponsesStreamResponse
|
||||
}
|
||||
|
||||
type ChatToResponsesStreamState struct {
|
||||
ID string
|
||||
Model string
|
||||
Created int64
|
||||
Usage *dto.Usage
|
||||
|
||||
status string
|
||||
incompleteDetails *dto.IncompleteDetails
|
||||
sentCreated bool
|
||||
textOutputIndex int
|
||||
textStarted bool
|
||||
textDone bool
|
||||
reasoningIndex int
|
||||
reasoningStarted bool
|
||||
reasoningDone bool
|
||||
finalized bool
|
||||
nextOutputIndex int
|
||||
toolsByIndex map[int]*chatToResponsesStreamTool
|
||||
outputOrder []chatToResponsesOutputRef
|
||||
text strings.Builder
|
||||
reasoning strings.Builder
|
||||
}
|
||||
|
||||
type chatToResponsesStreamTool struct {
|
||||
ChatIndex int
|
||||
OutputIndex int
|
||||
ID string
|
||||
Name string
|
||||
Arguments strings.Builder
|
||||
Done bool
|
||||
}
|
||||
|
||||
type chatToResponsesOutputRef struct {
|
||||
Kind string
|
||||
ToolIndex int
|
||||
}
|
||||
|
||||
func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState {
|
||||
return &ChatToResponsesStreamState{
|
||||
ID: id,
|
||||
Model: model,
|
||||
Created: time.Now().Unix(),
|
||||
Usage: &dto.Usage{},
|
||||
status: "completed",
|
||||
textOutputIndex: -1,
|
||||
reasoningIndex: -1,
|
||||
toolsByIndex: make(map[int]*chatToResponsesStreamTool),
|
||||
}
|
||||
}
|
||||
|
||||
func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) {
|
||||
if chunk == nil || state == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if state.ID == "" {
|
||||
state.ID = chunk.Id
|
||||
}
|
||||
if state.Model == "" {
|
||||
state.Model = chunk.Model
|
||||
}
|
||||
if state.Created == 0 {
|
||||
state.Created = chunk.Created
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
state.Usage = UsageFromChatUsage(chunk.Usage)
|
||||
}
|
||||
|
||||
events := make([]ChatToResponsesStreamEvent, 0)
|
||||
if !state.sentCreated {
|
||||
state.sentCreated = true
|
||||
events = append(events, responsesStreamEvent(responsesEventCreated, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventCreated,
|
||||
Response: state.createdResponse(),
|
||||
}))
|
||||
}
|
||||
for _, choice := range chunk.Choices {
|
||||
if choice.Delta.GetReasoningContent() != "" {
|
||||
events = append(events, state.appendReasoningDelta(choice.Delta.GetReasoningContent())...)
|
||||
}
|
||||
if choice.Delta.GetContentString() != "" {
|
||||
events = append(events, state.appendTextDelta(choice.Delta.GetContentString())...)
|
||||
}
|
||||
for _, toolCall := range choice.Delta.ToolCalls {
|
||||
toolEvents, err := state.appendToolCallDelta(toolCall)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, toolEvents...)
|
||||
}
|
||||
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
|
||||
state.applyFinishReason(*choice.FinishReason)
|
||||
events = append(events, state.doneDeltaEvents()...)
|
||||
}
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent {
|
||||
if state == nil || state.finalized {
|
||||
return nil
|
||||
}
|
||||
events := state.doneDeltaEvents()
|
||||
state.finalized = true
|
||||
resp := state.finalResponse()
|
||||
eventType := responsesEventCompleted
|
||||
if state.status == "incomplete" {
|
||||
eventType = responsesEventIncomplete
|
||||
}
|
||||
events = append(events, responsesStreamEvent(eventType, dto.ResponsesStreamResponse{
|
||||
Type: eventType,
|
||||
Response: resp,
|
||||
}))
|
||||
return events
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) UsageText() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.text.String()
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent {
|
||||
events := make([]ChatToResponsesStreamEvent, 0, 2)
|
||||
if !s.textStarted {
|
||||
s.textStarted = true
|
||||
s.textOutputIndex = s.nextIndex("message", -1)
|
||||
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: intPtr(s.textOutputIndex),
|
||||
Item: &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeMessage,
|
||||
ID: s.messageID(),
|
||||
Status: "in_progress",
|
||||
Role: "assistant",
|
||||
Content: []dto.ResponsesOutputContent{},
|
||||
},
|
||||
}))
|
||||
}
|
||||
s.text.WriteString(delta)
|
||||
events = append(events, responsesStreamEvent(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputTextDelta,
|
||||
OutputIndex: intPtr(s.textOutputIndex),
|
||||
ContentIndex: intPtr(0),
|
||||
Delta: delta,
|
||||
ItemID: s.messageID(),
|
||||
}))
|
||||
return events
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent {
|
||||
events := make([]ChatToResponsesStreamEvent, 0, 2)
|
||||
if !s.reasoningStarted {
|
||||
s.reasoningStarted = true
|
||||
s.reasoningIndex = s.nextIndex("reasoning", -1)
|
||||
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
Item: &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeReasoning,
|
||||
ID: s.reasoningID(),
|
||||
Status: "in_progress",
|
||||
Content: []dto.ResponsesOutputContent{},
|
||||
},
|
||||
}))
|
||||
}
|
||||
s.reasoning.WriteString(delta)
|
||||
events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventReasoningSummaryDelta,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
SummaryIndex: intPtr(0),
|
||||
Delta: delta,
|
||||
ItemID: s.reasoningID(),
|
||||
}))
|
||||
return events
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallResponse) ([]ChatToResponsesStreamEvent, error) {
|
||||
chatIndex := 0
|
||||
if toolCall.Index != nil {
|
||||
chatIndex = *toolCall.Index
|
||||
}
|
||||
tool := s.toolsByIndex[chatIndex]
|
||||
events := make([]ChatToResponsesStreamEvent, 0, 2)
|
||||
if tool == nil {
|
||||
tool = &chatToResponsesStreamTool{
|
||||
ChatIndex: chatIndex,
|
||||
OutputIndex: s.nextIndex("tool", chatIndex),
|
||||
ID: strings.TrimSpace(toolCall.ID),
|
||||
Name: strings.TrimSpace(toolCall.Function.Name),
|
||||
}
|
||||
if tool.ID == "" {
|
||||
tool.ID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
|
||||
}
|
||||
s.toolsByIndex[chatIndex] = tool
|
||||
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ID,
|
||||
Item: &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: tool.ID,
|
||||
Status: "in_progress",
|
||||
CallId: tool.ID,
|
||||
Name: tool.Name,
|
||||
Arguments: []byte(`""`),
|
||||
},
|
||||
}))
|
||||
}
|
||||
if strings.TrimSpace(toolCall.ID) != "" {
|
||||
tool.ID = strings.TrimSpace(toolCall.ID)
|
||||
}
|
||||
if strings.TrimSpace(toolCall.Function.Name) != "" {
|
||||
tool.Name = strings.TrimSpace(toolCall.Function.Name)
|
||||
}
|
||||
if toolCall.Function.Arguments != "" {
|
||||
tool.Arguments.WriteString(toolCall.Function.Arguments)
|
||||
events = append(events, responsesStreamEvent(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ID,
|
||||
Delta: toolCall.Function.Arguments,
|
||||
}))
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEvent {
|
||||
events := make([]ChatToResponsesStreamEvent, 0)
|
||||
status := s.outputStatus()
|
||||
if s.textStarted && !s.textDone {
|
||||
s.textDone = true
|
||||
events = append(events, responsesStreamEvent("response.output_text.done", dto.ResponsesStreamResponse{
|
||||
Type: "response.output_text.done",
|
||||
OutputIndex: intPtr(s.textOutputIndex),
|
||||
ContentIndex: intPtr(0),
|
||||
ItemID: s.messageID(),
|
||||
}))
|
||||
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemDone,
|
||||
OutputIndex: intPtr(s.textOutputIndex),
|
||||
Item: s.messageOutput(status),
|
||||
}))
|
||||
}
|
||||
if s.reasoningStarted && !s.reasoningDone {
|
||||
s.reasoningDone = true
|
||||
events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventReasoningSummaryDone,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
SummaryIndex: intPtr(0),
|
||||
ItemID: s.reasoningID(),
|
||||
Part: &dto.ResponsesReasoningSummaryPart{
|
||||
Type: "summary_text",
|
||||
Text: s.reasoning.String(),
|
||||
},
|
||||
}))
|
||||
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemDone,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
Item: s.reasoningOutput(status),
|
||||
}))
|
||||
}
|
||||
for _, tool := range s.sortedTools() {
|
||||
if tool.Done {
|
||||
continue
|
||||
}
|
||||
tool.Done = true
|
||||
events = append(events, responsesStreamEvent(responsesEventFunctionArgsDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDone,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ID,
|
||||
}))
|
||||
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemDone,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
Item: s.toolOutput(tool, status),
|
||||
}))
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) applyFinishReason(finishReason string) {
|
||||
if status, details := ResponsesStatusFromChatFinishReason(finishReason); status != "" {
|
||||
s.status = status
|
||||
s.incompleteDetails = details
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesResponse {
|
||||
output := make([]dto.ResponsesOutput, 0, len(s.outputOrder))
|
||||
status := s.outputStatus()
|
||||
for _, ref := range s.outputOrder {
|
||||
switch ref.Kind {
|
||||
case "message":
|
||||
output = append(output, *s.messageOutput(status))
|
||||
case "reasoning":
|
||||
output = append(output, *s.reasoningOutput(status))
|
||||
case "tool":
|
||||
if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil {
|
||||
output = append(output, *s.toolOutput(tool, status))
|
||||
}
|
||||
}
|
||||
}
|
||||
return &dto.OpenAIResponsesResponse{
|
||||
ID: s.ID,
|
||||
Object: "response",
|
||||
CreatedAt: int(s.Created),
|
||||
Status: []byte(fmt.Sprintf("%q", s.status)),
|
||||
IncompleteDetails: s.incompleteDetails,
|
||||
Model: s.Model,
|
||||
Output: output,
|
||||
Usage: s.Usage,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) createdResponse() *dto.OpenAIResponsesResponse {
|
||||
return &dto.OpenAIResponsesResponse{
|
||||
ID: s.ID,
|
||||
Object: "response",
|
||||
CreatedAt: int(s.Created),
|
||||
Status: []byte(`"in_progress"`),
|
||||
Model: s.Model,
|
||||
Output: []dto.ResponsesOutput{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int {
|
||||
index := s.nextOutputIndex
|
||||
s.nextOutputIndex++
|
||||
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: kind, ToolIndex: toolIndex})
|
||||
return index
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) sortedTools() []*chatToResponsesStreamTool {
|
||||
indexes := make([]int, 0, len(s.toolsByIndex))
|
||||
for index := range s.toolsByIndex {
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
sort.Ints(indexes)
|
||||
tools := make([]*chatToResponsesStreamTool, 0, len(indexes))
|
||||
for _, index := range indexes {
|
||||
tools = append(tools, s.toolsByIndex[index])
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) outputStatus() string {
|
||||
if s.status == "incomplete" {
|
||||
return "incomplete"
|
||||
}
|
||||
return "completed"
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) messageID() string {
|
||||
return fmt.Sprintf("%s_msg_0", s.ID)
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) reasoningID() string {
|
||||
return fmt.Sprintf("%s_reasoning_0", s.ID)
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput {
|
||||
return &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeMessage,
|
||||
ID: s.messageID(),
|
||||
Status: status,
|
||||
Role: "assistant",
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{
|
||||
Type: "output_text",
|
||||
Text: s.text.String(),
|
||||
Annotations: []interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.ResponsesOutput {
|
||||
return &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeReasoning,
|
||||
ID: s.reasoningID(),
|
||||
Status: status,
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{
|
||||
Type: "summary_text",
|
||||
Text: s.reasoning.String(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput {
|
||||
return &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: tool.ID,
|
||||
Status: status,
|
||||
CallId: tool.ID,
|
||||
Name: tool.Name,
|
||||
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
|
||||
}
|
||||
}
|
||||
|
||||
func responseOutputStatus(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || responseStatusString(resp) != "incomplete" {
|
||||
return "completed"
|
||||
}
|
||||
return "incomplete"
|
||||
}
|
||||
|
||||
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) {
|
||||
callID := strings.TrimSpace(toolCall.ID)
|
||||
if callID == "" {
|
||||
callID = fmt.Sprintf("%s_call_%d", responseID, index)
|
||||
}
|
||||
if toolCall.Type == "" || toolCall.Type == "function" {
|
||||
return dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: callID,
|
||||
Status: status,
|
||||
CallId: callID,
|
||||
Name: toolCall.Function.Name,
|
||||
Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments),
|
||||
}, nil
|
||||
}
|
||||
return dto.ResponsesOutput{
|
||||
Type: toolCall.Type,
|
||||
ID: callID,
|
||||
Status: status,
|
||||
CallId: callID,
|
||||
Arguments: toolCall.Custom,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func chatArgumentsRawMessage(arguments string) []byte {
|
||||
raw, err := common.Marshal(arguments)
|
||||
if err != nil {
|
||||
return []byte(`""`)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func chatCreatedAt(created any) int {
|
||||
switch v := created.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case float32:
|
||||
return int(v)
|
||||
case string:
|
||||
if parsed := common.String2Int(v); parsed != 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return int(time.Now().Unix())
|
||||
}
|
||||
|
||||
func responsesStreamEvent(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
|
||||
payload.Type = eventType
|
||||
return ChatToResponsesStreamEvent{
|
||||
Type: eventType,
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package relayconvert
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/model_setting"
|
||||
|
||||
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
|
||||
if !policy.IsChannelEnabled(channelID, channelType) {
|
||||
return false
|
||||
}
|
||||
return matchAnyRegex(policy.ModelPatterns, model)
|
||||
}
|
||||
|
||||
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
|
||||
return ShouldChatCompletionsUseResponsesPolicy(
|
||||
model_setting.GetGlobalSettings().ChatCompletionsToResponsesPolicy,
|
||||
channelID,
|
||||
channelType,
|
||||
model,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var compiledRegexCache sync.Map // map[string]*regexp.Regexp
|
||||
|
||||
func matchAnyRegex(patterns []string, s string) bool {
|
||||
if len(patterns) == 0 || s == "" {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
re, ok := compiledRegexCache.Load(pattern)
|
||||
if !ok {
|
||||
compiled, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
// Treat invalid patterns as non-matching to avoid breaking runtime traffic.
|
||||
continue
|
||||
}
|
||||
re = compiled
|
||||
compiledRegexCache.Store(pattern, re)
|
||||
}
|
||||
if re.(*regexp.Regexp).MatchString(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
responsesInputTypeFunctionCall = "function_call"
|
||||
responsesInputTypeFunctionCallOutput = "function_call_output"
|
||||
responsesInputTypeCustomToolCall = "custom_tool_call"
|
||||
)
|
||||
|
||||
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
if req.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
if err := validateResponsesRequestChatUnsupportedFields(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
messages, err := responsesRequestMessagesToChat(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tools, err := responsesRequestToolsToChat(req.Tools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toolChoice, err := responsesRequestToolChoiceToChat(req.ToolChoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responseFormat, err := responsesRequestTextToChatResponseFormat(req.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &dto.GeneralOpenAIRequest{
|
||||
Model: req.Model,
|
||||
Messages: messages,
|
||||
Stream: req.Stream,
|
||||
StreamOptions: req.StreamOptions,
|
||||
MaxCompletionTokens: req.MaxOutputTokens,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
TopLogProbs: req.TopLogProbs,
|
||||
ResponseFormat: responseFormat,
|
||||
Tools: tools,
|
||||
ToolChoice: toolChoice,
|
||||
User: req.User,
|
||||
Store: req.Store,
|
||||
Metadata: req.Metadata,
|
||||
SafetyIdentifier: req.SafetyIdentifier,
|
||||
PromptCacheRetention: req.PromptCacheRetention,
|
||||
EnableThinking: req.EnableThinking,
|
||||
}
|
||||
|
||||
if req.Reasoning != nil {
|
||||
out.ReasoningEffort = req.Reasoning.Effort
|
||||
}
|
||||
if req.ServiceTier != "" {
|
||||
out.ServiceTier, _ = common.Marshal(req.ServiceTier)
|
||||
}
|
||||
if len(req.ParallelToolCalls) > 0 && common.GetJsonType(req.ParallelToolCalls) == "boolean" {
|
||||
var parallelToolCalls bool
|
||||
if err := common.Unmarshal(req.ParallelToolCalls, ¶llelToolCalls); err == nil {
|
||||
out.ParallelTooCalls = ¶llelToolCalls
|
||||
}
|
||||
}
|
||||
if len(req.PromptCacheKey) > 0 && common.GetJsonType(req.PromptCacheKey) == "string" {
|
||||
var promptCacheKey string
|
||||
if err := common.Unmarshal(req.PromptCacheKey, &promptCacheKey); err == nil {
|
||||
out.PromptCacheKey = promptCacheKey
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
|
||||
unsupported := make([]string, 0, 4)
|
||||
if rawJSONPresent(req.Conversation) {
|
||||
unsupported = append(unsupported, "conversation")
|
||||
}
|
||||
if strings.TrimSpace(req.PreviousResponseID) != "" {
|
||||
unsupported = append(unsupported, "previous_response_id")
|
||||
}
|
||||
if rawJSONPresent(req.Prompt) {
|
||||
unsupported = append(unsupported, "prompt")
|
||||
}
|
||||
if rawJSONPresent(req.ContextManagement) {
|
||||
unsupported = append(unsupported, "context_management")
|
||||
}
|
||||
if len(unsupported) > 0 {
|
||||
return fmt.Errorf("responses to chat conversion does not support stateful fields: %s", strings.Join(unsupported, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
|
||||
messages := make([]dto.Message, 0)
|
||||
if rawJSONPresent(req.Instructions) {
|
||||
instructions, err := responsesJSONString(req.Instructions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid instructions: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(instructions) != "" {
|
||||
messages = append(messages, dto.Message{Role: "system", Content: instructions})
|
||||
}
|
||||
}
|
||||
|
||||
if !rawJSONPresent(req.Input) {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
switch common.GetJsonType(req.Input) {
|
||||
case "string":
|
||||
input, err := responsesJSONString(req.Input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid input string: %w", err)
|
||||
}
|
||||
messages = append(messages, dto.Message{Role: "user", Content: input})
|
||||
return messages, nil
|
||||
case "array":
|
||||
var items []map[string]any
|
||||
if err := common.Unmarshal(req.Input, &items); err != nil {
|
||||
return nil, fmt.Errorf("invalid input array: %w", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
nextMessages, err := responsesInputItemToChatMessages(item, messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = nextMessages
|
||||
}
|
||||
return messages, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported responses input type %q", common.GetJsonType(req.Input))
|
||||
}
|
||||
}
|
||||
|
||||
func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) {
|
||||
itemType := strings.TrimSpace(common.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case responsesInputTypeFunctionCall:
|
||||
toolCall, err := responsesFunctionCallItemToChatToolCall(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appendToolCallToLastAssistant(messages, toolCall), nil
|
||||
case responsesInputTypeCustomToolCall:
|
||||
toolCall, err := responsesCustomToolCallItemToChatToolCall(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appendToolCallToLastAssistant(messages, toolCall), nil
|
||||
case responsesInputTypeFunctionCallOutput:
|
||||
callID := strings.TrimSpace(common.Interface2String(item["call_id"]))
|
||||
content := responseToolOutputToChatContent(item["output"])
|
||||
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(common.Interface2String(item["role"]))
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
content, err := responsesInputContentToChatContent(item["content"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(messages, dto.Message{Role: role, Content: content}), nil
|
||||
}
|
||||
|
||||
func responsesInputContentToChatContent(content any) (any, error) {
|
||||
if content == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
switch value := content.(type) {
|
||||
case string:
|
||||
return value, nil
|
||||
case []any:
|
||||
return responsesContentPartsToChatContent(value)
|
||||
case []map[string]any:
|
||||
parts := make([]any, 0, len(value))
|
||||
for _, part := range value {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return responsesContentPartsToChatContent(parts)
|
||||
default:
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
func responsesContentPartsToChatContent(parts []any) (any, error) {
|
||||
chatParts := make([]any, 0, len(parts))
|
||||
var textOnly strings.Builder
|
||||
onlyText := true
|
||||
|
||||
for _, rawPart := range parts {
|
||||
part, ok := rawPart.(map[string]any)
|
||||
if !ok {
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, rawPart)
|
||||
continue
|
||||
}
|
||||
|
||||
partType := strings.TrimSpace(common.Interface2String(part["type"]))
|
||||
switch partType {
|
||||
case "input_text", "output_text", "text":
|
||||
text := common.Interface2String(part["text"])
|
||||
textOnly.WriteString(text)
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeText,
|
||||
"text": text,
|
||||
})
|
||||
case "input_image":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeImageURL,
|
||||
"image_url": responsesImagePartToChatImageURL(part),
|
||||
})
|
||||
case "input_file":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeFile,
|
||||
"file": responsesFilePartToChatFile(part),
|
||||
})
|
||||
case "input_audio":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeInputAudio,
|
||||
"input_audio": responsesPartPayload(part, "input_audio"),
|
||||
})
|
||||
case "input_video":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeVideoUrl,
|
||||
"video_url": responsesVideoPartToChatVideoURL(part),
|
||||
})
|
||||
default:
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, part)
|
||||
}
|
||||
}
|
||||
|
||||
if onlyText {
|
||||
return textOnly.String(), nil
|
||||
}
|
||||
return chatParts, nil
|
||||
}
|
||||
|
||||
func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
||||
name := strings.TrimSpace(common.Interface2String(item["name"]))
|
||||
if name == "" {
|
||||
return dto.ToolCallRequest{}, errors.New("function_call item is missing name")
|
||||
}
|
||||
return dto.ToolCallRequest{
|
||||
ID: responsesCallID(item),
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: name,
|
||||
Arguments: responsesArgumentsString(item["arguments"]),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func responsesCustomToolCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
||||
raw, err := common.Marshal(item)
|
||||
if err != nil {
|
||||
return dto.ToolCallRequest{}, err
|
||||
}
|
||||
return dto.ToolCallRequest{
|
||||
ID: responsesCallID(item),
|
||||
Type: dto.CustomType,
|
||||
Custom: raw,
|
||||
Function: dto.FunctionRequest{
|
||||
Name: strings.TrimSpace(common.Interface2String(item["name"])),
|
||||
Arguments: responsesArgumentsString(item["input"]),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func appendToolCallToLastAssistant(messages []dto.Message, toolCall dto.ToolCallRequest) []dto.Message {
|
||||
if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" {
|
||||
messages = append(messages, dto.Message{Role: "assistant"})
|
||||
}
|
||||
|
||||
idx := len(messages) - 1
|
||||
toolCalls := messages[idx].ParseToolCalls()
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
toolCallsRaw, _ := common.Marshal(toolCalls)
|
||||
messages[idx].ToolCalls = toolCallsRaw
|
||||
return messages
|
||||
}
|
||||
|
||||
func responsesRequestToolsToChat(raw json.RawMessage) ([]dto.ToolCallRequest, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var tools []map[string]any
|
||||
if err := common.Unmarshal(raw, &tools); err != nil {
|
||||
return nil, fmt.Errorf("invalid tools: %w", err)
|
||||
}
|
||||
|
||||
out := make([]dto.ToolCallRequest, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
toolType := strings.TrimSpace(common.Interface2String(tool["type"]))
|
||||
if toolType == "function" {
|
||||
out = append(out, dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: strings.TrimSpace(common.Interface2String(tool["name"])),
|
||||
Description: common.Interface2String(tool["description"]),
|
||||
Parameters: tool["parameters"],
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
rawTool, err := common.Marshal(tool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, dto.ToolCallRequest{
|
||||
Type: toolType,
|
||||
Custom: rawTool,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
if common.GetJsonType(raw) == "string" {
|
||||
var choice string
|
||||
if err := common.Unmarshal(raw, &choice); err != nil {
|
||||
return nil, fmt.Errorf("invalid tool_choice: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
var choice map[string]any
|
||||
if err := common.Unmarshal(raw, &choice); err != nil {
|
||||
return nil, fmt.Errorf("invalid tool_choice: %w", err)
|
||||
}
|
||||
if common.Interface2String(choice["type"]) == "function" {
|
||||
name := strings.TrimSpace(common.Interface2String(choice["name"]))
|
||||
if name != "" {
|
||||
return map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": name,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var textConfig map[string]any
|
||||
if err := common.Unmarshal(raw, &textConfig); err != nil {
|
||||
return nil, fmt.Errorf("invalid text config: %w", err)
|
||||
}
|
||||
format, ok := textConfig["format"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
formatType := strings.TrimSpace(common.Interface2String(format["type"]))
|
||||
if formatType == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
out := &dto.ResponseFormat{Type: formatType}
|
||||
if formatType == "json_schema" {
|
||||
schemaRaw, err := common.Marshal(format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.JsonSchema = schemaRaw
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func responsesImagePartToChatImageURL(part map[string]any) any {
|
||||
if imageURL, ok := part["image_url"]; ok {
|
||||
return imageURL
|
||||
}
|
||||
imageURL := map[string]any{}
|
||||
for _, key := range []string{"url", "file_id", "detail"} {
|
||||
if value, ok := part[key]; ok {
|
||||
imageURL[key] = value
|
||||
}
|
||||
}
|
||||
if len(imageURL) == 0 {
|
||||
return part
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func responsesFilePartToChatFile(part map[string]any) any {
|
||||
if file, ok := part["file"]; ok {
|
||||
return file
|
||||
}
|
||||
file := map[string]any{}
|
||||
for _, key := range []string{"file_id", "file_data", "filename", "file_url"} {
|
||||
if value, ok := part[key]; ok {
|
||||
file[key] = value
|
||||
}
|
||||
}
|
||||
if len(file) == 0 {
|
||||
return part
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
func responsesVideoPartToChatVideoURL(part map[string]any) any {
|
||||
if videoURL, ok := part["video_url"]; ok {
|
||||
if videoURLMap, ok := videoURL.(map[string]any); ok {
|
||||
if url := common.Interface2String(videoURLMap["url"]); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return videoURL
|
||||
}
|
||||
if url := common.Interface2String(part["url"]); url != "" {
|
||||
return url
|
||||
}
|
||||
return responsesPartPayload(part, "video_url")
|
||||
}
|
||||
|
||||
func responsesPartPayload(part map[string]any, key string) any {
|
||||
if value, ok := part[key]; ok {
|
||||
return value
|
||||
}
|
||||
payload := make(map[string]any, len(part))
|
||||
for k, value := range part {
|
||||
if k == "type" {
|
||||
continue
|
||||
}
|
||||
payload[k] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func responsesCallID(item map[string]any) string {
|
||||
callID := strings.TrimSpace(common.Interface2String(item["call_id"]))
|
||||
if callID != "" {
|
||||
return callID
|
||||
}
|
||||
return strings.TrimSpace(common.Interface2String(item["id"]))
|
||||
}
|
||||
|
||||
func responsesArgumentsString(value any) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
raw, err := common.Marshal(v)
|
||||
if err != nil {
|
||||
return common.Interface2String(v)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func responseToolOutputToChatContent(value any) any {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
raw, err := common.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func responsesJSONString(raw json.RawMessage) (string, error) {
|
||||
if common.GetJsonType(raw) != "string" {
|
||||
return string(raw), nil
|
||||
}
|
||||
var value string
|
||||
if err := common.Unmarshal(raw, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func rawJSONPresent(raw json.RawMessage) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
return common.GetJsonType(raw) != "null"
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"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 TestResponsesRequestToChatCompletionsRequestInstructionsAndScalarInput(t *testing.T) {
|
||||
stream := true
|
||||
temperature := 0.0
|
||||
topP := 0.9
|
||||
maxOutputTokens := uint(128)
|
||||
parallelToolCalls := true
|
||||
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Instructions: mustRawMessage(t, "system rules"),
|
||||
Input: mustRawMessage(t, "hello"),
|
||||
Stream: &stream,
|
||||
StreamOptions: &dto.StreamOptions{IncludeUsage: true},
|
||||
MaxOutputTokens: &maxOutputTokens,
|
||||
Temperature: &temperature,
|
||||
TopP: &topP,
|
||||
User: mustRawMessage(t, "user-1"),
|
||||
Store: mustRawMessage(t, false),
|
||||
Metadata: mustRawMessage(t, map[string]any{"trace": "abc"}),
|
||||
ParallelToolCalls: mustRawMessage(t, parallelToolCalls),
|
||||
PromptCacheKey: mustRawMessage(t, "cache-key"),
|
||||
PromptCacheRetention: mustRawMessage(t, "24h"),
|
||||
Reasoning: &dto.Reasoning{Effort: "medium"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "gpt-test", got.Model)
|
||||
require.Len(t, got.Messages, 2)
|
||||
assert.Equal(t, dto.Message{Role: "system", Content: "system rules"}, got.Messages[0])
|
||||
assert.Equal(t, dto.Message{Role: "user", Content: "hello"}, got.Messages[1])
|
||||
assert.Same(t, &stream, got.Stream)
|
||||
require.NotNil(t, got.StreamOptions)
|
||||
assert.True(t, got.StreamOptions.IncludeUsage)
|
||||
assert.Equal(t, maxOutputTokens, lo.FromPtr(got.MaxCompletionTokens))
|
||||
assert.Equal(t, 0.0, lo.FromPtr(got.Temperature))
|
||||
assert.Equal(t, 0.9, lo.FromPtr(got.TopP))
|
||||
assert.True(t, lo.FromPtr(got.ParallelTooCalls))
|
||||
assert.Equal(t, "cache-key", got.PromptCacheKey)
|
||||
assert.Equal(t, "medium", got.ReasoningEffort)
|
||||
assert.Equal(t, `"user-1"`, string(got.User))
|
||||
assert.Equal(t, `false`, string(got.Store))
|
||||
assert.Equal(t, "abc", gjson.GetBytes(got.Metadata, "trace").String())
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestMultimodalInput(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": []map[string]any{
|
||||
{"type": "input_text", "text": "look"},
|
||||
{"type": "input_image", "image_url": "https://example.test/a.png", "detail": "low"},
|
||||
{"type": "input_file", "file_id": "file_1", "filename": "a.txt"},
|
||||
{"type": "input_audio", "input_audio": map[string]any{"data": "abc", "format": "wav"}},
|
||||
{"type": "input_video", "video_url": map[string]any{"url": "https://example.test/v.mp4"}},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 1)
|
||||
assert.Equal(t, "user", got.Messages[0].Role)
|
||||
parts := got.Messages[0].ParseContent()
|
||||
require.Len(t, parts, 5)
|
||||
assert.Equal(t, dto.ContentTypeText, parts[0].Type)
|
||||
assert.Equal(t, "look", parts[0].Text)
|
||||
assert.Equal(t, dto.ContentTypeImageURL, parts[1].Type)
|
||||
assert.Equal(t, "https://example.test/a.png", parts[1].GetImageMedia().Url)
|
||||
assert.Equal(t, dto.ContentTypeFile, parts[2].Type)
|
||||
assert.Equal(t, "file_1", parts[2].GetFile().FileId)
|
||||
assert.Equal(t, dto.ContentTypeInputAudio, parts[3].Type)
|
||||
assert.Equal(t, "wav", parts[3].GetInputAudio().Format)
|
||||
assert.Equal(t, dto.ContentTypeVideoUrl, parts[4].Type)
|
||||
assert.Equal(t, "https://example.test/v.mp4", parts[4].GetVideoUrl().Url)
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestAssistantTextAndFunctionCallCoexist(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": []map[string]any{
|
||||
{"type": "output_text", "text": "I will call."},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "lookup",
|
||||
"arguments": map[string]any{"q": "x"},
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": map[string]any{"ok": true},
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 2)
|
||||
assert.Equal(t, "assistant", got.Messages[0].Role)
|
||||
assert.Equal(t, "I will call.", got.Messages[0].StringContent())
|
||||
toolCalls := got.Messages[0].ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, "call_1", toolCalls[0].ID)
|
||||
assert.Equal(t, "function", toolCalls[0].Type)
|
||||
assert.Equal(t, "lookup", toolCalls[0].Function.Name)
|
||||
assert.JSONEq(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
||||
assert.Equal(t, "tool", got.Messages[1].Role)
|
||||
assert.Equal(t, "call_1", got.Messages[1].ToolCallId)
|
||||
assert.JSONEq(t, `{"ok":true}`, got.Messages[1].StringContent())
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestOnlyFunctionCallCreatesAssistant(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "lookup",
|
||||
"arguments": `{"q":"x"}`,
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 1)
|
||||
assert.Equal(t, "assistant", got.Messages[0].Role)
|
||||
assert.Nil(t, got.Messages[0].Content)
|
||||
toolCalls := got.Messages[0].ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestToolsToolChoiceAndTextFormat(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, "hello"),
|
||||
Tools: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"q": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ToolChoice: mustRawMessage(t, map[string]any{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
}),
|
||||
Text: mustRawMessage(t, map[string]any{
|
||||
"format": map[string]any{
|
||||
"type": "json_schema",
|
||||
"name": "answer",
|
||||
"schema": map[string]any{"type": "object"},
|
||||
"strict": true,
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Tools, 1)
|
||||
assert.Equal(t, "function", got.Tools[0].Type)
|
||||
assert.Equal(t, "lookup", got.Tools[0].Function.Name)
|
||||
assert.Equal(t, "Lookup data", got.Tools[0].Function.Description)
|
||||
assert.Equal(t, "object", got.Tools[0].Function.Parameters.(map[string]any)["type"])
|
||||
assert.Equal(t, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": "lookup",
|
||||
},
|
||||
}, got.ToolChoice)
|
||||
require.NotNil(t, got.ResponseFormat)
|
||||
assert.Equal(t, "json_schema", got.ResponseFormat.Type)
|
||||
assert.Equal(t, "answer", gjson.GetBytes(got.ResponseFormat.JsonSchema, "name").String())
|
||||
assert.True(t, gjson.GetBytes(got.ResponseFormat.JsonSchema, "strict").Bool())
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestCustomToolCallPreservesRawShape(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_custom",
|
||||
"name": "apply_patch",
|
||||
"input": "patch body",
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 1)
|
||||
toolCalls := got.Messages[0].ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, dto.CustomType, toolCalls[0].Type)
|
||||
assert.Equal(t, "call_custom", toolCalls[0].ID)
|
||||
assert.Equal(t, "apply_patch", toolCalls[0].Function.Name)
|
||||
assert.Equal(t, "patch body", toolCalls[0].Function.Arguments)
|
||||
assert.Equal(t, "custom_tool_call", gjson.GetBytes(toolCalls[0].Custom, "type").String())
|
||||
assert.Equal(t, "patch body", gjson.GetBytes(toolCalls[0].Custom, "input").String())
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestRejectsStatefulFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *dto.OpenAIResponsesRequest
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "conversation",
|
||||
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", Conversation: mustRawMessage(t, "conv_1")},
|
||||
want: "conversation",
|
||||
},
|
||||
{
|
||||
name: "previous response",
|
||||
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", PreviousResponseID: "resp_1"},
|
||||
want: "previous_response_id",
|
||||
},
|
||||
{
|
||||
name: "prompt",
|
||||
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", Prompt: mustRawMessage(t, map[string]any{"id": "pmpt_1"})},
|
||||
want: "prompt",
|
||||
},
|
||||
{
|
||||
name: "context management",
|
||||
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", ContextManagement: mustRawMessage(t, map[string]any{"type": "auto"})},
|
||||
want: "context_management",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ResponsesRequestToChatCompletionsRequest(tt.req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.want)
|
||||
assert.Contains(t, err.Error(), "stateful fields")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustRawMessage(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
raw, err := common.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
responsesEventCreated = "response.created"
|
||||
responsesEventCompleted = "response.completed"
|
||||
responsesEventDone = "response.done"
|
||||
responsesEventIncomplete = "response.incomplete"
|
||||
responsesEventFailed = "response.failed"
|
||||
responsesEventError = "response.error"
|
||||
responsesEventOutputTextDelta = "response.output_text.delta"
|
||||
responsesEventOutputItemAdded = "response.output_item.added"
|
||||
responsesEventOutputItemDone = "response.output_item.done"
|
||||
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
||||
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
|
||||
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
|
||||
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
|
||||
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
|
||||
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
|
||||
responsesEventReasoningTextDelta = "response.reasoning_text.delta"
|
||||
responsesEventReasoningTextDone = "response.reasoning_text.done"
|
||||
responsesOutputTypeFunctionCall = "function_call"
|
||||
responsesOutputTypeCustomToolCall = "custom_tool_call"
|
||||
responsesOutputTypeMessage = "message"
|
||||
responsesOutputTypeReasoning = "reasoning"
|
||||
responsesIncompleteReasonContentFilter = "content_filter"
|
||||
responsesIncompleteReasonMaxTokens = "max_output_tokens"
|
||||
)
|
||||
|
||||
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
|
||||
if resp == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
status := responseStatusString(resp)
|
||||
if status != "incomplete" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if resp.IncompleteDetails != nil {
|
||||
reason = strings.TrimSpace(resp.IncompleteDetails.Reason)
|
||||
}
|
||||
if reason == responsesIncompleteReasonContentFilter {
|
||||
return "content_filter", true
|
||||
}
|
||||
return "length", true
|
||||
}
|
||||
|
||||
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
||||
if resp == nil {
|
||||
return nil, nil, errors.New("response is nil")
|
||||
}
|
||||
|
||||
text := ExtractOutputTextFromResponses(resp)
|
||||
reasoning := ExtractReasoningTextFromResponses(resp)
|
||||
|
||||
usage := UsageFromResponsesUsage(resp.Usage)
|
||||
|
||||
created := resp.CreatedAt
|
||||
|
||||
var toolCalls []dto.ToolCallResponse
|
||||
if len(resp.Output) > 0 {
|
||||
for _, out := range resp.Output {
|
||||
if !isResponsesToolOutputType(out.Type) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(out.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
callId := strings.TrimSpace(out.CallId)
|
||||
if callId == "" {
|
||||
callId = strings.TrimSpace(out.ID)
|
||||
}
|
||||
toolCalls = append(toolCalls, dto.ToolCallResponse{
|
||||
ID: callId,
|
||||
Type: "function",
|
||||
Function: dto.FunctionResponse{
|
||||
Name: name,
|
||||
Arguments: out.ArgumentsString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
if mappedReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
|
||||
finishReason = mappedReason
|
||||
} else if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
|
||||
msg := dto.Message{
|
||||
Role: "assistant",
|
||||
Content: text,
|
||||
}
|
||||
if reasoning != "" {
|
||||
msg.ReasoningContent = &reasoning
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
msg.SetToolCalls(toolCalls)
|
||||
}
|
||||
|
||||
out := &dto.OpenAITextResponse{
|
||||
Id: id,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Model: resp.Model,
|
||||
Choices: []dto.OpenAITextResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
},
|
||||
Usage: *usage,
|
||||
}
|
||||
|
||||
return out, usage, nil
|
||||
}
|
||||
|
||||
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
|
||||
usage := &dto.Usage{}
|
||||
if src == nil {
|
||||
return usage
|
||||
}
|
||||
if src.InputTokens != 0 {
|
||||
usage.PromptTokens = src.InputTokens
|
||||
usage.InputTokens = src.InputTokens
|
||||
}
|
||||
if src.OutputTokens != 0 {
|
||||
usage.CompletionTokens = src.OutputTokens
|
||||
usage.OutputTokens = src.OutputTokens
|
||||
}
|
||||
if src.TotalTokens != 0 {
|
||||
usage.TotalTokens = src.TotalTokens
|
||||
} else {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
if src.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
|
||||
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
|
||||
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
|
||||
}
|
||||
if src.CompletionTokenDetails.ReasoningTokens != 0 {
|
||||
usage.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Prefer assistant message outputs.
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != "message" {
|
||||
continue
|
||||
}
|
||||
if out.Role != "" && out.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
for _, c := range out.Content {
|
||||
if c.Type == "output_text" && c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sb.Len() > 0 {
|
||||
return sb.String()
|
||||
}
|
||||
for _, out := range resp.Output {
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != responsesOutputTypeReasoning {
|
||||
continue
|
||||
}
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type ResponsesToChatStreamState struct {
|
||||
ID string
|
||||
Model string
|
||||
Created int64
|
||||
IncludeUsage bool
|
||||
|
||||
Usage *dto.Usage
|
||||
|
||||
sentStart bool
|
||||
finalized bool
|
||||
hasSentText bool
|
||||
sawToolCall bool
|
||||
hasSentReasoning bool
|
||||
needsReasoningSummaryBreak bool
|
||||
nextToolIndex int
|
||||
toolByKey map[string]*responsesStreamTool
|
||||
outputIndexToKey map[int]string
|
||||
itemIDToKey map[string]string
|
||||
callIDToKey map[string]string
|
||||
pendingArgsByOutputIndex map[int]string
|
||||
pendingArgsByItemID map[string]string
|
||||
usageText strings.Builder
|
||||
}
|
||||
|
||||
type responsesStreamTool struct {
|
||||
Key string
|
||||
CallID string
|
||||
ItemID string
|
||||
Name string
|
||||
Arguments string
|
||||
Index int
|
||||
Sent bool
|
||||
NameSent bool
|
||||
ArgsSentAt int
|
||||
}
|
||||
|
||||
func NewResponsesToChatStreamState(model string, includeUsage bool) *ResponsesToChatStreamState {
|
||||
return &ResponsesToChatStreamState{
|
||||
Model: model,
|
||||
Created: time.Now().Unix(),
|
||||
IncludeUsage: includeUsage,
|
||||
Usage: &dto.Usage{},
|
||||
toolByKey: make(map[string]*responsesStreamTool),
|
||||
outputIndexToKey: make(map[int]string),
|
||||
itemIDToKey: make(map[string]string),
|
||||
callIDToKey: make(map[string]string),
|
||||
pendingArgsByOutputIndex: make(map[int]string),
|
||||
pendingArgsByItemID: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) UsageText() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.usageText.String()
|
||||
}
|
||||
|
||||
func ResponsesStreamEventToChatChunks(event *dto.ResponsesStreamResponse, state *ResponsesToChatStreamState) ([]dto.ChatCompletionsStreamResponse, error) {
|
||||
if event == nil || state == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case responsesEventCreated:
|
||||
state.applyResponseMetadata(event.Response)
|
||||
return state.ensureStart(), nil
|
||||
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
|
||||
return state.reasoningDelta(event.Delta), nil
|
||||
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
|
||||
if state.hasSentReasoning {
|
||||
state.needsReasoningSummaryBreak = true
|
||||
}
|
||||
return nil, nil
|
||||
case responsesEventOutputTextDelta:
|
||||
return state.textDelta(event.Delta), nil
|
||||
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
|
||||
if event.Item == nil || !isResponsesToolOutputType(event.Item.Type) {
|
||||
return nil, nil
|
||||
}
|
||||
return state.toolItem(event), nil
|
||||
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
|
||||
return state.toolArgumentsDelta(event), nil
|
||||
case responsesEventFunctionArgsDone, responsesEventCustomToolInputDone:
|
||||
return state.flushPendingTool(event), nil
|
||||
case responsesEventCompleted, responsesEventDone, responsesEventIncomplete:
|
||||
response := event.Response
|
||||
if event.Type == responsesEventIncomplete {
|
||||
response = ensureIncompleteResponse(response)
|
||||
}
|
||||
state.applyResponseMetadata(response)
|
||||
chunks := state.terminalOutputChunks(response)
|
||||
chunks = append(chunks, state.finalize(response)...)
|
||||
return chunks, nil
|
||||
case responsesEventFailed, responsesEventError:
|
||||
return nil, fmt.Errorf("responses stream error: %s", event.Type)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func FinalizeResponsesToChatStream(state *ResponsesToChatStreamState) []dto.ChatCompletionsStreamResponse {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
return state.finalize(nil)
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) applyResponseMetadata(response *dto.OpenAIResponsesResponse) {
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
if response.ID != "" && s.ID == "" {
|
||||
s.ID = response.ID
|
||||
}
|
||||
if response.Model != "" {
|
||||
s.Model = response.Model
|
||||
}
|
||||
if response.CreatedAt != 0 {
|
||||
s.Created = int64(response.CreatedAt)
|
||||
}
|
||||
if response.Usage != nil {
|
||||
s.Usage = UsageFromResponsesUsage(response.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) ensureStart() []dto.ChatCompletionsStreamResponse {
|
||||
if s.sentStart {
|
||||
return nil
|
||||
}
|
||||
s.sentStart = true
|
||||
return []dto.ChatCompletionsStreamResponse{s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
Role: "assistant",
|
||||
Content: common.GetPointer(""),
|
||||
}, nil)}
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) textDelta(delta string) []dto.ChatCompletionsStreamResponse {
|
||||
if delta == "" {
|
||||
return nil
|
||||
}
|
||||
s.usageText.WriteString(delta)
|
||||
s.hasSentText = true
|
||||
chunks := s.ensureStart()
|
||||
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
Content: &delta,
|
||||
}, nil))
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) terminalOutputChunks(response *dto.OpenAIResponsesResponse) []dto.ChatCompletionsStreamResponse {
|
||||
if s == nil || response == nil || len(response.Output) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var chunks []dto.ChatCompletionsStreamResponse
|
||||
for i := range response.Output {
|
||||
out := &response.Output[i]
|
||||
switch {
|
||||
case out.Type == responsesOutputTypeMessage && !s.hasSentText:
|
||||
var text strings.Builder
|
||||
for _, c := range out.Content {
|
||||
if c.Type == "output_text" && c.Text != "" {
|
||||
text.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
chunks = append(chunks, s.textDelta(text.String())...)
|
||||
case out.Type == responsesOutputTypeReasoning && !s.hasSentReasoning:
|
||||
var reasoning strings.Builder
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
reasoning.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
chunks = append(chunks, s.reasoningDelta(reasoning.String())...)
|
||||
case isResponsesToolOutputType(out.Type):
|
||||
chunks = append(chunks, s.toolItem(&dto.ResponsesStreamResponse{Item: out})...)
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) reasoningDelta(delta string) []dto.ChatCompletionsStreamResponse {
|
||||
if delta == "" {
|
||||
return nil
|
||||
}
|
||||
if s.needsReasoningSummaryBreak {
|
||||
if strings.HasPrefix(delta, "\n\n") {
|
||||
s.needsReasoningSummaryBreak = false
|
||||
} else if strings.HasPrefix(delta, "\n") {
|
||||
delta = "\n" + delta
|
||||
s.needsReasoningSummaryBreak = false
|
||||
} else {
|
||||
delta = "\n\n" + delta
|
||||
s.needsReasoningSummaryBreak = false
|
||||
}
|
||||
}
|
||||
s.usageText.WriteString(delta)
|
||||
chunks := s.ensureStart()
|
||||
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
ReasoningContent: &delta,
|
||||
}, nil))
|
||||
s.hasSentReasoning = true
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) toolItem(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
|
||||
tool := s.ensureToolForEvent(event)
|
||||
if tool == nil {
|
||||
return nil
|
||||
}
|
||||
args := event.Item.ArgumentsString()
|
||||
if args != "" {
|
||||
tool.Arguments = args
|
||||
}
|
||||
return s.toolDelta(tool, "")
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) toolArgumentsDelta(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
|
||||
if event.Delta == "" {
|
||||
return nil
|
||||
}
|
||||
tool := s.findToolForEvent(event)
|
||||
if tool == nil {
|
||||
if event.OutputIndex != nil {
|
||||
s.pendingArgsByOutputIndex[*event.OutputIndex] += event.Delta
|
||||
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
||||
s.pendingArgsByItemID[itemID] += event.Delta
|
||||
}
|
||||
return nil
|
||||
}
|
||||
tool.Arguments += event.Delta
|
||||
return s.toolDelta(tool, event.Delta)
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) flushPendingTool(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
|
||||
tool := s.findToolForEvent(event)
|
||||
if tool == nil {
|
||||
tool = s.ensureFallbackToolForEvent(event)
|
||||
}
|
||||
if tool == nil {
|
||||
return nil
|
||||
}
|
||||
return s.toolDelta(tool, "")
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) ensureToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
|
||||
if event == nil || event.Item == nil {
|
||||
return nil
|
||||
}
|
||||
key := s.keyForEvent(event)
|
||||
if key == "" {
|
||||
key = fallbackToolKey(event.Item.ID, event.Item.CallId, event.OutputIndex)
|
||||
}
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tool := s.toolByKey[key]
|
||||
if tool == nil {
|
||||
tool = &responsesStreamTool{Key: key, Index: s.nextToolIndex}
|
||||
s.nextToolIndex++
|
||||
s.toolByKey[key] = tool
|
||||
}
|
||||
|
||||
if event.OutputIndex != nil {
|
||||
s.outputIndexToKey[*event.OutputIndex] = key
|
||||
if pending := s.pendingArgsByOutputIndex[*event.OutputIndex]; pending != "" {
|
||||
tool.Arguments += pending
|
||||
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
|
||||
}
|
||||
}
|
||||
if itemID := responseStreamEventItemID(event); itemID != "" {
|
||||
tool.ItemID = itemID
|
||||
s.itemIDToKey[itemID] = key
|
||||
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
|
||||
tool.Arguments += pending
|
||||
delete(s.pendingArgsByItemID, itemID)
|
||||
}
|
||||
}
|
||||
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
|
||||
tool.CallID = callID
|
||||
s.callIDToKey[callID] = key
|
||||
} else if tool.CallID == "" {
|
||||
tool.CallID = strings.TrimSpace(event.Item.ID)
|
||||
}
|
||||
if name := strings.TrimSpace(event.Item.Name); name != "" {
|
||||
tool.Name = name
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) findToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
if key := s.outputIndexToKey[*event.OutputIndex]; key != "" {
|
||||
return s.toolByKey[key]
|
||||
}
|
||||
}
|
||||
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
||||
if key := s.itemIDToKey[itemID]; key != "" {
|
||||
return s.toolByKey[key]
|
||||
}
|
||||
}
|
||||
if event.Item != nil {
|
||||
if key := s.keyForEvent(event); key != "" {
|
||||
return s.toolByKey[key]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) ensureFallbackToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
key := ""
|
||||
if event.OutputIndex != nil {
|
||||
key = fmt.Sprintf("output:%d", *event.OutputIndex)
|
||||
}
|
||||
if key == "" && strings.TrimSpace(event.ItemID) != "" {
|
||||
key = "item:" + strings.TrimSpace(event.ItemID)
|
||||
}
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
tool := s.toolByKey[key]
|
||||
if tool == nil {
|
||||
tool = &responsesStreamTool{
|
||||
Key: key,
|
||||
Index: s.nextToolIndex,
|
||||
CallID: fallbackCallID(event),
|
||||
}
|
||||
s.nextToolIndex++
|
||||
s.toolByKey[key] = tool
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
s.outputIndexToKey[*event.OutputIndex] = key
|
||||
if pending := s.pendingArgsByOutputIndex[*event.OutputIndex]; pending != "" {
|
||||
tool.Arguments += pending
|
||||
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
|
||||
}
|
||||
}
|
||||
if itemID := responseStreamEventItemID(event); itemID != "" {
|
||||
tool.ItemID = itemID
|
||||
s.itemIDToKey[itemID] = key
|
||||
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
|
||||
tool.Arguments += pending
|
||||
delete(s.pendingArgsByItemID, itemID)
|
||||
}
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) toolDelta(tool *responsesStreamTool, explicitDelta string) []dto.ChatCompletionsStreamResponse {
|
||||
if tool == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
argsDelta := explicitDelta
|
||||
if argsDelta == "" && len(tool.Arguments) > tool.ArgsSentAt {
|
||||
argsDelta = tool.Arguments[tool.ArgsSentAt:]
|
||||
}
|
||||
if tool.Sent && argsDelta == "" && (tool.Name == "" || tool.NameSent) {
|
||||
return nil
|
||||
}
|
||||
|
||||
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.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)
|
||||
}
|
||||
if responseTool.Function.Name != "" {
|
||||
s.usageText.WriteString(responseTool.Function.Name)
|
||||
}
|
||||
|
||||
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
ToolCalls: []dto.ToolCallResponse{responseTool},
|
||||
}, nil))
|
||||
s.sawToolCall = true
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) finalize(response *dto.OpenAIResponsesResponse) []dto.ChatCompletionsStreamResponse {
|
||||
if s.finalized {
|
||||
return nil
|
||||
}
|
||||
s.finalized = true
|
||||
|
||||
chunks := s.flushAllPendingTools()
|
||||
chunks = append(chunks, s.ensureStart()...)
|
||||
|
||||
finishReason := "stop"
|
||||
if mappedReason, ok := ResponsesFinishReasonFromStatus(response); ok {
|
||||
finishReason = mappedReason
|
||||
} else if s.sawToolCall {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{}, &finishReason))
|
||||
if s.IncludeUsage && s.Usage != nil {
|
||||
chunks = append(chunks, dto.ChatCompletionsStreamResponse{
|
||||
Id: s.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: s.Created,
|
||||
Model: s.Model,
|
||||
Choices: make([]dto.ChatCompletionsStreamResponseChoice, 0),
|
||||
Usage: s.Usage,
|
||||
})
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) flushAllPendingTools() []dto.ChatCompletionsStreamResponse {
|
||||
keys := make([]string, 0, len(s.toolByKey)+len(s.pendingArgsByOutputIndex)+len(s.pendingArgsByItemID))
|
||||
seen := make(map[string]bool)
|
||||
for key := range s.toolByKey {
|
||||
keys = append(keys, key)
|
||||
seen[key] = true
|
||||
}
|
||||
for outputIndex := range s.pendingArgsByOutputIndex {
|
||||
key := fmt.Sprintf("output:%d", outputIndex)
|
||||
if !seen[key] {
|
||||
keys = append(keys, key)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
for itemID := range s.pendingArgsByItemID {
|
||||
key := "item:" + itemID
|
||||
if !seen[key] {
|
||||
keys = append(keys, key)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var chunks []dto.ChatCompletionsStreamResponse
|
||||
for _, key := range keys {
|
||||
tool := s.toolByKey[key]
|
||||
if tool == nil {
|
||||
callID := strings.TrimPrefix(key, "item:")
|
||||
if strings.HasPrefix(key, "output:") {
|
||||
callID = "call_output_" + strings.TrimPrefix(key, "output:")
|
||||
}
|
||||
tool = &responsesStreamTool{
|
||||
Key: key,
|
||||
Index: s.nextToolIndex,
|
||||
CallID: callID,
|
||||
}
|
||||
s.nextToolIndex++
|
||||
s.toolByKey[key] = tool
|
||||
}
|
||||
if strings.HasPrefix(key, "output:") {
|
||||
var outputIndex int
|
||||
if _, err := fmt.Sscanf(key, "output:%d", &outputIndex); err == nil {
|
||||
tool.Arguments += s.pendingArgsByOutputIndex[outputIndex]
|
||||
delete(s.pendingArgsByOutputIndex, outputIndex)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(key, "item:") {
|
||||
itemID := strings.TrimPrefix(key, "item:")
|
||||
tool.Arguments += s.pendingArgsByItemID[itemID]
|
||||
delete(s.pendingArgsByItemID, itemID)
|
||||
}
|
||||
chunks = append(chunks, s.toolDelta(tool, "")...)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) makeChunk(delta dto.ChatCompletionsStreamResponseChoiceDelta, finishReason *string) dto.ChatCompletionsStreamResponse {
|
||||
return dto.ChatCompletionsStreamResponse{
|
||||
Id: s.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: s.Created,
|
||||
Model: s.Model,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Delta: delta,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) keyForEvent(event *dto.ResponsesStreamResponse) string {
|
||||
if event == nil {
|
||||
return ""
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
return fmt.Sprintf("output:%d", *event.OutputIndex)
|
||||
}
|
||||
if event.Item != nil {
|
||||
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
|
||||
return "item:" + itemID
|
||||
}
|
||||
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
|
||||
return "call:" + callID
|
||||
}
|
||||
}
|
||||
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
||||
return "item:" + itemID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ResponsesBufferedAccumulator struct {
|
||||
text strings.Builder
|
||||
reasoning strings.Builder
|
||||
tools []*responsesBufferedTool
|
||||
outputIndexToToolIdx map[int]int
|
||||
itemIDToToolIdx map[string]int
|
||||
pendingByOutputIndex map[int]string
|
||||
pendingByItemID map[string]string
|
||||
}
|
||||
|
||||
type responsesBufferedTool struct {
|
||||
CallID string
|
||||
ItemID string
|
||||
Name string
|
||||
Arguments strings.Builder
|
||||
}
|
||||
|
||||
func NewResponsesBufferedAccumulator() *ResponsesBufferedAccumulator {
|
||||
return &ResponsesBufferedAccumulator{
|
||||
outputIndexToToolIdx: make(map[int]int),
|
||||
itemIDToToolIdx: make(map[string]int),
|
||||
pendingByOutputIndex: make(map[int]string),
|
||||
pendingByItemID: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) ProcessEvent(event *dto.ResponsesStreamResponse) {
|
||||
if a == nil || event == nil {
|
||||
return
|
||||
}
|
||||
switch event.Type {
|
||||
case responsesEventOutputTextDelta:
|
||||
a.text.WriteString(event.Delta)
|
||||
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
|
||||
a.reasoning.WriteString(event.Delta)
|
||||
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
|
||||
if event.Item != nil && isResponsesToolOutputType(event.Item.Type) {
|
||||
tool := a.ensureTool(event)
|
||||
if args := event.Item.ArgumentsString(); args != "" {
|
||||
tool.Arguments.Reset()
|
||||
tool.Arguments.WriteString(args)
|
||||
}
|
||||
}
|
||||
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
|
||||
if idx, ok := a.findToolIndex(event); ok {
|
||||
a.tools[idx].Arguments.WriteString(event.Delta)
|
||||
return
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
a.pendingByOutputIndex[*event.OutputIndex] += event.Delta
|
||||
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
||||
a.pendingByItemID[itemID] += event.Delta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) SupplementResponseOutput(resp *dto.OpenAIResponsesResponse) {
|
||||
if a == nil || resp == nil || len(resp.Output) > 0 {
|
||||
return
|
||||
}
|
||||
resp.Output = a.BuildOutput()
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) BuildOutput() []dto.ResponsesOutput {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]dto.ResponsesOutput, 0, 2+len(a.tools))
|
||||
if a.reasoning.Len() > 0 {
|
||||
out = append(out, dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeReasoning,
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{Type: "summary_text", Text: a.reasoning.String()},
|
||||
},
|
||||
})
|
||||
}
|
||||
if a.text.Len() > 0 {
|
||||
out = append(out, dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeMessage,
|
||||
Role: "assistant",
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{Type: "output_text", Text: a.text.String()},
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, tool := range a.tools {
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
argsRaw, _ := common.Marshal(tool.Arguments.String())
|
||||
out = append(out, dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: tool.ItemID,
|
||||
CallId: tool.CallID,
|
||||
Name: tool.Name,
|
||||
Arguments: argsRaw,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) ensureTool(event *dto.ResponsesStreamResponse) *responsesBufferedTool {
|
||||
if idx, ok := a.findToolIndex(event); ok {
|
||||
tool := a.tools[idx]
|
||||
a.applyToolMetadata(tool, event)
|
||||
return tool
|
||||
}
|
||||
tool := &responsesBufferedTool{}
|
||||
a.applyToolMetadata(tool, event)
|
||||
idx := len(a.tools)
|
||||
a.tools = append(a.tools, tool)
|
||||
if event.OutputIndex != nil {
|
||||
a.outputIndexToToolIdx[*event.OutputIndex] = idx
|
||||
if pending := a.pendingByOutputIndex[*event.OutputIndex]; pending != "" {
|
||||
tool.Arguments.WriteString(pending)
|
||||
delete(a.pendingByOutputIndex, *event.OutputIndex)
|
||||
}
|
||||
}
|
||||
if tool.ItemID != "" {
|
||||
a.itemIDToToolIdx[tool.ItemID] = idx
|
||||
if pending := a.pendingByItemID[tool.ItemID]; pending != "" {
|
||||
tool.Arguments.WriteString(pending)
|
||||
delete(a.pendingByItemID, tool.ItemID)
|
||||
}
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) applyToolMetadata(tool *responsesBufferedTool, event *dto.ResponsesStreamResponse) {
|
||||
if tool == nil || event == nil || event.Item == nil {
|
||||
return
|
||||
}
|
||||
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
|
||||
tool.ItemID = itemID
|
||||
}
|
||||
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
|
||||
tool.CallID = callID
|
||||
} else if tool.CallID == "" {
|
||||
tool.CallID = strings.TrimSpace(event.Item.ID)
|
||||
}
|
||||
if name := strings.TrimSpace(event.Item.Name); name != "" {
|
||||
tool.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) findToolIndex(event *dto.ResponsesStreamResponse) (int, bool) {
|
||||
if event == nil {
|
||||
return 0, false
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
if idx, ok := a.outputIndexToToolIdx[*event.OutputIndex]; ok {
|
||||
return idx, true
|
||||
}
|
||||
}
|
||||
itemID := strings.TrimSpace(event.ItemID)
|
||||
if itemID == "" && event.Item != nil {
|
||||
itemID = strings.TrimSpace(event.Item.ID)
|
||||
}
|
||||
if itemID != "" {
|
||||
idx, ok := a.itemIDToToolIdx[itemID]
|
||||
return idx, ok
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Status) == 0 {
|
||||
return ""
|
||||
}
|
||||
var status string
|
||||
_ = common.Unmarshal(resp.Status, &status)
|
||||
return strings.TrimSpace(status)
|
||||
}
|
||||
|
||||
func ensureIncompleteResponse(resp *dto.OpenAIResponsesResponse) *dto.OpenAIResponsesResponse {
|
||||
if resp == nil {
|
||||
resp = &dto.OpenAIResponsesResponse{}
|
||||
}
|
||||
if len(resp.Status) == 0 {
|
||||
resp.Status = []byte(`"incomplete"`)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func isResponsesToolOutputType(outputType string) bool {
|
||||
return outputType == responsesOutputTypeFunctionCall || outputType == responsesOutputTypeCustomToolCall
|
||||
}
|
||||
|
||||
func responseStreamEventItemID(event *dto.ResponsesStreamResponse) string {
|
||||
if event == nil {
|
||||
return ""
|
||||
}
|
||||
if event.Item != nil {
|
||||
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
|
||||
return itemID
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(event.ItemID)
|
||||
}
|
||||
|
||||
func fallbackToolKey(itemID string, callID string, outputIndex *int) string {
|
||||
if outputIndex != nil {
|
||||
return fmt.Sprintf("output:%d", *outputIndex)
|
||||
}
|
||||
if strings.TrimSpace(itemID) != "" {
|
||||
return "item:" + strings.TrimSpace(itemID)
|
||||
}
|
||||
if strings.TrimSpace(callID) != "" {
|
||||
return "call:" + strings.TrimSpace(callID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fallbackCallID(event *dto.ResponsesStreamResponse) string {
|
||||
if event == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(event.ItemID) != "" {
|
||||
return strings.TrimSpace(event.ItemID)
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
return fmt.Sprintf("call_output_%d", *event.OutputIndex)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user