From 2d5a041639e56316a45e8a2a11e5b5fb50c7a6ac Mon Sep 17 00:00:00 2001 From: Calcium-Ion Date: Sun, 28 Jun 2026 14:25:47 +0800 Subject: [PATCH] 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 --- .gitignore | 1 + dto/channel_settings.go | 6 + dto/channel_settings_test.go | 46 ++ relay/channel/advancedcustom/adaptor.go | 17 +- relay/channel/advancedcustom/adaptor_test.go | 46 ++ relay/channel/claude/relay_claude_test.go | 44 ++ relay/channel/gemini/adaptor.go | 21 +- relay/channel/gemini/adaptor_responses.go | 99 +++ .../channel/gemini/adaptor_responses_test.go | 176 +++++ relay/channel/gemini/relay_responses.go | 162 +++++ relay/channel/gemini/relay_responses_test.go | 205 ++++++ relay/channel/openai/chat_via_responses.go | 10 +- .../channel/openai/chat_via_responses_test.go | 52 ++ relay/channel/openai/responses_via_chat.go | 131 ++++ relay/chat_completions_via_responses.go | 6 +- relay/chat_completions_via_responses_test.go | 26 + service/convert.go | 12 +- service/openai_chat_responses_compat.go | 18 +- service/openai_chat_responses_mode.go | 6 +- .../chat_responses_compat_test.go | 232 ++++++- .../chat_to_responses.go | 2 +- .../chat_to_responses_response.go | 605 ++++++++++++++++++ .../{openaicompat => relayconvert}/policy.go | 2 +- .../{openaicompat => relayconvert}/regex.go | 2 +- .../relayconvert/responses_request_to_chat.go | 521 +++++++++++++++ .../responses_request_to_chat_test.go | 270 ++++++++ .../responses_to_chat.go | 24 +- .../features/channels/lib/advanced-custom.ts | 10 + web/default/src/features/channels/types.ts | 1 + web/default/src/i18n/locales/en.json | 3 +- web/default/src/i18n/locales/fr.json | 3 +- web/default/src/i18n/locales/ja.json | 3 +- web/default/src/i18n/locales/ru.json | 3 +- web/default/src/i18n/locales/vi.json | 3 +- web/default/src/i18n/locales/zh.json | 3 +- 35 files changed, 2731 insertions(+), 40 deletions(-) create mode 100644 dto/channel_settings_test.go create mode 100644 relay/channel/gemini/adaptor_responses.go create mode 100644 relay/channel/gemini/adaptor_responses_test.go create mode 100644 relay/channel/gemini/relay_responses.go create mode 100644 relay/channel/gemini/relay_responses_test.go create mode 100644 relay/channel/openai/responses_via_chat.go create mode 100644 relay/chat_completions_via_responses_test.go rename service/{openaicompat => relayconvert}/chat_responses_compat_test.go (57%) rename service/{openaicompat => relayconvert}/chat_to_responses.go (99%) create mode 100644 service/relayconvert/chat_to_responses_response.go rename service/{openaicompat => relayconvert}/policy.go (96%) rename service/{openaicompat => relayconvert}/regex.go (96%) create mode 100644 service/relayconvert/responses_request_to_chat.go create mode 100644 service/relayconvert/responses_request_to_chat_test.go rename service/{openaicompat => relayconvert}/responses_to_chat.go (97%) diff --git a/.gitignore b/.gitignore index d2c75202..c3afceb0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ skills-lock.json # Local-only live probes and scratch test workspaces. .local-tests/ +service/relayconvert/chat_responses_live_local_test.go service/openaicompat/chat_responses_live_local_test.go diff --git a/dto/channel_settings.go b/dto/channel_settings.go index 8d460c79..dc037739 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -63,6 +63,7 @@ const ( AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions = "anthropic_messages_to_openai_chat_completions" AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages = "openai_chat_completions_to_anthropic_messages" AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses = "openai_chat_completions_to_openai_responses" + AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions = "openai_responses_to_openai_chat_completions" AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions = "gemini_generate_content_to_openai_chat_completions" AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent = "openai_chat_completions_to_gemini_generate_content" ) @@ -147,6 +148,7 @@ func IsAdvancedCustomConverterAllowed(converter string) bool { AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions, AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages, AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses, + AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions, AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions, AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent: return true @@ -240,6 +242,10 @@ func validateAdvancedCustomConverterPath(index int, incomingPath string, convert if incomingPath == "/v1/chat/completions" { return nil } + case AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions: + if incomingPath == "/v1/responses" { + return nil + } case AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions: if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") { return nil diff --git a/dto/channel_settings_test.go b/dto/channel_settings_test.go new file mode 100644 index 00000000..92ae8045 --- /dev/null +++ b/dto/channel_settings_test.go @@ -0,0 +1,46 @@ +package dto + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAdvancedCustomValidateResponsesToChatConverterPath(t *testing.T) { + valid := &AdvancedCustomConfig{ + Routes: []AdvancedCustomRoute{ + { + IncomingPath: "/v1/responses", + UpstreamPath: "/v1/chat/completions", + Converter: AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions, + }, + }, + } + require.NoError(t, valid.Validate()) + + tests := []struct { + name string + incomingPath string + }{ + {name: "chat completions", incomingPath: "/v1/chat/completions"}, + {name: "responses compact", incomingPath: "/v1/responses/compact"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &AdvancedCustomConfig{ + Routes: []AdvancedCustomRoute{ + { + IncomingPath: tt.incomingPath, + UpstreamPath: "/v1/chat/completions", + Converter: AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions, + }, + }, + } + err := config.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "converter does not match incoming_path") + }) + } +} diff --git a/relay/channel/advancedcustom/adaptor.go b/relay/channel/advancedcustom/adaptor.go index 187f82ae..b7744fb3 100644 --- a/relay/channel/advancedcustom/adaptor.go +++ b/relay/channel/advancedcustom/adaptor.go @@ -104,10 +104,18 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo if err != nil { return nil, err } - if converter != dto.AdvancedCustomConverterNone { + switch converter { + case dto.AdvancedCustomConverterNone: + return a.convertOpenAICompatibleResponsesRequest(c, info, request) + case dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions: + chatReq, err := service.ResponsesRequestToChatCompletionsRequest(&request) + if err != nil { + return nil, err + } + return a.convertOpenAICompatibleRequest(c, info, chatReq) + default: return nil, fmt.Errorf("converter %q does not support OpenAI Responses requests", converter) } - return a.convertOpenAICompatibleResponsesRequest(c, info, request) } func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { @@ -221,6 +229,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom return openai.OaiResponsesToChatStreamHandler(c, info, resp) } return openai.OaiResponsesToChatHandler(c, info, resp) + case dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions: + if info.IsStream { + return openai.OaiChatToResponsesStreamHandler(c, info, resp) + } + return openai.OaiChatToResponsesHandler(c, info, resp) default: return nil, types.NewOpenAIError(fmt.Errorf("unsupported advanced custom converter: %s", a.converter), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } diff --git a/relay/channel/advancedcustom/adaptor_test.go b/relay/channel/advancedcustom/adaptor_test.go index c8e8d2e3..4cd8c92c 100644 --- a/relay/channel/advancedcustom/adaptor_test.go +++ b/relay/channel/advancedcustom/adaptor_test.go @@ -6,6 +6,7 @@ import ( "net/url" "testing" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" relaycommon "github.com/QuantumNous/new-api/relay/common" @@ -279,6 +280,44 @@ func TestAdaptorMatchesGeminiIncomingPathTemplate(t *testing.T) { } } +func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/responses", + UpstreamPath: "/v1/chat/completions", + Converter: dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions, + }, + }, + }) + info.RelayMode = relayconstant.RelayModeResponses + info.RequestURLPath = "/v1/responses" + c := advancedCustomGinContext("/v1/responses") + + converted, err := adaptor.ConvertOpenAIResponsesRequest(c, info, dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Instructions: mustAdvancedCustomRawMessage(t, "system rules"), + Input: mustAdvancedCustomRawMessage(t, "hello"), + }) + require.NoError(t, err) + + chatReq, ok := converted.(*dto.GeneralOpenAIRequest) + require.True(t, ok) + assert.Equal(t, "gpt-test", chatReq.Model) + require.Len(t, chatReq.Messages, 2) + assert.Equal(t, "system", chatReq.Messages[0].Role) + assert.Equal(t, "system rules", chatReq.Messages[0].StringContent()) + assert.Equal(t, "user", chatReq.Messages[1].Role) + assert.Equal(t, "hello", chatReq.Messages[1].StringContent()) + + requestURL, err := adaptor.GetRequestURL(info) + require.NoError(t, err) + parsedURL, err := url.Parse(requestURL) + require.NoError(t, err) + assert.Equal(t, "/v1/chat/completions", parsedURL.Path) +} + func advancedCustomRelayInfo(config *dto.AdvancedCustomConfig) *relaycommon.RelayInfo { return &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatOpenAI, @@ -302,3 +341,10 @@ func advancedCustomGinContext(path string) *gin.Context { c.Request.Header.Set("Content-Type", "application/json") return c } + +func mustAdvancedCustomRawMessage(t *testing.T, value any) []byte { + t.Helper() + raw, err := common.Marshal(value) + require.NoError(t, err) + return raw +} diff --git a/relay/channel/claude/relay_claude_test.go b/relay/channel/claude/relay_claude_test.go index 3bf6b35c..d6698f73 100644 --- a/relay/channel/claude/relay_claude_test.go +++ b/relay/channel/claude/relay_claude_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/service" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -12,6 +14,48 @@ func commonPointer[T any](value T) *T { return &value } +func TestResponseOpenAI2ClaudeToolUseInputIsObject(t *testing.T) { + tests := []struct { + name string + args string + want map[string]interface{} + }{ + {name: "object", args: `{"q":"x"}`, want: map[string]interface{}{"q": "x"}}, + {name: "empty", args: "", want: map[string]interface{}{}}, + {name: "invalid", args: "{", want: map[string]interface{}{}}, + {name: "null", args: "null", want: map[string]interface{}{}}, + {name: "array", args: `["x"]`, want: map[string]interface{}{}}, + {name: "string", args: `"x"`, want: map[string]interface{}{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := dto.Message{Role: "assistant"} + msg.SetToolCalls([]dto.ToolCallRequest{ + { + ID: "call_1", + Type: "function", + Function: dto.FunctionRequest{ + Name: "lookup", + Arguments: tt.args, + }, + }, + }) + resp := service.ResponseOpenAI2Claude(&dto.OpenAITextResponse{ + Id: "chatcmpl_1", + Model: "gpt-test", + Choices: []dto.OpenAITextResponseChoice{ + {Message: msg, FinishReason: "tool_calls"}, + }, + }, nil) + + require.Len(t, resp.Content, 1) + assert.Equal(t, "tool_use", resp.Content[0].Type) + assert.Equal(t, tt.want, resp.Content[0].Input) + }) + } +} + func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) { claudeInfo := &ClaudeResponseInfo{ Usage: &dto.Usage{}, diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 680c4ee4..276759b1 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -12,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/openai" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service/relayconvert" "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/setting/reasoning" "github.com/QuantumNous/new-api/types" @@ -238,8 +239,17 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela } func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { - // TODO implement me - return nil, errors.New("not implemented") + request, err := preprocessGeminiOpenAIResponsesRequest(request) + if err != nil { + return nil, err + } + + chatRequest, err := relayconvert.ResponsesRequestToChatCompletionsRequest(&request) + if err != nil { + return nil, err + } + + return a.ConvertOpenAIRequest(c, info, chatRequest) } func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { @@ -247,6 +257,13 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode == constant.RelayModeResponses { + if info.IsStream { + return GeminiResponsesStreamHandler(c, info, resp) + } + return GeminiResponsesHandler(c, info, resp) + } + if info.RelayMode == constant.RelayModeGemini { if strings.Contains(info.RequestURLPath, ":embedContent") || strings.Contains(info.RequestURLPath, ":batchEmbedContents") { diff --git a/relay/channel/gemini/adaptor_responses.go b/relay/channel/gemini/adaptor_responses.go new file mode 100644 index 00000000..f03c996f --- /dev/null +++ b/relay/channel/gemini/adaptor_responses.go @@ -0,0 +1,99 @@ +package gemini + +import ( + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +const ( + geminiResponsesInputTypeCustomToolCall = "custom_tool_call" + geminiResponsesInputTypeCustomToolCallOutput = "custom_tool_call_output" + geminiResponsesInputTypeFunctionCallOutput = "function_call_output" +) + +func preprocessGeminiOpenAIResponsesRequest(request dto.OpenAIResponsesRequest) (dto.OpenAIResponsesRequest, error) { + tools, err := filterGeminiResponsesTools(request.Tools) + if err != nil { + return request, err + } + request.Tools = tools + + input, err := filterGeminiResponsesInput(request.Input) + if err != nil { + return request, err + } + request.Input = input + + return request, nil +} + +func filterGeminiResponsesTools(raw []byte) ([]byte, error) { + if !geminiRawJSONPresent(raw) || common.GetJsonType(raw) != "array" { + return raw, nil + } + + var tools []map[string]any + if err := common.Unmarshal(raw, &tools); err != nil { + return nil, err + } + + filtered := make([]map[string]any, 0, len(tools)) + for _, tool := range tools { + if strings.TrimSpace(common.Interface2String(tool["type"])) != "function" { + // TODO: Support Responses custom/freeform tools when Gemini has a safe equivalent representation. + continue + } + filtered = append(filtered, tool) + } + if len(filtered) == 0 { + return nil, nil + } + return common.Marshal(filtered) +} + +func filterGeminiResponsesInput(raw []byte) ([]byte, error) { + if !geminiRawJSONPresent(raw) || common.GetJsonType(raw) != "array" { + return raw, nil + } + + var items []map[string]any + if err := common.Unmarshal(raw, &items); err != nil { + return nil, err + } + + skippedCustomCallIDs := make(map[string]struct{}) + for _, item := range items { + if strings.TrimSpace(common.Interface2String(item["type"])) != geminiResponsesInputTypeCustomToolCall { + continue + } + if callID := strings.TrimSpace(common.Interface2String(item["call_id"])); callID != "" { + skippedCustomCallIDs[callID] = struct{}{} + } + } + + filtered := make([]map[string]any, 0, len(items)) + for _, item := range items { + itemType := strings.TrimSpace(common.Interface2String(item["type"])) + switch itemType { + case geminiResponsesInputTypeCustomToolCall, geminiResponsesInputTypeCustomToolCallOutput: + // TODO: Support Responses custom/freeform tool calls once Gemini can preserve their semantics. + continue + case geminiResponsesInputTypeFunctionCallOutput: + if _, ok := skippedCustomCallIDs[strings.TrimSpace(common.Interface2String(item["call_id"]))]; ok { + continue + } + } + filtered = append(filtered, item) + } + + return common.Marshal(filtered) +} + +func geminiRawJSONPresent(raw []byte) bool { + if len(raw) == 0 { + return false + } + return common.GetJsonType(raw) != "null" +} diff --git a/relay/channel/gemini/adaptor_responses_test.go b/relay/channel/gemini/adaptor_responses_test.go new file mode 100644 index 00000000..a4ab67bc --- /dev/null +++ b/relay/channel/gemini/adaptor_responses_test.go @@ -0,0 +1,176 @@ +package gemini + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIResponsesRequestToGeminiInstructionsAndInput(t *testing.T) { + got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{ + Model: "gemini-test", + Instructions: mustGeminiRawMessage(t, "system rules"), + Input: mustGeminiRawMessage(t, "hello"), + }) + + require.NotNil(t, got.SystemInstructions) + require.Len(t, got.SystemInstructions.Parts, 1) + assert.Equal(t, "system rules", got.SystemInstructions.Parts[0].Text) + require.Len(t, got.Contents, 1) + assert.Equal(t, "user", got.Contents[0].Role) + require.Len(t, got.Contents[0].Parts, 1) + assert.Equal(t, "hello", got.Contents[0].Parts[0].Text) +} + +func TestConvertOpenAIResponsesRequestToGeminiFunctionToolAndChoice(t *testing.T) { + got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{ + Model: "gemini-test", + Input: mustGeminiRawMessage(t, "lookup weather"), + Tools: mustGeminiRawMessage(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"}, + }, + }, + }, + {"type": "custom", "name": "freeform"}, + }), + ToolChoice: mustGeminiRawMessage(t, map[string]any{ + "type": "function", + "name": "lookup", + }), + }) + + tools := got.GetTools() + require.Len(t, tools, 1) + assert.Equal(t, "lookup", gjson.GetBytes(got.Tools, "0.functionDeclarations.0.name").String()) + assert.Equal(t, "Lookup data", gjson.GetBytes(got.Tools, "0.functionDeclarations.0.description").String()) + require.NotNil(t, got.ToolConfig) + require.NotNil(t, got.ToolConfig.FunctionCallingConfig) + assert.Equal(t, dto.FunctionCallingConfigMode("ANY"), got.ToolConfig.FunctionCallingConfig.Mode) + assert.Equal(t, []string{"lookup"}, got.ToolConfig.FunctionCallingConfig.AllowedFunctionNames) +} + +func TestConvertOpenAIResponsesRequestToGeminiFunctionCallConversation(t *testing.T) { + got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{ + Model: "gemini-test", + Input: mustGeminiRawMessage(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}, + }, + }), + Tools: mustGeminiRawMessage(t, []map[string]any{ + {"type": "function", "name": "lookup", "parameters": map[string]any{"type": "object"}}, + }), + }) + + require.Len(t, got.Contents, 2) + assert.Equal(t, "model", got.Contents[0].Role) + require.Len(t, got.Contents[0].Parts, 2) + require.NotNil(t, got.Contents[0].Parts[0].FunctionCall) + assert.Equal(t, "lookup", got.Contents[0].Parts[0].FunctionCall.FunctionName) + assert.Equal(t, map[string]interface{}{"q": "x"}, got.Contents[0].Parts[0].FunctionCall.Arguments) + assert.Equal(t, "I will call.", got.Contents[0].Parts[1].Text) + + assert.Equal(t, "user", got.Contents[1].Role) + require.Len(t, got.Contents[1].Parts, 1) + require.NotNil(t, got.Contents[1].Parts[0].FunctionResponse) + assert.Equal(t, "lookup", got.Contents[1].Parts[0].FunctionResponse.Name) + assert.Equal(t, map[string]interface{}{"ok": true}, got.Contents[1].Parts[0].FunctionResponse.Response) +} + +func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T) { + got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{ + Model: "gemini-test", + Input: mustGeminiRawMessage(t, []map[string]any{ + { + "role": "assistant", + "content": []map[string]any{ + {"type": "output_text", "text": "before custom"}, + }, + }, + { + "type": "custom_tool_call", + "call_id": "call_custom", + "name": "apply_patch", + "input": "patch body", + }, + { + "type": "custom_tool_call_output", + "call_id": "call_custom", + "output": "ok", + }, + { + "type": "function_call_output", + "call_id": "call_custom", + "output": "legacy custom output", + }, + { + "role": "user", + "content": "next turn", + }, + }), + Tools: mustGeminiRawMessage(t, []map[string]any{ + {"type": "custom", "name": "apply_patch"}, + {"type": "unknown", "name": "unknown"}, + }), + }) + + assert.Empty(t, got.GetTools()) + require.Len(t, got.Contents, 2) + assert.Equal(t, "model", got.Contents[0].Role) + require.Len(t, got.Contents[0].Parts, 1) + assert.Equal(t, "before custom", got.Contents[0].Parts[0].Text) + assert.Nil(t, got.Contents[0].Parts[0].FunctionCall) + + assert.Equal(t, "user", got.Contents[1].Role) + require.Len(t, got.Contents[1].Parts, 1) + assert.Equal(t, "next turn", got.Contents[1].Parts[0].Text) + assert.Nil(t, got.Contents[1].Parts[0].FunctionResponse) +} + +func mustConvertResponsesToGemini(t *testing.T, req dto.OpenAIResponsesRequest) *dto.GeminiChatRequest { + t.Helper() + info := &relaycommon.RelayInfo{ + OriginModelName: req.Model, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: req.Model, + }, + } + got, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, req) + require.NoError(t, err) + geminiReq, ok := got.(*dto.GeminiChatRequest) + require.True(t, ok) + return geminiReq +} + +func mustGeminiRawMessage(t *testing.T, value any) []byte { + t.Helper() + raw, err := common.Marshal(value) + require.NoError(t, err) + return raw +} diff --git a/relay/channel/gemini/relay_responses.go b/relay/channel/gemini/relay_responses.go new file mode 100644 index 00000000..4ae75a4e --- /dev/null +++ b/relay/channel/gemini/relay_responses.go @@ -0,0 +1,162 @@ +package gemini + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/service/relayconvert" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + logger.LogDebug(c, "Gemini responses response body: %s", responseBody) + + var geminiResponse dto.GeminiChatResponse + if err := common.Unmarshal(responseBody, &geminiResponse); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if len(geminiResponse.Candidates) == 0 { + usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens()) + if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason)) + return &usage, types.NewOpenAIError( + errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), + types.ErrorCodePromptBlocked, + http.StatusBadRequest, + ) + } + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "gemini_empty_candidates") + return &usage, types.NewOpenAIError( + errors.New("empty response from Gemini API"), + types.ErrorCodeEmptyResponse, + http.StatusInternalServerError, + ) + } + + chatResp := responseGeminiChat2OpenAI(c, &geminiResponse) + chatResp.Model = info.UpstreamModelName + usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens()) + chatResp.Usage = usage + + responsesResp, responsesUsage, err := service.ChatCompletionsResponseToResponsesResponse(chatResp, helper.GetResponseID(c)) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if responsesUsage == nil || responsesUsage.TotalTokens == 0 { + responsesResp.Usage = relayconvert.UsageFromChatUsage(&usage) + } + + responseBody, err = common.Marshal(responsesResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + } + service.IOCopyBytesGracefully(c, resp, responseBody) + return &usage, nil +} + +func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseID := helper.GetResponseID(c) + created := common.GetTimestamp() + state := relayconvert.NewChatToResponsesStreamState(responseID, info.UpstreamModelName) + state.Created = created + finishReason := constant.FinishReasonStop + toolCallIndexByChoice := make(map[int]map[string]int) + nextToolCallIndexByChoice := make(map[int]int) + var streamErr *types.NewAPIError + + sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool { + data, err := common.Marshal(event.Payload) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + return false + } + helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)) + return true + } + sendChunk := func(chunk *dto.ChatCompletionsStreamResponse) bool { + events, err := relayconvert.ChatCompletionsStreamChunkToResponsesEvents(chunk, state) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + return false + } + for _, event := range events { + if !sendEvent(event) { + return false + } + } + return true + } + + usage, err := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool { + response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse) + response.Id = responseID + response.Created = created + response.Model = info.UpstreamModelName + + if response.IsToolCall() { + finishReason = constant.FinishReasonToolCalls + } + for choiceIdx := range response.Choices { + choiceKey := response.Choices[choiceIdx].Index + for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls { + tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx] + if tool.ID == "" { + continue + } + indexByID := toolCallIndexByChoice[choiceKey] + if indexByID == nil { + indexByID = make(map[string]int) + toolCallIndexByChoice[choiceKey] = indexByID + } + if idx, ok := indexByID[tool.ID]; ok { + tool.SetIndex(idx) + continue + } + idx := nextToolCallIndexByChoice[choiceKey] + nextToolCallIndexByChoice[choiceKey] = idx + 1 + indexByID[tool.ID] = idx + tool.SetIndex(idx) + } + } + + if !sendChunk(response) { + return false + } + if isStop { + return sendChunk(helper.GenerateStopResponse(responseID, created, info.UpstreamModelName, finishReason)) + } + return true + }) + if err != nil { + return usage, err + } + if streamErr != nil { + return nil, streamErr + } + + if usage != nil { + state.Usage = relayconvert.UsageFromChatUsage(usage) + } + for _, event := range relayconvert.FinalizeChatCompletionsStreamToResponses(state) { + if !sendEvent(event) { + return nil, streamErr + } + } + return usage, nil +} diff --git a/relay/channel/gemini/relay_responses_test.go b/relay/channel/gemini/relay_responses_test.go new file mode 100644 index 00000000..84fed51d --- /dev/null +++ b/relay/channel/gemini/relay_responses_test.go @@ -0,0 +1,205 @@ +package gemini + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGeminiResponsesHandlerReturnsOpenAIResponsesJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Set(common.RequestIdKey, "gemini-responses-test") + + info := newGeminiResponsesRelayInfo(false) + payload := dto.GeminiChatResponse{ + Candidates: []dto.GeminiChatCandidate{ + { + Content: dto.GeminiChatContent{ + Role: "model", + Parts: []dto.GeminiPart{ + {Text: "hello"}, + }, + }, + }, + }, + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: 2, + CandidatesTokenCount: 3, + TotalTokenCount: 5, + }, + } + body, err := common.Marshal(payload) + require.NoError(t, err) + + usage, newAPIError := GeminiResponsesHandler(c, info, &http.Response{ + Body: io.NopCloser(bytes.NewReader(body)), + }) + require.Nil(t, newAPIError) + require.NotNil(t, usage) + assert.Equal(t, 2, usage.PromptTokens) + assert.Equal(t, 3, usage.CompletionTokens) + + got := recorder.Body.String() + assert.Contains(t, got, `"object":"response"`) + assert.Contains(t, got, `"status":"completed"`) + assert.Contains(t, got, `"type":"output_text"`) + assert.Contains(t, got, `"text":"hello"`) + assert.Contains(t, got, `"input_tokens":2`) + assert.Contains(t, got, `"output_tokens":3`) + assert.NotContains(t, got, `"choices"`) + assert.NotContains(t, got, `"candidates"`) +} + +func TestGeminiResponsesHandlerClosesBodyOnReadError(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Set(common.RequestIdKey, "gemini-responses-read-error-test") + + body := &failingReadCloser{} + usage, newAPIError := GeminiResponsesHandler(c, newGeminiResponsesRelayInfo(false), &http.Response{Body: body}) + + require.Nil(t, usage) + require.NotNil(t, newAPIError) + assert.True(t, body.closed) +} + +func TestGeminiResponsesStreamHandlerReturnsOpenAIResponsesSSE(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Set(common.RequestIdKey, "gemini-responses-stream-test") + + oldStreamingTimeout := constant.StreamingTimeout + constant.StreamingTimeout = 300 + t.Cleanup(func() { constant.StreamingTimeout = oldStreamingTimeout }) + + info := newGeminiResponsesRelayInfo(true) + first := dto.GeminiChatResponse{ + Candidates: []dto.GeminiChatCandidate{ + { + Content: dto.GeminiChatContent{ + Role: "model", + Parts: []dto.GeminiPart{ + {Text: "hello"}, + }, + }, + }, + }, + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: 2, + CandidatesTokenCount: 3, + TotalTokenCount: 5, + }, + } + stop := "STOP" + final := dto.GeminiChatResponse{ + Candidates: []dto.GeminiChatCandidate{ + { + FinishReason: &stop, + Content: dto.GeminiChatContent{ + Role: "model", + Parts: []dto.GeminiPart{{Text: ""}}, + }, + }, + }, + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: 2, + CandidatesTokenCount: 3, + TotalTokenCount: 5, + }, + } + firstData, err := common.Marshal(first) + require.NoError(t, err) + finalData, err := common.Marshal(final) + require.NoError(t, err) + streamBody := strings.Join([]string{ + "data: " + string(firstData), + "", + "data: " + string(finalData), + "", + "data: [DONE]", + "", + }, "\n") + + usage, newAPIError := GeminiResponsesStreamHandler(c, info, &http.Response{ + Body: io.NopCloser(strings.NewReader(streamBody)), + }) + require.Nil(t, newAPIError) + require.NotNil(t, usage) + assert.Equal(t, 5, usage.TotalTokens) + + got := recorder.Body.String() + assert.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type")) + assert.Contains(t, got, `event: response.created`) + assert.Contains(t, got, `event: response.output_text.delta`) + assert.Contains(t, got, `"delta":"hello"`) + assert.Contains(t, got, `event: response.completed`) + assert.Contains(t, got, `"input_tokens":2`) + assert.Contains(t, got, `"output_tokens":3`) + assert.NotContains(t, got, `"choices"`) + assert.NotContains(t, got, `"candidates"`) + requireOrderedGeminiResponsesSubstrings(t, got, + `event: response.created`, + `event: response.output_item.added`, + `event: response.output_text.delta`, + `event: response.output_text.done`, + `event: response.completed`, + ) +} + +func newGeminiResponsesRelayInfo(isStream bool) *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + IsStream: isStream, + RelayMode: relayconstant.RelayModeResponses, + RelayFormat: types.RelayFormatOpenAIResponses, + RequestURLPath: "/v1/responses", + DisablePing: true, + OriginModelName: "gemini-test", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "gemini-test", + }, + } +} + +type failingReadCloser struct { + closed bool +} + +func (r *failingReadCloser) Read([]byte) (int, error) { + return 0, errors.New("read failed") +} + +func (r *failingReadCloser) Close() error { + r.closed = true + return nil +} + +func requireOrderedGeminiResponsesSubstrings(t *testing.T, s string, parts ...string) { + t.Helper() + offset := 0 + for _, part := range parts { + idx := strings.Index(s[offset:], part) + require.NotEqualf(t, -1, idx, "missing %q after byte offset %d", part, offset) + offset += idx + len(part) + } +} diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go index 8eb4821c..1bc04f67 100644 --- a/relay/channel/openai/chat_via_responses.go +++ b/relay/channel/openai/chat_via_responses.go @@ -14,7 +14,7 @@ import ( relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" - "github.com/QuantumNous/new-api/service/openaicompat" + "github.com/QuantumNous/new-api/service/relayconvert" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -78,7 +78,7 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R } defer service.CloseResponseBodyGracefully(resp) - accumulator := openaicompat.NewResponsesBufferedAccumulator() + accumulator := relayconvert.NewResponsesBufferedAccumulator() var finalResponse *dto.OpenAIResponsesResponse var streamErr *types.NewAPIError @@ -184,7 +184,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo responseId := helper.GetResponseID(c) createAt := time.Now().Unix() - state := openaicompat.NewResponsesToChatStreamState(info.UpstreamModelName, false) + state := relayconvert.NewResponsesToChatStreamState(info.UpstreamModelName, false) state.ID = responseId state.Created = createAt streamErr := (*types.NewAPIError)(nil) @@ -243,7 +243,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo return } - chunks, err := openaicompat.ResponsesStreamEventToChatChunks(&streamResp, state) + chunks, err := relayconvert.ResponsesStreamEventToChatChunks(&streamResp, state) if err != nil { streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) sr.Stop(streamErr) @@ -270,7 +270,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil { info.ClaudeConvertInfo.Usage = usage } - for _, chunk := range openaicompat.FinalizeResponsesToChatStream(state) { + for _, chunk := range relayconvert.FinalizeResponsesToChatStream(state) { if !sendChatChunk(chunk) { return nil, streamErr } diff --git a/relay/channel/openai/chat_via_responses_test.go b/relay/channel/openai/chat_via_responses_test.go index 7f5caf26..dd6d8e39 100644 --- a/relay/channel/openai/chat_via_responses_test.go +++ b/relay/channel/openai/chat_via_responses_test.go @@ -116,6 +116,58 @@ func TestOaiResponsesToChatBufferedStreamHandlerReturnsJSONFromSSE(t *testing.T) require.Contains(t, got, `"finish_reason":"tool_calls"`) } +func TestOaiChatToResponsesStreamHandlerConvertsSSEOrderAndUsage(t *testing.T) { + oldMode := gin.Mode() + gin.SetMode(gin.TestMode) + t.Cleanup(func() { gin.SetMode(oldMode) }) + + oldTimeout := constant.StreamingTimeout + constant.StreamingTimeout = 30 + t.Cleanup(func() { constant.StreamingTimeout = oldTimeout }) + + body := strings.Join([]string{ + `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup"}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"q\":\"x\"}"}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[],"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5}}`, + `data: [DONE]`, + ``, + }, "\n") + + c, recorder, resp, info := newResponsesChatTestContext(t, body, true) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + usage, err := OaiChatToResponsesStreamHandler(c, info, resp) + require.Nil(t, err) + require.NotNil(t, usage) + require.Equal(t, 2, usage.PromptTokens) + require.Equal(t, 3, usage.CompletionTokens) + require.Equal(t, 5, usage.TotalTokens) + + got := recorder.Body.String() + require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type")) + require.Contains(t, got, `event: response.created`) + require.Contains(t, got, `event: response.output_text.delta`) + require.Contains(t, got, `"delta":"hello"`) + require.Contains(t, got, `event: response.function_call_arguments.delta`) + require.Contains(t, got, `"delta":"{\"q\":\"x\"}"`) + require.Contains(t, got, `event: response.completed`) + require.Contains(t, got, `"input_tokens":2`) + require.Contains(t, got, `"output_tokens":3`) + requireOrderedSubstrings(t, got, + `event: response.created`, + `event: response.output_item.added`, + `event: response.output_text.delta`, + `event: response.output_item.added`, + `event: response.function_call_arguments.delta`, + `event: response.output_text.done`, + `event: response.function_call_arguments.done`, + `event: response.completed`, + ) +} + func requireOrderedSubstrings(t *testing.T, s string, parts ...string) { t.Helper() diff --git a/relay/channel/openai/responses_via_chat.go b/relay/channel/openai/responses_via_chat.go new file mode 100644 index 00000000..180c4a97 --- /dev/null +++ b/relay/channel/openai/responses_via_chat.go @@ -0,0 +1,131 @@ +package openai + +import ( + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/service/relayconvert" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +func OaiChatToResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + if resp == nil || resp.Body == nil { + return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + defer service.CloseResponseBodyGracefully(resp) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + + var chatResp dto.OpenAITextResponse + if err := common.Unmarshal(body, &chatResp); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if oaiError := chatResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + } + + responseID := helper.GetResponseID(c) + responsesResp, usage, err := service.ChatCompletionsResponseToResponsesResponse(&chatResp, responseID) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if usage == nil || usage.TotalTokens == 0 { + text := service.ExtractOutputTextFromResponses(responsesResp) + usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens()) + responsesResp.Usage = relayconvert.UsageFromChatUsage(usage) + } + + responseBody, err := common.Marshal(responsesResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + } + + service.IOCopyBytesGracefully(c, resp, responseBody) + return usage, nil +} + +func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + if resp == nil || resp.Body == nil { + return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + defer service.CloseResponseBodyGracefully(resp) + + responseID := helper.GetResponseID(c) + state := relayconvert.NewChatToResponsesStreamState(responseID, info.UpstreamModelName) + streamErr := (*types.NewAPIError)(nil) + + sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool { + data, err := common.Marshal(event.Payload) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + return false + } + helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)) + return true + } + + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { + if streamErr != nil { + sr.Stop(streamErr) + return + } + + var errorResp dto.OpenAITextResponse + if err := common.UnmarshalJsonStr(data, &errorResp); err == nil { + if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode) + sr.Stop(streamErr) + return + } + } + + var chunk dto.ChatCompletionsStreamResponse + if err := common.UnmarshalJsonStr(data, &chunk); err != nil { + logger.LogError(c, "failed to unmarshal chat stream response: "+err.Error()) + sr.Error(err) + return + } + + events, err := relayconvert.ChatCompletionsStreamChunkToResponsesEvents(&chunk, state) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + sr.Stop(streamErr) + return + } + for _, event := range events { + if !sendEvent(event) { + sr.Stop(streamErr) + return + } + } + }) + + if streamErr != nil { + return nil, streamErr + } + + usage := state.Usage + if usage == nil || usage.TotalTokens == 0 { + usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + state.Usage = relayconvert.UsageFromChatUsage(usage) + } + + for _, event := range relayconvert.FinalizeChatCompletionsStreamToResponses(state) { + if !sendEvent(event) { + return nil, streamErr + } + } + + return usage, nil +} diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index ce197f95..80b12475 100644 --- a/relay/chat_completions_via_responses.go +++ b/relay/chat_completions_via_responses.go @@ -146,7 +146,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad httpResp = resp.(*http.Response) clientStream := info.IsStream - upstreamStream := strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + upstreamStream := isResponsesEventStreamContentType(httpResp.Header.Get("Content-Type")) info.IsStream = clientStream || upstreamStream if httpResp.StatusCode != http.StatusOK { newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false) @@ -179,3 +179,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad } return usage, nil } + +func isResponsesEventStreamContentType(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "text/event-stream") +} diff --git a/relay/chat_completions_via_responses_test.go b/relay/chat_completions_via_responses_test.go new file mode 100644 index 00000000..ae96d0fd --- /dev/null +++ b/relay/chat_completions_via_responses_test.go @@ -0,0 +1,26 @@ +package relay + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsResponsesEventStreamContentType(t *testing.T) { + tests := []struct { + name string + contentType string + want bool + }{ + {name: "plain", contentType: "text/event-stream", want: true}, + {name: "mixed case with charset", contentType: "Text/Event-Stream; charset=utf-8", want: true}, + {name: "json", contentType: "application/json", want: false}, + {name: "empty", contentType: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isResponsesEventStreamContentType(tt.contentType)) + }) + } +} diff --git a/service/convert.go b/service/convert.go index 7a1d8747..a0ae8cb4 100644 --- a/service/convert.go +++ b/service/convert.go @@ -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) } } diff --git a/service/openai_chat_responses_compat.go b/service/openai_chat_responses_compat.go index e0202580..806b7662 100644 --- a/service/openai_chat_responses_compat.go +++ b/service/openai_chat_responses_compat.go @@ -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) } diff --git a/service/openai_chat_responses_mode.go b/service/openai_chat_responses_mode.go index c66c33c9..4db37279 100644 --- a/service/openai_chat_responses_mode.go +++ b/service/openai_chat_responses_mode.go @@ -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) } diff --git a/service/openaicompat/chat_responses_compat_test.go b/service/relayconvert/chat_responses_compat_test.go similarity index 57% rename from service/openaicompat/chat_responses_compat_test.go rename to service/relayconvert/chat_responses_compat_test.go index 4661f9d9..b14754d6 100644 --- a/service/openaicompat/chat_responses_compat_test.go +++ b/service/relayconvert/chat_responses_compat_test.go @@ -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 +} diff --git a/service/openaicompat/chat_to_responses.go b/service/relayconvert/chat_to_responses.go similarity index 99% rename from service/openaicompat/chat_to_responses.go rename to service/relayconvert/chat_to_responses.go index 16096b88..3f51e280 100644 --- a/service/openaicompat/chat_to_responses.go +++ b/service/relayconvert/chat_to_responses.go @@ -1,4 +1,4 @@ -package openaicompat +package relayconvert import ( "encoding/json" diff --git a/service/relayconvert/chat_to_responses_response.go b/service/relayconvert/chat_to_responses_response.go new file mode 100644 index 00000000..95416953 --- /dev/null +++ b/service/relayconvert/chat_to_responses_response.go @@ -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 +} diff --git a/service/openaicompat/policy.go b/service/relayconvert/policy.go similarity index 96% rename from service/openaicompat/policy.go rename to service/relayconvert/policy.go index b600b0fd..ee25e69f 100644 --- a/service/openaicompat/policy.go +++ b/service/relayconvert/policy.go @@ -1,4 +1,4 @@ -package openaicompat +package relayconvert import "github.com/QuantumNous/new-api/setting/model_setting" diff --git a/service/openaicompat/regex.go b/service/relayconvert/regex.go similarity index 96% rename from service/openaicompat/regex.go rename to service/relayconvert/regex.go index 4ad5e929..d65f0aa7 100644 --- a/service/openaicompat/regex.go +++ b/service/relayconvert/regex.go @@ -1,4 +1,4 @@ -package openaicompat +package relayconvert import ( "regexp" diff --git a/service/relayconvert/responses_request_to_chat.go b/service/relayconvert/responses_request_to_chat.go new file mode 100644 index 00000000..076d79af --- /dev/null +++ b/service/relayconvert/responses_request_to_chat.go @@ -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" +} diff --git a/service/relayconvert/responses_request_to_chat_test.go b/service/relayconvert/responses_request_to_chat_test.go new file mode 100644 index 00000000..1e8f5e79 --- /dev/null +++ b/service/relayconvert/responses_request_to_chat_test.go @@ -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 +} diff --git a/service/openaicompat/responses_to_chat.go b/service/relayconvert/responses_to_chat.go similarity index 97% rename from service/openaicompat/responses_to_chat.go rename to service/relayconvert/responses_to_chat.go index d7a9fbc5..bfa16211 100644 --- a/service/openaicompat/responses_to_chat.go +++ b/service/relayconvert/responses_to_chat.go @@ -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) diff --git a/web/default/src/features/channels/lib/advanced-custom.ts b/web/default/src/features/channels/lib/advanced-custom.ts index e85f9692..823e55f7 100644 --- a/web/default/src/features/channels/lib/advanced-custom.ts +++ b/web/default/src/features/channels/lib/advanced-custom.ts @@ -43,6 +43,10 @@ export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{ value: 'openai_chat_completions_to_openai_responses', label: 'OpenAI Chat to OpenAI Responses', }, + { + value: 'openai_responses_to_openai_chat_completions', + label: 'OpenAI Responses to OpenAI Chat', + }, { value: 'gemini_generate_content_to_openai_chat_completions', label: 'Gemini Generate Content to OpenAI Chat', @@ -326,6 +330,9 @@ export function getAdvancedCustomUpstreamPathPlaceholder( if (converter === 'openai_chat_completions_to_anthropic_messages') { return '/v1/messages' } + if (converter === 'openai_responses_to_openai_chat_completions') { + return '/v1/chat/completions' + } return '/v1/chat/completions' } @@ -573,6 +580,9 @@ function isConverterPathAllowed( ) { return incomingPath === '/v1/chat/completions' } + if (converter === 'openai_responses_to_openai_chat_completions') { + return incomingPath === '/v1/responses' + } return ( incomingPath.includes(':generateContent') || incomingPath.includes(':streamGenerateContent') diff --git a/web/default/src/features/channels/types.ts b/web/default/src/features/channels/types.ts index a70d5a28..6d9b7b1d 100644 --- a/web/default/src/features/channels/types.ts +++ b/web/default/src/features/channels/types.ts @@ -131,6 +131,7 @@ export type AdvancedCustomConverter = | 'anthropic_messages_to_openai_chat_completions' | 'openai_chat_completions_to_anthropic_messages' | 'openai_chat_completions_to_openai_responses' + | 'openai_responses_to_openai_chat_completions' | 'gemini_generate_content_to_openai_chat_completions' | 'openai_chat_completions_to_gemini_generate_content' diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 964e53da..93d11602 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -2995,6 +2995,7 @@ "OpenAI Rerank": "OpenAI Rerank", "OpenAI Responses": "OpenAI Responses", "OpenAI Responses Compact": "OpenAI Responses Compact", + "OpenAI Responses to OpenAI Chat": "OpenAI Responses to OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.", "OpenAIMax": "OpenAIMax", @@ -3675,8 +3676,8 @@ "Resetting...": "Resetting...", "Resolve Conflicts": "Resolve Conflicts", "Resource Configuration": "Resource Configuration", - "Responding...": "Responding...", "Resources": "Resources", + "Responding...": "Responding...", "Response": "Response", "Response Time": "Response Time", "Response time: {{duration}}": "Response time: {{duration}}", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index d0b0f8c5..45bdafef 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -2995,6 +2995,7 @@ "OpenAI Rerank": "OpenAI Rerank", "OpenAI Responses": "OpenAI Responses", "OpenAI Responses Compact": "OpenAI Responses Compact", + "OpenAI Responses to OpenAI Chat": "OpenAI Responses vers OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.", "OpenAIMax": "OpenAIMax", @@ -3675,8 +3676,8 @@ "Resetting...": "Réinitialisation...", "Resolve Conflicts": "Résoudre les conflits", "Resource Configuration": "Configuration des ressources", - "Responding...": "Réponse en cours...", "Resources": "Ressources", + "Responding...": "Réponse en cours...", "Response": "Réponse", "Response Time": "Temps de réponse", "Response time: {{duration}}": "Temps de réponse : {{duration}}", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 9de51685..e5a1f705 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -2995,6 +2995,7 @@ "OpenAI Rerank": "OpenAI 再ランク付け", "OpenAI Responses": "OpenAI レスポンス", "OpenAI Responses Compact": "OpenAI レスポンス圧縮", + "OpenAI Responses to OpenAI Chat": "OpenAI Responses から OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI、Anthropicなど", "OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Googleなど", "OpenAIMax": "OpenAIMax", @@ -3675,8 +3676,8 @@ "Resetting...": "リセット中...", "Resolve Conflicts": "競合を解決", "Resource Configuration": "リソース設定", - "Responding...": "応答中...", "Resources": "リソース", + "Responding...": "応答中...", "Response": "レスポンス", "Response Time": "応答時間", "Response time: {{duration}}": "応答時間: {{duration}}", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index beaf2abc..8a1e437c2 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -2995,6 +2995,7 @@ "OpenAI Rerank": "Реранжирование OpenAI", "OpenAI Responses": "Ответы OpenAI", "OpenAI Responses Compact": "Компактные ответы OpenAI", + "OpenAI Responses to OpenAI Chat": "OpenAI Responses в OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic и т.д.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google и т.д.", "OpenAIMax": "OpenAIMax", @@ -3675,8 +3676,8 @@ "Resetting...": "Сброс...", "Resolve Conflicts": "Разрешить конфликты", "Resource Configuration": "Конфигурация ресурсов", - "Responding...": "Отвечаем...", "Resources": "Ресурсы", + "Responding...": "Отвечаем...", "Response": "Ответ", "Response Time": "Время ответа", "Response time: {{duration}}": "Время ответа: {{duration}}", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index c244477b..a5179d77 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -2995,6 +2995,7 @@ "OpenAI Rerank": "OpenAI Rerank", "OpenAI Responses": "OpenAI Responses", "OpenAI Responses Compact": "OpenAI Responses Compact", + "OpenAI Responses to OpenAI Chat": "OpenAI Responses sang OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic, v.v.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, v.v.", "OpenAIMax": "OpenAIMax", @@ -3675,8 +3676,8 @@ "Resetting...": "Đang đặt lại...", "Resolve Conflicts": "Giải quyết Xung đột", "Resource Configuration": "Cấu hình tài nguyên", - "Responding...": "Đang phản hồi...", "Resources": "Tài nguyên", + "Responding...": "Đang phản hồi...", "Response": "Phản hồi", "Response Time": "Thời gian phản hồi", "Response time: {{duration}}": "Thời gian phản hồi: {{duration}}", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index b25ff0ed..2a108e15 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -2995,6 +2995,7 @@ "OpenAI Rerank": "OpenAI 重排序", "OpenAI Responses": "OpenAI 响应", "OpenAI Responses Compact": "OpenAI 响应压缩", + "OpenAI Responses to OpenAI Chat": "OpenAI Responses 到 OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI、Anthropic 等", "OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Google 等", "OpenAIMax": "OpenAIMax", @@ -3675,8 +3676,8 @@ "Resetting...": "重置中...", "Resolve Conflicts": "解决冲突", "Resource Configuration": "资源配置", - "Responding...": "正在回复...", "Resources": "资源", + "Responding...": "正在回复...", "Response": "响应", "Response Time": "响应时间", "Response time: {{duration}}": "响应时间:{{duration}}",