mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-12 15:21:09 +00:00
feat: support Responses to Chat (#5787)
* fix(openai): harden Chat-to-Responses compatibility Add a shared Responses-to-Chat stream state machine and use it from the OpenAI relay path. Preserve assistant text alongside tool calls, bind tool argument deltas by output_index, map incomplete finish reasons, support reasoning/custom tool events, and buffer upstream SSE for non-stream Chat clients. Add deterministic service tests and relay SSE tests for the conversion path. Related to #5745. * refactor: rename openaicompat to relayconvert for improved clarity * feat(gemini): support responses request conversion * feat: add responses to chat conversion support * fix: harden responses chat conversion edge cases
This commit is contained in:
@@ -0,0 +1,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"
|
||||
}
|
||||
Reference in New Issue
Block a user