mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
feat: support Responses to Chat (#5787)
* fix(openai): harden Chat-to-Responses compatibility Add a shared Responses-to-Chat stream state machine and use it from the OpenAI relay path. Preserve assistant text alongside tool calls, bind tool argument deltas by output_index, map incomplete finish reasons, support reasoning/custom tool events, and buffer upstream SSE for non-stream Chat clients. Add deterministic service tests and relay SSE tests for the conversion path. Related to #5745. * refactor: rename openaicompat to relayconvert for improved clarity * feat(gemini): support responses request conversion * feat: add responses to chat conversion support * fix: harden responses chat conversion edge cases
This commit is contained in:
@@ -39,4 +39,5 @@ skills-lock.json
|
|||||||
|
|
||||||
# Local-only live probes and scratch test workspaces.
|
# Local-only live probes and scratch test workspaces.
|
||||||
.local-tests/
|
.local-tests/
|
||||||
|
service/relayconvert/chat_responses_live_local_test.go
|
||||||
service/openaicompat/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"
|
AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions = "anthropic_messages_to_openai_chat_completions"
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages = "openai_chat_completions_to_anthropic_messages"
|
AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages = "openai_chat_completions_to_anthropic_messages"
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses = "openai_chat_completions_to_openai_responses"
|
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses = "openai_chat_completions_to_openai_responses"
|
||||||
|
AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions = "openai_responses_to_openai_chat_completions"
|
||||||
AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions = "gemini_generate_content_to_openai_chat_completions"
|
AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions = "gemini_generate_content_to_openai_chat_completions"
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent = "openai_chat_completions_to_gemini_generate_content"
|
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent = "openai_chat_completions_to_gemini_generate_content"
|
||||||
)
|
)
|
||||||
@@ -147,6 +148,7 @@ func IsAdvancedCustomConverterAllowed(converter string) bool {
|
|||||||
AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
|
AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages,
|
AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages,
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses,
|
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses,
|
||||||
|
AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions,
|
||||||
AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions,
|
AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions,
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
|
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
|
||||||
return true
|
return true
|
||||||
@@ -240,6 +242,10 @@ func validateAdvancedCustomConverterPath(index int, incomingPath string, convert
|
|||||||
if incomingPath == "/v1/chat/completions" {
|
if incomingPath == "/v1/chat/completions" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
case AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions:
|
||||||
|
if incomingPath == "/v1/responses" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
case AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
|
case AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
|
||||||
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
|
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,10 +104,18 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 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) {
|
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.OaiResponsesToChatStreamHandler(c, info, resp)
|
||||||
}
|
}
|
||||||
return openai.OaiResponsesToChatHandler(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:
|
default:
|
||||||
return nil, types.NewOpenAIError(fmt.Errorf("unsupported advanced custom converter: %s", a.converter), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
|
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"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/constant"
|
"github.com/QuantumNous/new-api/constant"
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
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 {
|
func advancedCustomRelayInfo(config *dto.AdvancedCustomConfig) *relaycommon.RelayInfo {
|
||||||
return &relaycommon.RelayInfo{
|
return &relaycommon.RelayInfo{
|
||||||
RelayFormat: types.RelayFormatOpenAI,
|
RelayFormat: types.RelayFormatOpenAI,
|
||||||
@@ -302,3 +341,10 @@ func advancedCustomGinContext(path string) *gin.Context {
|
|||||||
c.Request.Header.Set("Content-Type", "application/json")
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
return c
|
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"
|
"testing"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,6 +14,48 @@ func commonPointer[T any](value T) *T {
|
|||||||
return &value
|
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) {
|
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
|
||||||
claudeInfo := &ClaudeResponseInfo{
|
claudeInfo := &ClaudeResponseInfo{
|
||||||
Usage: &dto.Usage{},
|
Usage: &dto.Usage{},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
"github.com/QuantumNous/new-api/relay/channel/openai"
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
"github.com/QuantumNous/new-api/relay/constant"
|
"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/model_setting"
|
||||||
"github.com/QuantumNous/new-api/setting/reasoning"
|
"github.com/QuantumNous/new-api/setting/reasoning"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"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) {
|
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||||
// TODO implement me
|
request, err := preprocessGeminiOpenAIResponsesRequest(request)
|
||||||
return nil, errors.New("not implemented")
|
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) {
|
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) {
|
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 info.RelayMode == constant.RelayModeGemini {
|
||||||
if strings.Contains(info.RequestURLPath, ":embedContent") ||
|
if strings.Contains(info.RequestURLPath, ":embedContent") ||
|
||||||
strings.Contains(info.RequestURLPath, ":batchEmbedContents") {
|
strings.Contains(info.RequestURLPath, ":batchEmbedContents") {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
"github.com/QuantumNous/new-api/relay/helper"
|
"github.com/QuantumNous/new-api/relay/helper"
|
||||||
"github.com/QuantumNous/new-api/service"
|
"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/QuantumNous/new-api/types"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -78,7 +78,7 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
|
|||||||
}
|
}
|
||||||
defer service.CloseResponseBodyGracefully(resp)
|
defer service.CloseResponseBodyGracefully(resp)
|
||||||
|
|
||||||
accumulator := openaicompat.NewResponsesBufferedAccumulator()
|
accumulator := relayconvert.NewResponsesBufferedAccumulator()
|
||||||
var finalResponse *dto.OpenAIResponsesResponse
|
var finalResponse *dto.OpenAIResponsesResponse
|
||||||
var streamErr *types.NewAPIError
|
var streamErr *types.NewAPIError
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
|
|
||||||
responseId := helper.GetResponseID(c)
|
responseId := helper.GetResponseID(c)
|
||||||
createAt := time.Now().Unix()
|
createAt := time.Now().Unix()
|
||||||
state := openaicompat.NewResponsesToChatStreamState(info.UpstreamModelName, false)
|
state := relayconvert.NewResponsesToChatStreamState(info.UpstreamModelName, false)
|
||||||
state.ID = responseId
|
state.ID = responseId
|
||||||
state.Created = createAt
|
state.Created = createAt
|
||||||
streamErr := (*types.NewAPIError)(nil)
|
streamErr := (*types.NewAPIError)(nil)
|
||||||
@@ -243,7 +243,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks, err := openaicompat.ResponsesStreamEventToChatChunks(&streamResp, state)
|
chunks, err := relayconvert.ResponsesStreamEventToChatChunks(&streamResp, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
sr.Stop(streamErr)
|
sr.Stop(streamErr)
|
||||||
@@ -270,7 +270,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
|
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
|
||||||
info.ClaudeConvertInfo.Usage = usage
|
info.ClaudeConvertInfo.Usage = usage
|
||||||
}
|
}
|
||||||
for _, chunk := range openaicompat.FinalizeResponsesToChatStream(state) {
|
for _, chunk := range relayconvert.FinalizeResponsesToChatStream(state) {
|
||||||
if !sendChatChunk(chunk) {
|
if !sendChatChunk(chunk) {
|
||||||
return nil, streamErr
|
return nil, streamErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,58 @@ func TestOaiResponsesToChatBufferedStreamHandlerReturnsJSONFromSSE(t *testing.T)
|
|||||||
require.Contains(t, got, `"finish_reason":"tool_calls"`)
|
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) {
|
func requireOrderedSubstrings(t *testing.T, s string, parts ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -146,7 +146,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
|
|||||||
|
|
||||||
httpResp = resp.(*http.Response)
|
httpResp = resp.(*http.Response)
|
||||||
clientStream := info.IsStream
|
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
|
info.IsStream = clientStream || upstreamStream
|
||||||
if httpResp.StatusCode != http.StatusOK {
|
if httpResp.StatusCode != http.StatusOK {
|
||||||
newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
||||||
@@ -179,3 +179,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
|
|||||||
}
|
}
|
||||||
return usage, nil
|
return usage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isResponsesEventStreamContentType(contentType string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(contentType), "text/event-stream")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-5
@@ -628,12 +628,14 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco
|
|||||||
claudeContent.Type = "tool_use"
|
claudeContent.Type = "tool_use"
|
||||||
claudeContent.Id = toolUse.ID
|
claudeContent.Id = toolUse.ID
|
||||||
claudeContent.Name = toolUse.Function.Name
|
claudeContent.Name = toolUse.Function.Name
|
||||||
var mapParams map[string]interface{}
|
mapParams := map[string]interface{}{}
|
||||||
if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &mapParams); err == nil {
|
if strings.TrimSpace(toolUse.Function.Arguments) != "" {
|
||||||
claudeContent.Input = mapParams
|
var parsed map[string]interface{}
|
||||||
} else {
|
if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &parsed); err == nil && parsed != nil {
|
||||||
claudeContent.Input = toolUse.Function.Arguments
|
mapParams = parsed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
claudeContent.Input = mapParams
|
||||||
contents = append(contents, claudeContent)
|
contents = append(contents, claudeContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,29 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"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) {
|
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) {
|
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) {
|
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
|
||||||
return openaicompat.ResponsesFinishReasonFromStatus(resp)
|
return relayconvert.ResponsesFinishReasonFromStatus(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||||
return openaicompat.ExtractOutputTextFromResponses(resp)
|
return relayconvert.ExtractOutputTextFromResponses(resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/QuantumNous/new-api/service/openaicompat"
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
|
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 {
|
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
|
||||||
return openaicompat.ShouldChatCompletionsUseResponsesGlobal(channelID, channelType, model)
|
return relayconvert.ShouldChatCompletionsUseResponsesGlobal(channelID, channelType, model)
|
||||||
}
|
}
|
||||||
|
|||||||
+231
-1
@@ -1,4 +1,4 @@
|
|||||||
package openaicompat
|
package relayconvert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@@ -182,6 +182,70 @@ func TestResponsesStreamEventToChatChunksUsesOutputIndexForToolArguments(t *test
|
|||||||
assert.Equal(t, 3, state.Usage.TotalTokens)
|
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) {
|
func TestResponsesStreamEventToChatChunksCustomToolAndReasoning(t *testing.T) {
|
||||||
state := newTestResponsesStreamState()
|
state := newTestResponsesStreamState()
|
||||||
outputIndex := 0
|
outputIndex := 0
|
||||||
@@ -313,6 +377,165 @@ func TestResponsesBufferedAccumulatorSupplementsEmptyTerminalOutput(t *testing.T
|
|||||||
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
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 {
|
func assistantMessageWithTool(content string, id string, name string, args string) dto.Message {
|
||||||
msg := dto.Message{Role: "assistant", Content: content}
|
msg := dto.Message{Role: "assistant", Content: content}
|
||||||
msg.SetToolCalls([]dto.ToolCallRequest{
|
msg.SetToolCalls([]dto.ToolCallRequest{
|
||||||
@@ -341,3 +564,10 @@ func mustStreamChunks(t *testing.T, state *ResponsesToChatStreamState, event *dt
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
|
||||||
|
t.Helper()
|
||||||
|
events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return events
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package openaicompat
|
package relayconvert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
package relayconvert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
chatFinishReasonLength = "length"
|
||||||
|
chatFinishReasonContentFilter = "content_filter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
|
||||||
|
if resp == nil {
|
||||||
|
return nil, nil, errors.New("response is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := UsageFromChatUsage(&resp.Usage)
|
||||||
|
out := &dto.OpenAIResponsesResponse{
|
||||||
|
ID: id,
|
||||||
|
Object: "response",
|
||||||
|
CreatedAt: chatCreatedAt(resp.Created),
|
||||||
|
Status: []byte(`"completed"`),
|
||||||
|
Model: resp.Model,
|
||||||
|
Output: make([]dto.ResponsesOutput, 0),
|
||||||
|
Usage: usage,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return out, usage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
if status, details := ResponsesStatusFromChatFinishReason(choice.FinishReason); status != "" {
|
||||||
|
out.Status = []byte(fmt.Sprintf("%q", status))
|
||||||
|
out.IncompleteDetails = details
|
||||||
|
}
|
||||||
|
|
||||||
|
if text := choice.Message.StringContent(); text != "" {
|
||||||
|
out.Output = append(out.Output, dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeMessage,
|
||||||
|
ID: fmt.Sprintf("%s_msg_0", id),
|
||||||
|
Status: responseOutputStatus(out),
|
||||||
|
Role: "assistant",
|
||||||
|
Content: []dto.ResponsesOutputContent{
|
||||||
|
{
|
||||||
|
Type: "output_text",
|
||||||
|
Text: text,
|
||||||
|
Annotations: []interface{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
|
||||||
|
out.Output = append(out.Output, dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeReasoning,
|
||||||
|
ID: fmt.Sprintf("%s_reasoning_0", id),
|
||||||
|
Status: responseOutputStatus(out),
|
||||||
|
Content: []dto.ResponsesOutputContent{
|
||||||
|
{
|
||||||
|
Type: "summary_text",
|
||||||
|
Text: reasoning,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, toolCall := range choice.Message.ParseToolCalls() {
|
||||||
|
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
out.Output = append(out.Output, toolOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, usage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
|
||||||
|
switch strings.TrimSpace(finishReason) {
|
||||||
|
case chatFinishReasonLength:
|
||||||
|
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonMaxTokens}
|
||||||
|
case chatFinishReasonContentFilter:
|
||||||
|
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter}
|
||||||
|
default:
|
||||||
|
return "completed", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
|
||||||
|
usage := &dto.Usage{}
|
||||||
|
if src == nil {
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
if src.PromptTokens != 0 {
|
||||||
|
usage.PromptTokens = src.PromptTokens
|
||||||
|
usage.InputTokens = src.PromptTokens
|
||||||
|
}
|
||||||
|
if src.CompletionTokens != 0 {
|
||||||
|
usage.CompletionTokens = src.CompletionTokens
|
||||||
|
usage.OutputTokens = src.CompletionTokens
|
||||||
|
}
|
||||||
|
if src.TotalTokens != 0 {
|
||||||
|
usage.TotalTokens = src.TotalTokens
|
||||||
|
} else {
|
||||||
|
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
|
||||||
|
}
|
||||||
|
if src.PromptTokensDetails.CachedTokens != 0 ||
|
||||||
|
src.PromptTokensDetails.ImageTokens != 0 ||
|
||||||
|
src.PromptTokensDetails.AudioTokens != 0 ||
|
||||||
|
src.PromptTokensDetails.CachedCreationTokens != 0 ||
|
||||||
|
src.PromptTokensDetails.TextTokens != 0 {
|
||||||
|
details := src.PromptTokensDetails
|
||||||
|
usage.InputTokensDetails = &details
|
||||||
|
}
|
||||||
|
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
|
||||||
|
src.CompletionTokenDetails.TextTokens != 0 ||
|
||||||
|
src.CompletionTokenDetails.AudioTokens != 0 ||
|
||||||
|
src.CompletionTokenDetails.ImageTokens != 0 {
|
||||||
|
usage.CompletionTokenDetails = src.CompletionTokenDetails
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatToResponsesStreamEvent struct {
|
||||||
|
Type string
|
||||||
|
Payload dto.ResponsesStreamResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatToResponsesStreamState struct {
|
||||||
|
ID string
|
||||||
|
Model string
|
||||||
|
Created int64
|
||||||
|
Usage *dto.Usage
|
||||||
|
|
||||||
|
status string
|
||||||
|
incompleteDetails *dto.IncompleteDetails
|
||||||
|
sentCreated bool
|
||||||
|
textOutputIndex int
|
||||||
|
textStarted bool
|
||||||
|
textDone bool
|
||||||
|
reasoningIndex int
|
||||||
|
reasoningStarted bool
|
||||||
|
reasoningDone bool
|
||||||
|
finalized bool
|
||||||
|
nextOutputIndex int
|
||||||
|
toolsByIndex map[int]*chatToResponsesStreamTool
|
||||||
|
outputOrder []chatToResponsesOutputRef
|
||||||
|
text strings.Builder
|
||||||
|
reasoning strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatToResponsesStreamTool struct {
|
||||||
|
ChatIndex int
|
||||||
|
OutputIndex int
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Arguments strings.Builder
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatToResponsesOutputRef struct {
|
||||||
|
Kind string
|
||||||
|
ToolIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState {
|
||||||
|
return &ChatToResponsesStreamState{
|
||||||
|
ID: id,
|
||||||
|
Model: model,
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
Usage: &dto.Usage{},
|
||||||
|
status: "completed",
|
||||||
|
textOutputIndex: -1,
|
||||||
|
reasoningIndex: -1,
|
||||||
|
toolsByIndex: make(map[int]*chatToResponsesStreamTool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) {
|
||||||
|
if chunk == nil || state == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if state.ID == "" {
|
||||||
|
state.ID = chunk.Id
|
||||||
|
}
|
||||||
|
if state.Model == "" {
|
||||||
|
state.Model = chunk.Model
|
||||||
|
}
|
||||||
|
if state.Created == 0 {
|
||||||
|
state.Created = chunk.Created
|
||||||
|
}
|
||||||
|
if chunk.Usage != nil {
|
||||||
|
state.Usage = UsageFromChatUsage(chunk.Usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := make([]ChatToResponsesStreamEvent, 0)
|
||||||
|
if !state.sentCreated {
|
||||||
|
state.sentCreated = true
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventCreated, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventCreated,
|
||||||
|
Response: state.createdResponse(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
for _, choice := range chunk.Choices {
|
||||||
|
if choice.Delta.GetReasoningContent() != "" {
|
||||||
|
events = append(events, state.appendReasoningDelta(choice.Delta.GetReasoningContent())...)
|
||||||
|
}
|
||||||
|
if choice.Delta.GetContentString() != "" {
|
||||||
|
events = append(events, state.appendTextDelta(choice.Delta.GetContentString())...)
|
||||||
|
}
|
||||||
|
for _, toolCall := range choice.Delta.ToolCalls {
|
||||||
|
toolEvents, err := state.appendToolCallDelta(toolCall)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events = append(events, toolEvents...)
|
||||||
|
}
|
||||||
|
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
|
||||||
|
state.applyFinishReason(*choice.FinishReason)
|
||||||
|
events = append(events, state.doneDeltaEvents()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent {
|
||||||
|
if state == nil || state.finalized {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
events := state.doneDeltaEvents()
|
||||||
|
state.finalized = true
|
||||||
|
resp := state.finalResponse()
|
||||||
|
eventType := responsesEventCompleted
|
||||||
|
if state.status == "incomplete" {
|
||||||
|
eventType = responsesEventIncomplete
|
||||||
|
}
|
||||||
|
events = append(events, responsesStreamEvent(eventType, dto.ResponsesStreamResponse{
|
||||||
|
Type: eventType,
|
||||||
|
Response: resp,
|
||||||
|
}))
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) UsageText() string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.text.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent {
|
||||||
|
events := make([]ChatToResponsesStreamEvent, 0, 2)
|
||||||
|
if !s.textStarted {
|
||||||
|
s.textStarted = true
|
||||||
|
s.textOutputIndex = s.nextIndex("message", -1)
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventOutputItemAdded,
|
||||||
|
OutputIndex: intPtr(s.textOutputIndex),
|
||||||
|
Item: &dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeMessage,
|
||||||
|
ID: s.messageID(),
|
||||||
|
Status: "in_progress",
|
||||||
|
Role: "assistant",
|
||||||
|
Content: []dto.ResponsesOutputContent{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
s.text.WriteString(delta)
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventOutputTextDelta,
|
||||||
|
OutputIndex: intPtr(s.textOutputIndex),
|
||||||
|
ContentIndex: intPtr(0),
|
||||||
|
Delta: delta,
|
||||||
|
ItemID: s.messageID(),
|
||||||
|
}))
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent {
|
||||||
|
events := make([]ChatToResponsesStreamEvent, 0, 2)
|
||||||
|
if !s.reasoningStarted {
|
||||||
|
s.reasoningStarted = true
|
||||||
|
s.reasoningIndex = s.nextIndex("reasoning", -1)
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventOutputItemAdded,
|
||||||
|
OutputIndex: intPtr(s.reasoningIndex),
|
||||||
|
Item: &dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeReasoning,
|
||||||
|
ID: s.reasoningID(),
|
||||||
|
Status: "in_progress",
|
||||||
|
Content: []dto.ResponsesOutputContent{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
s.reasoning.WriteString(delta)
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventReasoningSummaryDelta,
|
||||||
|
OutputIndex: intPtr(s.reasoningIndex),
|
||||||
|
SummaryIndex: intPtr(0),
|
||||||
|
Delta: delta,
|
||||||
|
ItemID: s.reasoningID(),
|
||||||
|
}))
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallResponse) ([]ChatToResponsesStreamEvent, error) {
|
||||||
|
chatIndex := 0
|
||||||
|
if toolCall.Index != nil {
|
||||||
|
chatIndex = *toolCall.Index
|
||||||
|
}
|
||||||
|
tool := s.toolsByIndex[chatIndex]
|
||||||
|
events := make([]ChatToResponsesStreamEvent, 0, 2)
|
||||||
|
if tool == nil {
|
||||||
|
tool = &chatToResponsesStreamTool{
|
||||||
|
ChatIndex: chatIndex,
|
||||||
|
OutputIndex: s.nextIndex("tool", chatIndex),
|
||||||
|
ID: strings.TrimSpace(toolCall.ID),
|
||||||
|
Name: strings.TrimSpace(toolCall.Function.Name),
|
||||||
|
}
|
||||||
|
if tool.ID == "" {
|
||||||
|
tool.ID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
|
||||||
|
}
|
||||||
|
s.toolsByIndex[chatIndex] = tool
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventOutputItemAdded,
|
||||||
|
OutputIndex: intPtr(tool.OutputIndex),
|
||||||
|
ItemID: tool.ID,
|
||||||
|
Item: &dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeFunctionCall,
|
||||||
|
ID: tool.ID,
|
||||||
|
Status: "in_progress",
|
||||||
|
CallId: tool.ID,
|
||||||
|
Name: tool.Name,
|
||||||
|
Arguments: []byte(`""`),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(toolCall.ID) != "" {
|
||||||
|
tool.ID = strings.TrimSpace(toolCall.ID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(toolCall.Function.Name) != "" {
|
||||||
|
tool.Name = strings.TrimSpace(toolCall.Function.Name)
|
||||||
|
}
|
||||||
|
if toolCall.Function.Arguments != "" {
|
||||||
|
tool.Arguments.WriteString(toolCall.Function.Arguments)
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventFunctionArgsDelta,
|
||||||
|
OutputIndex: intPtr(tool.OutputIndex),
|
||||||
|
ItemID: tool.ID,
|
||||||
|
Delta: toolCall.Function.Arguments,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEvent {
|
||||||
|
events := make([]ChatToResponsesStreamEvent, 0)
|
||||||
|
status := s.outputStatus()
|
||||||
|
if s.textStarted && !s.textDone {
|
||||||
|
s.textDone = true
|
||||||
|
events = append(events, responsesStreamEvent("response.output_text.done", dto.ResponsesStreamResponse{
|
||||||
|
Type: "response.output_text.done",
|
||||||
|
OutputIndex: intPtr(s.textOutputIndex),
|
||||||
|
ContentIndex: intPtr(0),
|
||||||
|
ItemID: s.messageID(),
|
||||||
|
}))
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventOutputItemDone,
|
||||||
|
OutputIndex: intPtr(s.textOutputIndex),
|
||||||
|
Item: s.messageOutput(status),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if s.reasoningStarted && !s.reasoningDone {
|
||||||
|
s.reasoningDone = true
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDone, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventReasoningSummaryDone,
|
||||||
|
OutputIndex: intPtr(s.reasoningIndex),
|
||||||
|
SummaryIndex: intPtr(0),
|
||||||
|
ItemID: s.reasoningID(),
|
||||||
|
Part: &dto.ResponsesReasoningSummaryPart{
|
||||||
|
Type: "summary_text",
|
||||||
|
Text: s.reasoning.String(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventOutputItemDone,
|
||||||
|
OutputIndex: intPtr(s.reasoningIndex),
|
||||||
|
Item: s.reasoningOutput(status),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
for _, tool := range s.sortedTools() {
|
||||||
|
if tool.Done {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tool.Done = true
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventFunctionArgsDone, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventFunctionArgsDone,
|
||||||
|
OutputIndex: intPtr(tool.OutputIndex),
|
||||||
|
ItemID: tool.ID,
|
||||||
|
}))
|
||||||
|
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||||
|
Type: responsesEventOutputItemDone,
|
||||||
|
OutputIndex: intPtr(tool.OutputIndex),
|
||||||
|
Item: s.toolOutput(tool, status),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) applyFinishReason(finishReason string) {
|
||||||
|
if status, details := ResponsesStatusFromChatFinishReason(finishReason); status != "" {
|
||||||
|
s.status = status
|
||||||
|
s.incompleteDetails = details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesResponse {
|
||||||
|
output := make([]dto.ResponsesOutput, 0, len(s.outputOrder))
|
||||||
|
status := s.outputStatus()
|
||||||
|
for _, ref := range s.outputOrder {
|
||||||
|
switch ref.Kind {
|
||||||
|
case "message":
|
||||||
|
output = append(output, *s.messageOutput(status))
|
||||||
|
case "reasoning":
|
||||||
|
output = append(output, *s.reasoningOutput(status))
|
||||||
|
case "tool":
|
||||||
|
if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil {
|
||||||
|
output = append(output, *s.toolOutput(tool, status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &dto.OpenAIResponsesResponse{
|
||||||
|
ID: s.ID,
|
||||||
|
Object: "response",
|
||||||
|
CreatedAt: int(s.Created),
|
||||||
|
Status: []byte(fmt.Sprintf("%q", s.status)),
|
||||||
|
IncompleteDetails: s.incompleteDetails,
|
||||||
|
Model: s.Model,
|
||||||
|
Output: output,
|
||||||
|
Usage: s.Usage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) createdResponse() *dto.OpenAIResponsesResponse {
|
||||||
|
return &dto.OpenAIResponsesResponse{
|
||||||
|
ID: s.ID,
|
||||||
|
Object: "response",
|
||||||
|
CreatedAt: int(s.Created),
|
||||||
|
Status: []byte(`"in_progress"`),
|
||||||
|
Model: s.Model,
|
||||||
|
Output: []dto.ResponsesOutput{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int {
|
||||||
|
index := s.nextOutputIndex
|
||||||
|
s.nextOutputIndex++
|
||||||
|
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: kind, ToolIndex: toolIndex})
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) sortedTools() []*chatToResponsesStreamTool {
|
||||||
|
indexes := make([]int, 0, len(s.toolsByIndex))
|
||||||
|
for index := range s.toolsByIndex {
|
||||||
|
indexes = append(indexes, index)
|
||||||
|
}
|
||||||
|
sort.Ints(indexes)
|
||||||
|
tools := make([]*chatToResponsesStreamTool, 0, len(indexes))
|
||||||
|
for _, index := range indexes {
|
||||||
|
tools = append(tools, s.toolsByIndex[index])
|
||||||
|
}
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) outputStatus() string {
|
||||||
|
if s.status == "incomplete" {
|
||||||
|
return "incomplete"
|
||||||
|
}
|
||||||
|
return "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) messageID() string {
|
||||||
|
return fmt.Sprintf("%s_msg_0", s.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) reasoningID() string {
|
||||||
|
return fmt.Sprintf("%s_reasoning_0", s.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput {
|
||||||
|
return &dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeMessage,
|
||||||
|
ID: s.messageID(),
|
||||||
|
Status: status,
|
||||||
|
Role: "assistant",
|
||||||
|
Content: []dto.ResponsesOutputContent{
|
||||||
|
{
|
||||||
|
Type: "output_text",
|
||||||
|
Text: s.text.String(),
|
||||||
|
Annotations: []interface{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.ResponsesOutput {
|
||||||
|
return &dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeReasoning,
|
||||||
|
ID: s.reasoningID(),
|
||||||
|
Status: status,
|
||||||
|
Content: []dto.ResponsesOutputContent{
|
||||||
|
{
|
||||||
|
Type: "summary_text",
|
||||||
|
Text: s.reasoning.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput {
|
||||||
|
return &dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeFunctionCall,
|
||||||
|
ID: tool.ID,
|
||||||
|
Status: status,
|
||||||
|
CallId: tool.ID,
|
||||||
|
Name: tool.Name,
|
||||||
|
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseOutputStatus(resp *dto.OpenAIResponsesResponse) string {
|
||||||
|
if resp == nil || responseStatusString(resp) != "incomplete" {
|
||||||
|
return "completed"
|
||||||
|
}
|
||||||
|
return "incomplete"
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) {
|
||||||
|
callID := strings.TrimSpace(toolCall.ID)
|
||||||
|
if callID == "" {
|
||||||
|
callID = fmt.Sprintf("%s_call_%d", responseID, index)
|
||||||
|
}
|
||||||
|
if toolCall.Type == "" || toolCall.Type == "function" {
|
||||||
|
return dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeFunctionCall,
|
||||||
|
ID: callID,
|
||||||
|
Status: status,
|
||||||
|
CallId: callID,
|
||||||
|
Name: toolCall.Function.Name,
|
||||||
|
Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return dto.ResponsesOutput{
|
||||||
|
Type: toolCall.Type,
|
||||||
|
ID: callID,
|
||||||
|
Status: status,
|
||||||
|
CallId: callID,
|
||||||
|
Arguments: toolCall.Custom,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatArgumentsRawMessage(arguments string) []byte {
|
||||||
|
raw, err := common.Marshal(arguments)
|
||||||
|
if err != nil {
|
||||||
|
return []byte(`""`)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatCreatedAt(created any) int {
|
||||||
|
switch v := created.(type) {
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
case int64:
|
||||||
|
return int(v)
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case float32:
|
||||||
|
return int(v)
|
||||||
|
case string:
|
||||||
|
if parsed := common.String2Int(v); parsed != 0 {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return int(time.Now().Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesStreamEvent(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
|
||||||
|
payload.Type = eventType
|
||||||
|
return ChatToResponsesStreamEvent{
|
||||||
|
Type: eventType,
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(v int) *int {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package openaicompat
|
package relayconvert
|
||||||
|
|
||||||
import "github.com/QuantumNous/new-api/setting/model_setting"
|
import "github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package openaicompat
|
package relayconvert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
package relayconvert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
responsesInputTypeFunctionCall = "function_call"
|
||||||
|
responsesInputTypeFunctionCallOutput = "function_call_output"
|
||||||
|
responsesInputTypeCustomToolCall = "custom_tool_call"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("request is nil")
|
||||||
|
}
|
||||||
|
if req.Model == "" {
|
||||||
|
return nil, errors.New("model is required")
|
||||||
|
}
|
||||||
|
if err := validateResponsesRequestChatUnsupportedFields(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := responsesRequestMessagesToChat(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tools, err := responsesRequestToolsToChat(req.Tools)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
toolChoice, err := responsesRequestToolChoiceToChat(req.ToolChoice)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
responseFormat, err := responsesRequestTextToChatResponseFormat(req.Text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &dto.GeneralOpenAIRequest{
|
||||||
|
Model: req.Model,
|
||||||
|
Messages: messages,
|
||||||
|
Stream: req.Stream,
|
||||||
|
StreamOptions: req.StreamOptions,
|
||||||
|
MaxCompletionTokens: req.MaxOutputTokens,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
TopP: req.TopP,
|
||||||
|
TopLogProbs: req.TopLogProbs,
|
||||||
|
ResponseFormat: responseFormat,
|
||||||
|
Tools: tools,
|
||||||
|
ToolChoice: toolChoice,
|
||||||
|
User: req.User,
|
||||||
|
Store: req.Store,
|
||||||
|
Metadata: req.Metadata,
|
||||||
|
SafetyIdentifier: req.SafetyIdentifier,
|
||||||
|
PromptCacheRetention: req.PromptCacheRetention,
|
||||||
|
EnableThinking: req.EnableThinking,
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Reasoning != nil {
|
||||||
|
out.ReasoningEffort = req.Reasoning.Effort
|
||||||
|
}
|
||||||
|
if req.ServiceTier != "" {
|
||||||
|
out.ServiceTier, _ = common.Marshal(req.ServiceTier)
|
||||||
|
}
|
||||||
|
if len(req.ParallelToolCalls) > 0 && common.GetJsonType(req.ParallelToolCalls) == "boolean" {
|
||||||
|
var parallelToolCalls bool
|
||||||
|
if err := common.Unmarshal(req.ParallelToolCalls, ¶llelToolCalls); err == nil {
|
||||||
|
out.ParallelTooCalls = ¶llelToolCalls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(req.PromptCacheKey) > 0 && common.GetJsonType(req.PromptCacheKey) == "string" {
|
||||||
|
var promptCacheKey string
|
||||||
|
if err := common.Unmarshal(req.PromptCacheKey, &promptCacheKey); err == nil {
|
||||||
|
out.PromptCacheKey = promptCacheKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
|
||||||
|
unsupported := make([]string, 0, 4)
|
||||||
|
if rawJSONPresent(req.Conversation) {
|
||||||
|
unsupported = append(unsupported, "conversation")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.PreviousResponseID) != "" {
|
||||||
|
unsupported = append(unsupported, "previous_response_id")
|
||||||
|
}
|
||||||
|
if rawJSONPresent(req.Prompt) {
|
||||||
|
unsupported = append(unsupported, "prompt")
|
||||||
|
}
|
||||||
|
if rawJSONPresent(req.ContextManagement) {
|
||||||
|
unsupported = append(unsupported, "context_management")
|
||||||
|
}
|
||||||
|
if len(unsupported) > 0 {
|
||||||
|
return fmt.Errorf("responses to chat conversion does not support stateful fields: %s", strings.Join(unsupported, ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
|
||||||
|
messages := make([]dto.Message, 0)
|
||||||
|
if rawJSONPresent(req.Instructions) {
|
||||||
|
instructions, err := responsesJSONString(req.Instructions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid instructions: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(instructions) != "" {
|
||||||
|
messages = append(messages, dto.Message{Role: "system", Content: instructions})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rawJSONPresent(req.Input) {
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch common.GetJsonType(req.Input) {
|
||||||
|
case "string":
|
||||||
|
input, err := responsesJSONString(req.Input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid input string: %w", err)
|
||||||
|
}
|
||||||
|
messages = append(messages, dto.Message{Role: "user", Content: input})
|
||||||
|
return messages, nil
|
||||||
|
case "array":
|
||||||
|
var items []map[string]any
|
||||||
|
if err := common.Unmarshal(req.Input, &items); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid input array: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
nextMessages, err := responsesInputItemToChatMessages(item, messages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
messages = nextMessages
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported responses input type %q", common.GetJsonType(req.Input))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) {
|
||||||
|
itemType := strings.TrimSpace(common.Interface2String(item["type"]))
|
||||||
|
switch itemType {
|
||||||
|
case responsesInputTypeFunctionCall:
|
||||||
|
toolCall, err := responsesFunctionCallItemToChatToolCall(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return appendToolCallToLastAssistant(messages, toolCall), nil
|
||||||
|
case responsesInputTypeCustomToolCall:
|
||||||
|
toolCall, err := responsesCustomToolCallItemToChatToolCall(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return appendToolCallToLastAssistant(messages, toolCall), nil
|
||||||
|
case responsesInputTypeFunctionCallOutput:
|
||||||
|
callID := strings.TrimSpace(common.Interface2String(item["call_id"]))
|
||||||
|
content := responseToolOutputToChatContent(item["output"])
|
||||||
|
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
role := strings.TrimSpace(common.Interface2String(item["role"]))
|
||||||
|
if role == "" {
|
||||||
|
role = "user"
|
||||||
|
}
|
||||||
|
content, err := responsesInputContentToChatContent(item["content"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(messages, dto.Message{Role: role, Content: content}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesInputContentToChatContent(content any) (any, error) {
|
||||||
|
if content == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value := content.(type) {
|
||||||
|
case string:
|
||||||
|
return value, nil
|
||||||
|
case []any:
|
||||||
|
return responsesContentPartsToChatContent(value)
|
||||||
|
case []map[string]any:
|
||||||
|
parts := make([]any, 0, len(value))
|
||||||
|
for _, part := range value {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
return responsesContentPartsToChatContent(parts)
|
||||||
|
default:
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesContentPartsToChatContent(parts []any) (any, error) {
|
||||||
|
chatParts := make([]any, 0, len(parts))
|
||||||
|
var textOnly strings.Builder
|
||||||
|
onlyText := true
|
||||||
|
|
||||||
|
for _, rawPart := range parts {
|
||||||
|
part, ok := rawPart.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
onlyText = false
|
||||||
|
chatParts = append(chatParts, rawPart)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
partType := strings.TrimSpace(common.Interface2String(part["type"]))
|
||||||
|
switch partType {
|
||||||
|
case "input_text", "output_text", "text":
|
||||||
|
text := common.Interface2String(part["text"])
|
||||||
|
textOnly.WriteString(text)
|
||||||
|
chatParts = append(chatParts, map[string]any{
|
||||||
|
"type": dto.ContentTypeText,
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
case "input_image":
|
||||||
|
onlyText = false
|
||||||
|
chatParts = append(chatParts, map[string]any{
|
||||||
|
"type": dto.ContentTypeImageURL,
|
||||||
|
"image_url": responsesImagePartToChatImageURL(part),
|
||||||
|
})
|
||||||
|
case "input_file":
|
||||||
|
onlyText = false
|
||||||
|
chatParts = append(chatParts, map[string]any{
|
||||||
|
"type": dto.ContentTypeFile,
|
||||||
|
"file": responsesFilePartToChatFile(part),
|
||||||
|
})
|
||||||
|
case "input_audio":
|
||||||
|
onlyText = false
|
||||||
|
chatParts = append(chatParts, map[string]any{
|
||||||
|
"type": dto.ContentTypeInputAudio,
|
||||||
|
"input_audio": responsesPartPayload(part, "input_audio"),
|
||||||
|
})
|
||||||
|
case "input_video":
|
||||||
|
onlyText = false
|
||||||
|
chatParts = append(chatParts, map[string]any{
|
||||||
|
"type": dto.ContentTypeVideoUrl,
|
||||||
|
"video_url": responsesVideoPartToChatVideoURL(part),
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
onlyText = false
|
||||||
|
chatParts = append(chatParts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if onlyText {
|
||||||
|
return textOnly.String(), nil
|
||||||
|
}
|
||||||
|
return chatParts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
||||||
|
name := strings.TrimSpace(common.Interface2String(item["name"]))
|
||||||
|
if name == "" {
|
||||||
|
return dto.ToolCallRequest{}, errors.New("function_call item is missing name")
|
||||||
|
}
|
||||||
|
return dto.ToolCallRequest{
|
||||||
|
ID: responsesCallID(item),
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: name,
|
||||||
|
Arguments: responsesArgumentsString(item["arguments"]),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesCustomToolCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
||||||
|
raw, err := common.Marshal(item)
|
||||||
|
if err != nil {
|
||||||
|
return dto.ToolCallRequest{}, err
|
||||||
|
}
|
||||||
|
return dto.ToolCallRequest{
|
||||||
|
ID: responsesCallID(item),
|
||||||
|
Type: dto.CustomType,
|
||||||
|
Custom: raw,
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: strings.TrimSpace(common.Interface2String(item["name"])),
|
||||||
|
Arguments: responsesArgumentsString(item["input"]),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendToolCallToLastAssistant(messages []dto.Message, toolCall dto.ToolCallRequest) []dto.Message {
|
||||||
|
if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" {
|
||||||
|
messages = append(messages, dto.Message{Role: "assistant"})
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := len(messages) - 1
|
||||||
|
toolCalls := messages[idx].ParseToolCalls()
|
||||||
|
toolCalls = append(toolCalls, toolCall)
|
||||||
|
toolCallsRaw, _ := common.Marshal(toolCalls)
|
||||||
|
messages[idx].ToolCalls = toolCallsRaw
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesRequestToolsToChat(raw json.RawMessage) ([]dto.ToolCallRequest, error) {
|
||||||
|
if !rawJSONPresent(raw) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tools []map[string]any
|
||||||
|
if err := common.Unmarshal(raw, &tools); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid tools: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]dto.ToolCallRequest, 0, len(tools))
|
||||||
|
for _, tool := range tools {
|
||||||
|
toolType := strings.TrimSpace(common.Interface2String(tool["type"]))
|
||||||
|
if toolType == "function" {
|
||||||
|
out = append(out, dto.ToolCallRequest{
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: strings.TrimSpace(common.Interface2String(tool["name"])),
|
||||||
|
Description: common.Interface2String(tool["description"]),
|
||||||
|
Parameters: tool["parameters"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rawTool, err := common.Marshal(tool)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, dto.ToolCallRequest{
|
||||||
|
Type: toolType,
|
||||||
|
Custom: rawTool,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
||||||
|
if !rawJSONPresent(raw) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if common.GetJsonType(raw) == "string" {
|
||||||
|
var choice string
|
||||||
|
if err := common.Unmarshal(raw, &choice); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid tool_choice: %w", err)
|
||||||
|
}
|
||||||
|
return choice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var choice map[string]any
|
||||||
|
if err := common.Unmarshal(raw, &choice); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid tool_choice: %w", err)
|
||||||
|
}
|
||||||
|
if common.Interface2String(choice["type"]) == "function" {
|
||||||
|
name := strings.TrimSpace(common.Interface2String(choice["name"]))
|
||||||
|
if name != "" {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]any{
|
||||||
|
"name": name,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return choice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
||||||
|
if !rawJSONPresent(raw) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var textConfig map[string]any
|
||||||
|
if err := common.Unmarshal(raw, &textConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid text config: %w", err)
|
||||||
|
}
|
||||||
|
format, ok := textConfig["format"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
formatType := strings.TrimSpace(common.Interface2String(format["type"]))
|
||||||
|
if formatType == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &dto.ResponseFormat{Type: formatType}
|
||||||
|
if formatType == "json_schema" {
|
||||||
|
schemaRaw, err := common.Marshal(format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.JsonSchema = schemaRaw
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesImagePartToChatImageURL(part map[string]any) any {
|
||||||
|
if imageURL, ok := part["image_url"]; ok {
|
||||||
|
return imageURL
|
||||||
|
}
|
||||||
|
imageURL := map[string]any{}
|
||||||
|
for _, key := range []string{"url", "file_id", "detail"} {
|
||||||
|
if value, ok := part[key]; ok {
|
||||||
|
imageURL[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(imageURL) == 0 {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
return imageURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesFilePartToChatFile(part map[string]any) any {
|
||||||
|
if file, ok := part["file"]; ok {
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
file := map[string]any{}
|
||||||
|
for _, key := range []string{"file_id", "file_data", "filename", "file_url"} {
|
||||||
|
if value, ok := part[key]; ok {
|
||||||
|
file[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(file) == 0 {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesVideoPartToChatVideoURL(part map[string]any) any {
|
||||||
|
if videoURL, ok := part["video_url"]; ok {
|
||||||
|
if videoURLMap, ok := videoURL.(map[string]any); ok {
|
||||||
|
if url := common.Interface2String(videoURLMap["url"]); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return videoURL
|
||||||
|
}
|
||||||
|
if url := common.Interface2String(part["url"]); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return responsesPartPayload(part, "video_url")
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesPartPayload(part map[string]any, key string) any {
|
||||||
|
if value, ok := part[key]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
payload := make(map[string]any, len(part))
|
||||||
|
for k, value := range part {
|
||||||
|
if k == "type" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload[k] = value
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesCallID(item map[string]any) string {
|
||||||
|
callID := strings.TrimSpace(common.Interface2String(item["call_id"]))
|
||||||
|
if callID != "" {
|
||||||
|
return callID
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(common.Interface2String(item["id"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesArgumentsString(value any) string {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
raw, err := common.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return common.Interface2String(v)
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseToolOutputToChatContent(value any) any {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
raw, err := common.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesJSONString(raw json.RawMessage) (string, error) {
|
||||||
|
if common.GetJsonType(raw) != "string" {
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
var value string
|
||||||
|
if err := common.Unmarshal(raw, &value); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawJSONPresent(raw json.RawMessage) bool {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return common.GetJsonType(raw) != "null"
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package relayconvert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResponsesRequestToChatCompletionsRequestInstructionsAndScalarInput(t *testing.T) {
|
||||||
|
stream := true
|
||||||
|
temperature := 0.0
|
||||||
|
topP := 0.9
|
||||||
|
maxOutputTokens := uint(128)
|
||||||
|
parallelToolCalls := true
|
||||||
|
|
||||||
|
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Instructions: mustRawMessage(t, "system rules"),
|
||||||
|
Input: mustRawMessage(t, "hello"),
|
||||||
|
Stream: &stream,
|
||||||
|
StreamOptions: &dto.StreamOptions{IncludeUsage: true},
|
||||||
|
MaxOutputTokens: &maxOutputTokens,
|
||||||
|
Temperature: &temperature,
|
||||||
|
TopP: &topP,
|
||||||
|
User: mustRawMessage(t, "user-1"),
|
||||||
|
Store: mustRawMessage(t, false),
|
||||||
|
Metadata: mustRawMessage(t, map[string]any{"trace": "abc"}),
|
||||||
|
ParallelToolCalls: mustRawMessage(t, parallelToolCalls),
|
||||||
|
PromptCacheKey: mustRawMessage(t, "cache-key"),
|
||||||
|
PromptCacheRetention: mustRawMessage(t, "24h"),
|
||||||
|
Reasoning: &dto.Reasoning{Effort: "medium"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "gpt-test", got.Model)
|
||||||
|
require.Len(t, got.Messages, 2)
|
||||||
|
assert.Equal(t, dto.Message{Role: "system", Content: "system rules"}, got.Messages[0])
|
||||||
|
assert.Equal(t, dto.Message{Role: "user", Content: "hello"}, got.Messages[1])
|
||||||
|
assert.Same(t, &stream, got.Stream)
|
||||||
|
require.NotNil(t, got.StreamOptions)
|
||||||
|
assert.True(t, got.StreamOptions.IncludeUsage)
|
||||||
|
assert.Equal(t, maxOutputTokens, lo.FromPtr(got.MaxCompletionTokens))
|
||||||
|
assert.Equal(t, 0.0, lo.FromPtr(got.Temperature))
|
||||||
|
assert.Equal(t, 0.9, lo.FromPtr(got.TopP))
|
||||||
|
assert.True(t, lo.FromPtr(got.ParallelTooCalls))
|
||||||
|
assert.Equal(t, "cache-key", got.PromptCacheKey)
|
||||||
|
assert.Equal(t, "medium", got.ReasoningEffort)
|
||||||
|
assert.Equal(t, `"user-1"`, string(got.User))
|
||||||
|
assert.Equal(t, `false`, string(got.Store))
|
||||||
|
assert.Equal(t, "abc", gjson.GetBytes(got.Metadata, "trace").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsesRequestToChatCompletionsRequestMultimodalInput(t *testing.T) {
|
||||||
|
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Input: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": []map[string]any{
|
||||||
|
{"type": "input_text", "text": "look"},
|
||||||
|
{"type": "input_image", "image_url": "https://example.test/a.png", "detail": "low"},
|
||||||
|
{"type": "input_file", "file_id": "file_1", "filename": "a.txt"},
|
||||||
|
{"type": "input_audio", "input_audio": map[string]any{"data": "abc", "format": "wav"}},
|
||||||
|
{"type": "input_video", "video_url": map[string]any{"url": "https://example.test/v.mp4"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, got.Messages, 1)
|
||||||
|
assert.Equal(t, "user", got.Messages[0].Role)
|
||||||
|
parts := got.Messages[0].ParseContent()
|
||||||
|
require.Len(t, parts, 5)
|
||||||
|
assert.Equal(t, dto.ContentTypeText, parts[0].Type)
|
||||||
|
assert.Equal(t, "look", parts[0].Text)
|
||||||
|
assert.Equal(t, dto.ContentTypeImageURL, parts[1].Type)
|
||||||
|
assert.Equal(t, "https://example.test/a.png", parts[1].GetImageMedia().Url)
|
||||||
|
assert.Equal(t, dto.ContentTypeFile, parts[2].Type)
|
||||||
|
assert.Equal(t, "file_1", parts[2].GetFile().FileId)
|
||||||
|
assert.Equal(t, dto.ContentTypeInputAudio, parts[3].Type)
|
||||||
|
assert.Equal(t, "wav", parts[3].GetInputAudio().Format)
|
||||||
|
assert.Equal(t, dto.ContentTypeVideoUrl, parts[4].Type)
|
||||||
|
assert.Equal(t, "https://example.test/v.mp4", parts[4].GetVideoUrl().Url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsesRequestToChatCompletionsRequestAssistantTextAndFunctionCallCoexist(t *testing.T) {
|
||||||
|
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Input: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": []map[string]any{
|
||||||
|
{"type": "output_text", "text": "I will call."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"name": "lookup",
|
||||||
|
"arguments": map[string]any{"q": "x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"output": map[string]any{"ok": true},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, got.Messages, 2)
|
||||||
|
assert.Equal(t, "assistant", got.Messages[0].Role)
|
||||||
|
assert.Equal(t, "I will call.", got.Messages[0].StringContent())
|
||||||
|
toolCalls := got.Messages[0].ParseToolCalls()
|
||||||
|
require.Len(t, toolCalls, 1)
|
||||||
|
assert.Equal(t, "call_1", toolCalls[0].ID)
|
||||||
|
assert.Equal(t, "function", toolCalls[0].Type)
|
||||||
|
assert.Equal(t, "lookup", toolCalls[0].Function.Name)
|
||||||
|
assert.JSONEq(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
||||||
|
assert.Equal(t, "tool", got.Messages[1].Role)
|
||||||
|
assert.Equal(t, "call_1", got.Messages[1].ToolCallId)
|
||||||
|
assert.JSONEq(t, `{"ok":true}`, got.Messages[1].StringContent())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsesRequestToChatCompletionsRequestOnlyFunctionCallCreatesAssistant(t *testing.T) {
|
||||||
|
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Input: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"name": "lookup",
|
||||||
|
"arguments": `{"q":"x"}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, got.Messages, 1)
|
||||||
|
assert.Equal(t, "assistant", got.Messages[0].Role)
|
||||||
|
assert.Nil(t, got.Messages[0].Content)
|
||||||
|
toolCalls := got.Messages[0].ParseToolCalls()
|
||||||
|
require.Len(t, toolCalls, 1)
|
||||||
|
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsesRequestToChatCompletionsRequestToolsToolChoiceAndTextFormat(t *testing.T) {
|
||||||
|
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Input: mustRawMessage(t, "hello"),
|
||||||
|
Tools: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "lookup",
|
||||||
|
"description": "Lookup data",
|
||||||
|
"parameters": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"q": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ToolChoice: mustRawMessage(t, map[string]any{
|
||||||
|
"type": "function",
|
||||||
|
"name": "lookup",
|
||||||
|
}),
|
||||||
|
Text: mustRawMessage(t, map[string]any{
|
||||||
|
"format": map[string]any{
|
||||||
|
"type": "json_schema",
|
||||||
|
"name": "answer",
|
||||||
|
"schema": map[string]any{"type": "object"},
|
||||||
|
"strict": true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, got.Tools, 1)
|
||||||
|
assert.Equal(t, "function", got.Tools[0].Type)
|
||||||
|
assert.Equal(t, "lookup", got.Tools[0].Function.Name)
|
||||||
|
assert.Equal(t, "Lookup data", got.Tools[0].Function.Description)
|
||||||
|
assert.Equal(t, "object", got.Tools[0].Function.Parameters.(map[string]any)["type"])
|
||||||
|
assert.Equal(t, map[string]any{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]any{
|
||||||
|
"name": "lookup",
|
||||||
|
},
|
||||||
|
}, got.ToolChoice)
|
||||||
|
require.NotNil(t, got.ResponseFormat)
|
||||||
|
assert.Equal(t, "json_schema", got.ResponseFormat.Type)
|
||||||
|
assert.Equal(t, "answer", gjson.GetBytes(got.ResponseFormat.JsonSchema, "name").String())
|
||||||
|
assert.True(t, gjson.GetBytes(got.ResponseFormat.JsonSchema, "strict").Bool())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsesRequestToChatCompletionsRequestCustomToolCallPreservesRawShape(t *testing.T) {
|
||||||
|
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Input: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "custom_tool_call",
|
||||||
|
"call_id": "call_custom",
|
||||||
|
"name": "apply_patch",
|
||||||
|
"input": "patch body",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, got.Messages, 1)
|
||||||
|
toolCalls := got.Messages[0].ParseToolCalls()
|
||||||
|
require.Len(t, toolCalls, 1)
|
||||||
|
assert.Equal(t, dto.CustomType, toolCalls[0].Type)
|
||||||
|
assert.Equal(t, "call_custom", toolCalls[0].ID)
|
||||||
|
assert.Equal(t, "apply_patch", toolCalls[0].Function.Name)
|
||||||
|
assert.Equal(t, "patch body", toolCalls[0].Function.Arguments)
|
||||||
|
assert.Equal(t, "custom_tool_call", gjson.GetBytes(toolCalls[0].Custom, "type").String())
|
||||||
|
assert.Equal(t, "patch body", gjson.GetBytes(toolCalls[0].Custom, "input").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsesRequestToChatCompletionsRequestRejectsStatefulFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req *dto.OpenAIResponsesRequest
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "conversation",
|
||||||
|
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", Conversation: mustRawMessage(t, "conv_1")},
|
||||||
|
want: "conversation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "previous response",
|
||||||
|
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", PreviousResponseID: "resp_1"},
|
||||||
|
want: "previous_response_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prompt",
|
||||||
|
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", Prompt: mustRawMessage(t, map[string]any{"id": "pmpt_1"})},
|
||||||
|
want: "prompt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context management",
|
||||||
|
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", ContextManagement: mustRawMessage(t, map[string]any{"type": "auto"})},
|
||||||
|
want: "context_management",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ResponsesRequestToChatCompletionsRequest(tt.req)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.want)
|
||||||
|
assert.Contains(t, err.Error(), "stateful fields")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustRawMessage(t *testing.T, value any) []byte {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := common.Marshal(value)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
+17
-7
@@ -1,4 +1,4 @@
|
|||||||
package openaicompat
|
package relayconvert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -438,8 +438,7 @@ func (s *ResponsesToChatStreamState) toolArgumentsDelta(event *dto.ResponsesStre
|
|||||||
if tool == nil {
|
if tool == nil {
|
||||||
if event.OutputIndex != nil {
|
if event.OutputIndex != nil {
|
||||||
s.pendingArgsByOutputIndex[*event.OutputIndex] += event.Delta
|
s.pendingArgsByOutputIndex[*event.OutputIndex] += event.Delta
|
||||||
}
|
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
||||||
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
|
||||||
s.pendingArgsByItemID[itemID] += event.Delta
|
s.pendingArgsByItemID[itemID] += event.Delta
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -485,7 +484,7 @@ func (s *ResponsesToChatStreamState) ensureToolForEvent(event *dto.ResponsesStre
|
|||||||
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
|
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
|
if itemID := responseStreamEventItemID(event); itemID != "" {
|
||||||
tool.ItemID = itemID
|
tool.ItemID = itemID
|
||||||
s.itemIDToKey[itemID] = key
|
s.itemIDToKey[itemID] = key
|
||||||
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
|
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
|
||||||
@@ -558,7 +557,7 @@ func (s *ResponsesToChatStreamState) ensureFallbackToolForEvent(event *dto.Respo
|
|||||||
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
|
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
if itemID := responseStreamEventItemID(event); itemID != "" {
|
||||||
tool.ItemID = itemID
|
tool.ItemID = itemID
|
||||||
s.itemIDToKey[itemID] = key
|
s.itemIDToKey[itemID] = key
|
||||||
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
|
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
|
||||||
@@ -789,8 +788,7 @@ func (a *ResponsesBufferedAccumulator) ProcessEvent(event *dto.ResponsesStreamRe
|
|||||||
}
|
}
|
||||||
if event.OutputIndex != nil {
|
if event.OutputIndex != nil {
|
||||||
a.pendingByOutputIndex[*event.OutputIndex] += event.Delta
|
a.pendingByOutputIndex[*event.OutputIndex] += event.Delta
|
||||||
}
|
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
||||||
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
|
||||||
a.pendingByItemID[itemID] += event.Delta
|
a.pendingByItemID[itemID] += event.Delta
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -928,6 +926,18 @@ func isResponsesToolOutputType(outputType string) bool {
|
|||||||
return outputType == responsesOutputTypeFunctionCall || outputType == responsesOutputTypeCustomToolCall
|
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 {
|
func fallbackToolKey(itemID string, callID string, outputIndex *int) string {
|
||||||
if outputIndex != nil {
|
if outputIndex != nil {
|
||||||
return fmt.Sprintf("output:%d", *outputIndex)
|
return fmt.Sprintf("output:%d", *outputIndex)
|
||||||
@@ -43,6 +43,10 @@ export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
|
|||||||
value: 'openai_chat_completions_to_openai_responses',
|
value: 'openai_chat_completions_to_openai_responses',
|
||||||
label: 'OpenAI Chat 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',
|
value: 'gemini_generate_content_to_openai_chat_completions',
|
||||||
label: 'Gemini Generate Content to OpenAI Chat',
|
label: 'Gemini Generate Content to OpenAI Chat',
|
||||||
@@ -326,6 +330,9 @@ export function getAdvancedCustomUpstreamPathPlaceholder(
|
|||||||
if (converter === 'openai_chat_completions_to_anthropic_messages') {
|
if (converter === 'openai_chat_completions_to_anthropic_messages') {
|
||||||
return '/v1/messages'
|
return '/v1/messages'
|
||||||
}
|
}
|
||||||
|
if (converter === 'openai_responses_to_openai_chat_completions') {
|
||||||
|
return '/v1/chat/completions'
|
||||||
|
}
|
||||||
return '/v1/chat/completions'
|
return '/v1/chat/completions'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,6 +580,9 @@ function isConverterPathAllowed(
|
|||||||
) {
|
) {
|
||||||
return incomingPath === '/v1/chat/completions'
|
return incomingPath === '/v1/chat/completions'
|
||||||
}
|
}
|
||||||
|
if (converter === 'openai_responses_to_openai_chat_completions') {
|
||||||
|
return incomingPath === '/v1/responses'
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
incomingPath.includes(':generateContent') ||
|
incomingPath.includes(':generateContent') ||
|
||||||
incomingPath.includes(':streamGenerateContent')
|
incomingPath.includes(':streamGenerateContent')
|
||||||
|
|||||||
+1
@@ -131,6 +131,7 @@ export type AdvancedCustomConverter =
|
|||||||
| 'anthropic_messages_to_openai_chat_completions'
|
| 'anthropic_messages_to_openai_chat_completions'
|
||||||
| 'openai_chat_completions_to_anthropic_messages'
|
| 'openai_chat_completions_to_anthropic_messages'
|
||||||
| 'openai_chat_completions_to_openai_responses'
|
| 'openai_chat_completions_to_openai_responses'
|
||||||
|
| 'openai_responses_to_openai_chat_completions'
|
||||||
| 'gemini_generate_content_to_openai_chat_completions'
|
| 'gemini_generate_content_to_openai_chat_completions'
|
||||||
| 'openai_chat_completions_to_gemini_generate_content'
|
| 'openai_chat_completions_to_gemini_generate_content'
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-1
@@ -2995,6 +2995,7 @@
|
|||||||
"OpenAI Rerank": "OpenAI Rerank",
|
"OpenAI Rerank": "OpenAI Rerank",
|
||||||
"OpenAI Responses": "OpenAI Responses",
|
"OpenAI Responses": "OpenAI Responses",
|
||||||
"OpenAI Responses Compact": "OpenAI Responses Compact",
|
"OpenAI Responses Compact": "OpenAI Responses Compact",
|
||||||
|
"OpenAI Responses to OpenAI Chat": "OpenAI Responses to OpenAI Chat",
|
||||||
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.",
|
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.",
|
||||||
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.",
|
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.",
|
||||||
"OpenAIMax": "OpenAIMax",
|
"OpenAIMax": "OpenAIMax",
|
||||||
@@ -3675,8 +3676,8 @@
|
|||||||
"Resetting...": "Resetting...",
|
"Resetting...": "Resetting...",
|
||||||
"Resolve Conflicts": "Resolve Conflicts",
|
"Resolve Conflicts": "Resolve Conflicts",
|
||||||
"Resource Configuration": "Resource Configuration",
|
"Resource Configuration": "Resource Configuration",
|
||||||
"Responding...": "Responding...",
|
|
||||||
"Resources": "Resources",
|
"Resources": "Resources",
|
||||||
|
"Responding...": "Responding...",
|
||||||
"Response": "Response",
|
"Response": "Response",
|
||||||
"Response Time": "Response Time",
|
"Response Time": "Response Time",
|
||||||
"Response time: {{duration}}": "Response time: {{duration}}",
|
"Response time: {{duration}}": "Response time: {{duration}}",
|
||||||
|
|||||||
Vendored
+2
-1
@@ -2995,6 +2995,7 @@
|
|||||||
"OpenAI Rerank": "OpenAI Rerank",
|
"OpenAI Rerank": "OpenAI Rerank",
|
||||||
"OpenAI Responses": "OpenAI Responses",
|
"OpenAI Responses": "OpenAI Responses",
|
||||||
"OpenAI Responses Compact": "OpenAI Responses Compact",
|
"OpenAI Responses Compact": "OpenAI Responses Compact",
|
||||||
|
"OpenAI Responses to OpenAI Chat": "OpenAI Responses vers OpenAI Chat",
|
||||||
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.",
|
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.",
|
||||||
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.",
|
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.",
|
||||||
"OpenAIMax": "OpenAIMax",
|
"OpenAIMax": "OpenAIMax",
|
||||||
@@ -3675,8 +3676,8 @@
|
|||||||
"Resetting...": "Réinitialisation...",
|
"Resetting...": "Réinitialisation...",
|
||||||
"Resolve Conflicts": "Résoudre les conflits",
|
"Resolve Conflicts": "Résoudre les conflits",
|
||||||
"Resource Configuration": "Configuration des ressources",
|
"Resource Configuration": "Configuration des ressources",
|
||||||
"Responding...": "Réponse en cours...",
|
|
||||||
"Resources": "Ressources",
|
"Resources": "Ressources",
|
||||||
|
"Responding...": "Réponse en cours...",
|
||||||
"Response": "Réponse",
|
"Response": "Réponse",
|
||||||
"Response Time": "Temps de réponse",
|
"Response Time": "Temps de réponse",
|
||||||
"Response time: {{duration}}": "Temps de réponse : {{duration}}",
|
"Response time: {{duration}}": "Temps de réponse : {{duration}}",
|
||||||
|
|||||||
Vendored
+2
-1
@@ -2995,6 +2995,7 @@
|
|||||||
"OpenAI Rerank": "OpenAI 再ランク付け",
|
"OpenAI Rerank": "OpenAI 再ランク付け",
|
||||||
"OpenAI Responses": "OpenAI レスポンス",
|
"OpenAI Responses": "OpenAI レスポンス",
|
||||||
"OpenAI Responses Compact": "OpenAI レスポンス圧縮",
|
"OpenAI Responses Compact": "OpenAI レスポンス圧縮",
|
||||||
|
"OpenAI Responses to OpenAI Chat": "OpenAI Responses から OpenAI Chat",
|
||||||
"OpenAI, Anthropic, etc.": "OpenAI、Anthropicなど",
|
"OpenAI, Anthropic, etc.": "OpenAI、Anthropicなど",
|
||||||
"OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Googleなど",
|
"OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Googleなど",
|
||||||
"OpenAIMax": "OpenAIMax",
|
"OpenAIMax": "OpenAIMax",
|
||||||
@@ -3675,8 +3676,8 @@
|
|||||||
"Resetting...": "リセット中...",
|
"Resetting...": "リセット中...",
|
||||||
"Resolve Conflicts": "競合を解決",
|
"Resolve Conflicts": "競合を解決",
|
||||||
"Resource Configuration": "リソース設定",
|
"Resource Configuration": "リソース設定",
|
||||||
"Responding...": "応答中...",
|
|
||||||
"Resources": "リソース",
|
"Resources": "リソース",
|
||||||
|
"Responding...": "応答中...",
|
||||||
"Response": "レスポンス",
|
"Response": "レスポンス",
|
||||||
"Response Time": "応答時間",
|
"Response Time": "応答時間",
|
||||||
"Response time: {{duration}}": "応答時間: {{duration}}",
|
"Response time: {{duration}}": "応答時間: {{duration}}",
|
||||||
|
|||||||
Vendored
+2
-1
@@ -2995,6 +2995,7 @@
|
|||||||
"OpenAI Rerank": "Реранжирование OpenAI",
|
"OpenAI Rerank": "Реранжирование OpenAI",
|
||||||
"OpenAI Responses": "Ответы OpenAI",
|
"OpenAI Responses": "Ответы OpenAI",
|
||||||
"OpenAI Responses Compact": "Компактные ответы OpenAI",
|
"OpenAI Responses Compact": "Компактные ответы OpenAI",
|
||||||
|
"OpenAI Responses to OpenAI Chat": "OpenAI Responses в OpenAI Chat",
|
||||||
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic и т.д.",
|
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic и т.д.",
|
||||||
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google и т.д.",
|
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google и т.д.",
|
||||||
"OpenAIMax": "OpenAIMax",
|
"OpenAIMax": "OpenAIMax",
|
||||||
@@ -3675,8 +3676,8 @@
|
|||||||
"Resetting...": "Сброс...",
|
"Resetting...": "Сброс...",
|
||||||
"Resolve Conflicts": "Разрешить конфликты",
|
"Resolve Conflicts": "Разрешить конфликты",
|
||||||
"Resource Configuration": "Конфигурация ресурсов",
|
"Resource Configuration": "Конфигурация ресурсов",
|
||||||
"Responding...": "Отвечаем...",
|
|
||||||
"Resources": "Ресурсы",
|
"Resources": "Ресурсы",
|
||||||
|
"Responding...": "Отвечаем...",
|
||||||
"Response": "Ответ",
|
"Response": "Ответ",
|
||||||
"Response Time": "Время ответа",
|
"Response Time": "Время ответа",
|
||||||
"Response time: {{duration}}": "Время ответа: {{duration}}",
|
"Response time: {{duration}}": "Время ответа: {{duration}}",
|
||||||
|
|||||||
Vendored
+2
-1
@@ -2995,6 +2995,7 @@
|
|||||||
"OpenAI Rerank": "OpenAI Rerank",
|
"OpenAI Rerank": "OpenAI Rerank",
|
||||||
"OpenAI Responses": "OpenAI Responses",
|
"OpenAI Responses": "OpenAI Responses",
|
||||||
"OpenAI Responses Compact": "OpenAI Responses Compact",
|
"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, etc.": "OpenAI, Anthropic, v.v.",
|
||||||
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, v.v.",
|
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, v.v.",
|
||||||
"OpenAIMax": "OpenAIMax",
|
"OpenAIMax": "OpenAIMax",
|
||||||
@@ -3675,8 +3676,8 @@
|
|||||||
"Resetting...": "Đang đặt lại...",
|
"Resetting...": "Đang đặt lại...",
|
||||||
"Resolve Conflicts": "Giải quyết Xung đột",
|
"Resolve Conflicts": "Giải quyết Xung đột",
|
||||||
"Resource Configuration": "Cấu hình tài nguyên",
|
"Resource Configuration": "Cấu hình tài nguyên",
|
||||||
"Responding...": "Đang phản hồi...",
|
|
||||||
"Resources": "Tài nguyên",
|
"Resources": "Tài nguyên",
|
||||||
|
"Responding...": "Đang phản hồi...",
|
||||||
"Response": "Phản hồi",
|
"Response": "Phản hồi",
|
||||||
"Response Time": "Thời gian phản hồi",
|
"Response Time": "Thời gian phản hồi",
|
||||||
"Response time: {{duration}}": "Thời gian phản hồi: {{duration}}",
|
"Response time: {{duration}}": "Thời gian phản hồi: {{duration}}",
|
||||||
|
|||||||
Vendored
+2
-1
@@ -2995,6 +2995,7 @@
|
|||||||
"OpenAI Rerank": "OpenAI 重排序",
|
"OpenAI Rerank": "OpenAI 重排序",
|
||||||
"OpenAI Responses": "OpenAI 响应",
|
"OpenAI Responses": "OpenAI 响应",
|
||||||
"OpenAI Responses Compact": "OpenAI 响应压缩",
|
"OpenAI Responses Compact": "OpenAI 响应压缩",
|
||||||
|
"OpenAI Responses to OpenAI Chat": "OpenAI Responses 到 OpenAI Chat",
|
||||||
"OpenAI, Anthropic, etc.": "OpenAI、Anthropic 等",
|
"OpenAI, Anthropic, etc.": "OpenAI、Anthropic 等",
|
||||||
"OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Google 等",
|
"OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Google 等",
|
||||||
"OpenAIMax": "OpenAIMax",
|
"OpenAIMax": "OpenAIMax",
|
||||||
@@ -3675,8 +3676,8 @@
|
|||||||
"Resetting...": "重置中...",
|
"Resetting...": "重置中...",
|
||||||
"Resolve Conflicts": "解决冲突",
|
"Resolve Conflicts": "解决冲突",
|
||||||
"Resource Configuration": "资源配置",
|
"Resource Configuration": "资源配置",
|
||||||
"Responding...": "正在回复...",
|
|
||||||
"Resources": "资源",
|
"Resources": "资源",
|
||||||
|
"Responding...": "正在回复...",
|
||||||
"Response": "响应",
|
"Response": "响应",
|
||||||
"Response Time": "响应时间",
|
"Response Time": "响应时间",
|
||||||
"Response time: {{duration}}": "响应时间:{{duration}}",
|
"Response time: {{duration}}": "响应时间:{{duration}}",
|
||||||
|
|||||||
Reference in New Issue
Block a user