Commit 2d5a0416 by Calcium-Ion Committed by GitHub

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
parent 3a506f50
......@@ -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
......@@ -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
......
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")
})
}
}
......@@ -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())
}
......
......@@ -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
}
......@@ -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{},
......
......@@ -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") {
......
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"
}
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
}
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
}
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)
}
}
......@@ -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
}
......
......@@ -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()
......
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
}
......@@ -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")
}
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))
})
}
}
......@@ -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)
}
}
......
......@@ -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)
}
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)
}
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
}
package openaicompat
package relayconvert
import (
"encoding/json"
......
package openaicompat
package relayconvert
import "github.com/QuantumNous/new-api/setting/model_setting"
......
package openaicompat
package relayconvert
import (
"regexp"
......
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
}
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)
......
......@@ -44,6 +44,10 @@ export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
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')
......
......@@ -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'
......
......@@ -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}}",
......
......@@ -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}}",
......
......@@ -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}}",
......
......@@ -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}}",
......
......@@ -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}}",
......
......@@ -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}}",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment