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:
Calcium-Ion
2026-06-28 14:25:47 +08:00
committed by GitHub
parent 3a506f50f0
commit 2d5a041639
35 changed files with 2731 additions and 40 deletions
+7 -5
View File
@@ -628,12 +628,14 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco
claudeContent.Type = "tool_use"
claudeContent.Id = toolUse.ID
claudeContent.Name = toolUse.Function.Name
var mapParams map[string]interface{}
if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &mapParams); err == nil {
claudeContent.Input = mapParams
} else {
claudeContent.Input = toolUse.Function.Arguments
mapParams := map[string]interface{}{}
if strings.TrimSpace(toolUse.Function.Arguments) != "" {
var parsed map[string]interface{}
if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &parsed); err == nil && parsed != nil {
mapParams = parsed
}
}
claudeContent.Input = mapParams
contents = append(contents, claudeContent)
}
}
+13 -5
View File
@@ -2,21 +2,29 @@ package service
import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/service/openaicompat"
"github.com/QuantumNous/new-api/service/relayconvert"
)
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
return openaicompat.ChatCompletionsRequestToResponsesRequest(req)
return relayconvert.ChatCompletionsRequestToResponsesRequest(req)
}
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
return relayconvert.ResponsesRequestToChatCompletionsRequest(req)
}
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
return relayconvert.ChatCompletionsResponseToResponsesResponse(resp, id)
}
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
return openaicompat.ResponsesResponseToChatCompletionsResponse(resp, id)
return relayconvert.ResponsesResponseToChatCompletionsResponse(resp, id)
}
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
return openaicompat.ResponsesFinishReasonFromStatus(resp)
return relayconvert.ResponsesFinishReasonFromStatus(resp)
}
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
return openaicompat.ExtractOutputTextFromResponses(resp)
return relayconvert.ExtractOutputTextFromResponses(resp)
}
+3 -3
View File
@@ -1,14 +1,14 @@
package service
import (
"github.com/QuantumNous/new-api/service/openaicompat"
"github.com/QuantumNous/new-api/service/relayconvert"
"github.com/QuantumNous/new-api/setting/model_setting"
)
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
return openaicompat.ShouldChatCompletionsUseResponsesPolicy(policy, channelID, channelType, model)
return relayconvert.ShouldChatCompletionsUseResponsesPolicy(policy, channelID, channelType, model)
}
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
return openaicompat.ShouldChatCompletionsUseResponsesGlobal(channelID, channelType, model)
return relayconvert.ShouldChatCompletionsUseResponsesGlobal(channelID, channelType, model)
}
@@ -1,4 +1,4 @@
package openaicompat
package relayconvert
import (
"testing"
@@ -182,6 +182,70 @@ func TestResponsesStreamEventToChatChunksUsesOutputIndexForToolArguments(t *test
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
@@ -313,6 +377,165 @@ func TestResponsesBufferedAccumulatorSupplementsEmptyTerminalOutput(t *testing.T
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{
@@ -341,3 +564,10 @@ func mustStreamChunks(t *testing.T, state *ResponsesToChatStreamState, event *dt
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
}
@@ -1,4 +1,4 @@
package openaicompat
package relayconvert
import (
"encoding/json"
@@ -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
}
@@ -1,4 +1,4 @@
package openaicompat
package relayconvert
import "github.com/QuantumNous/new-api/setting/model_setting"
@@ -1,4 +1,4 @@
package openaicompat
package relayconvert
import (
"regexp"
@@ -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, &parallelToolCalls); err == nil {
out.ParallelTooCalls = &parallelToolCalls
}
}
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
}
@@ -1,4 +1,4 @@
package openaicompat
package relayconvert
import (
"errors"
@@ -438,8 +438,7 @@ func (s *ResponsesToChatStreamState) toolArgumentsDelta(event *dto.ResponsesStre
if tool == nil {
if event.OutputIndex != nil {
s.pendingArgsByOutputIndex[*event.OutputIndex] += event.Delta
}
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
s.pendingArgsByItemID[itemID] += event.Delta
}
return nil
@@ -485,7 +484,7 @@ func (s *ResponsesToChatStreamState) ensureToolForEvent(event *dto.ResponsesStre
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
}
}
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
if itemID := responseStreamEventItemID(event); itemID != "" {
tool.ItemID = itemID
s.itemIDToKey[itemID] = key
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
@@ -558,7 +557,7 @@ func (s *ResponsesToChatStreamState) ensureFallbackToolForEvent(event *dto.Respo
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
}
}
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
if itemID := responseStreamEventItemID(event); itemID != "" {
tool.ItemID = itemID
s.itemIDToKey[itemID] = key
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
@@ -789,8 +788,7 @@ func (a *ResponsesBufferedAccumulator) ProcessEvent(event *dto.ResponsesStreamRe
}
if event.OutputIndex != nil {
a.pendingByOutputIndex[*event.OutputIndex] += event.Delta
}
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
a.pendingByItemID[itemID] += event.Delta
}
}
@@ -928,6 +926,18 @@ 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)