Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
428 lines
11 KiB
Go
428 lines
11 KiB
Go
package oaichat
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
|
|
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
|
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
func normalizeChatImageURLToString(v any) any {
|
|
switch vv := v.(type) {
|
|
case string:
|
|
return vv
|
|
case map[string]any:
|
|
if url := kitutil.Interface2String(vv["url"]); url != "" {
|
|
return url
|
|
}
|
|
return v
|
|
case dto.MessageImageUrl:
|
|
if vv.Url != "" {
|
|
return vv.Url
|
|
}
|
|
return v
|
|
case *dto.MessageImageUrl:
|
|
if vv != nil && vv.Url != "" {
|
|
return vv.Url
|
|
}
|
|
return v
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
func convertChatResponseFormatToResponsesText(reqFormat *dto.ResponseFormat) json.RawMessage {
|
|
if reqFormat == nil || strings.TrimSpace(reqFormat.Type) == "" {
|
|
return nil
|
|
}
|
|
|
|
format := map[string]any{
|
|
"type": reqFormat.Type,
|
|
}
|
|
|
|
if reqFormat.Type == "json_schema" && len(reqFormat.JsonSchema) > 0 {
|
|
var chatSchema map[string]any
|
|
if err := kitutil.Unmarshal(reqFormat.JsonSchema, &chatSchema); err == nil {
|
|
for key, value := range chatSchema {
|
|
if key == "type" {
|
|
continue
|
|
}
|
|
format[key] = value
|
|
}
|
|
|
|
if nested, ok := format["json_schema"].(map[string]any); ok {
|
|
for key, value := range nested {
|
|
if _, exists := format[key]; !exists {
|
|
format[key] = value
|
|
}
|
|
}
|
|
delete(format, "json_schema")
|
|
}
|
|
} else {
|
|
format["json_schema"] = reqFormat.JsonSchema
|
|
}
|
|
}
|
|
|
|
textRaw, _ := kitutil.Marshal(map[string]any{
|
|
"format": format,
|
|
})
|
|
return textRaw
|
|
}
|
|
|
|
func ChatCompletionsRequestToResponsesRequest(ctx context.Context, req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
|
|
if req == nil {
|
|
return nil, errors.New("request is nil")
|
|
}
|
|
if req.Model == "" {
|
|
return nil, errors.New("model is required")
|
|
}
|
|
if lo.FromPtrOr(req.N, 1) > 1 {
|
|
return nil, fmt.Errorf("n>1 is not supported in responses compatibility mode")
|
|
}
|
|
|
|
var instructionsParts []string
|
|
inputItems := make([]map[string]any, 0, len(req.Messages))
|
|
|
|
for _, msg := range req.Messages {
|
|
role := strings.TrimSpace(msg.Role)
|
|
if role == "" {
|
|
continue
|
|
}
|
|
|
|
if role == "tool" || role == "function" {
|
|
callID := strings.TrimSpace(msg.ToolCallId)
|
|
|
|
var output any
|
|
if msg.Content == nil {
|
|
output = ""
|
|
} else if msg.IsStringContent() {
|
|
output = msg.StringContent()
|
|
} else {
|
|
if b, err := kitutil.Marshal(msg.Content); err == nil {
|
|
output = string(b)
|
|
} else {
|
|
output = fmt.Sprintf("%v", msg.Content)
|
|
}
|
|
}
|
|
|
|
if callID == "" {
|
|
inputItems = append(inputItems, map[string]any{
|
|
"role": "user",
|
|
"content": fmt.Sprintf("[tool_output_missing_call_id] %v", output),
|
|
})
|
|
continue
|
|
}
|
|
|
|
inputItems = append(inputItems, map[string]any{
|
|
"type": "function_call_output",
|
|
"call_id": callID,
|
|
"output": output,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Prefer mapping system/developer messages into `instructions`.
|
|
if role == "system" || role == "developer" {
|
|
if msg.Content == nil {
|
|
continue
|
|
}
|
|
if msg.IsStringContent() {
|
|
if s := strings.TrimSpace(msg.StringContent()); s != "" {
|
|
instructionsParts = append(instructionsParts, s)
|
|
}
|
|
continue
|
|
}
|
|
parts := msg.ParseContent()
|
|
var sb strings.Builder
|
|
for _, part := range parts {
|
|
if part.Type == dto.ContentTypeText && strings.TrimSpace(part.Text) != "" {
|
|
if sb.Len() > 0 {
|
|
sb.WriteString("\n")
|
|
}
|
|
sb.WriteString(part.Text)
|
|
}
|
|
}
|
|
if s := strings.TrimSpace(sb.String()); s != "" {
|
|
instructionsParts = append(instructionsParts, s)
|
|
}
|
|
continue
|
|
}
|
|
|
|
item := map[string]any{
|
|
"role": role,
|
|
}
|
|
|
|
if msg.Content == nil {
|
|
item["content"] = ""
|
|
inputItems = append(inputItems, item)
|
|
|
|
if role == "assistant" {
|
|
for _, tc := range msg.ParseToolCalls() {
|
|
if strings.TrimSpace(tc.ID) == "" {
|
|
continue
|
|
}
|
|
if tc.Type != "" && tc.Type != "function" {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(tc.Function.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
inputItems = append(inputItems, map[string]any{
|
|
"type": "function_call",
|
|
"call_id": tc.ID,
|
|
"name": name,
|
|
"arguments": tc.Function.Arguments,
|
|
})
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if msg.IsStringContent() {
|
|
item["content"] = msg.StringContent()
|
|
inputItems = append(inputItems, item)
|
|
|
|
if role == "assistant" {
|
|
for _, tc := range msg.ParseToolCalls() {
|
|
if strings.TrimSpace(tc.ID) == "" {
|
|
continue
|
|
}
|
|
if tc.Type != "" && tc.Type != "function" {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(tc.Function.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
inputItems = append(inputItems, map[string]any{
|
|
"type": "function_call",
|
|
"call_id": tc.ID,
|
|
"name": name,
|
|
"arguments": tc.Function.Arguments,
|
|
})
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
parts := msg.ParseContent()
|
|
contentParts := make([]map[string]any, 0, len(parts))
|
|
for _, part := range parts {
|
|
switch part.Type {
|
|
case dto.ContentTypeText:
|
|
textType := "input_text"
|
|
if role == "assistant" {
|
|
textType = "output_text"
|
|
}
|
|
contentParts = append(contentParts, map[string]any{
|
|
"type": textType,
|
|
"text": part.Text,
|
|
})
|
|
case dto.ContentTypeImageURL:
|
|
contentParts = append(contentParts, map[string]any{
|
|
"type": "input_image",
|
|
"image_url": normalizeChatImageURLToString(part.ImageUrl),
|
|
})
|
|
case dto.ContentTypeInputAudio:
|
|
contentParts = append(contentParts, map[string]any{
|
|
"type": "input_audio",
|
|
"input_audio": part.InputAudio,
|
|
})
|
|
case dto.ContentTypeFile:
|
|
contentParts = append(contentParts, map[string]any{
|
|
"type": "input_file",
|
|
"file": part.File,
|
|
})
|
|
case dto.ContentTypeVideoUrl:
|
|
contentParts = append(contentParts, map[string]any{
|
|
"type": "input_video",
|
|
"video_url": part.VideoUrl,
|
|
})
|
|
default:
|
|
contentParts = append(contentParts, map[string]any{
|
|
"type": part.Type,
|
|
})
|
|
}
|
|
}
|
|
item["content"] = contentParts
|
|
inputItems = append(inputItems, item)
|
|
|
|
if role == "assistant" {
|
|
for _, tc := range msg.ParseToolCalls() {
|
|
if strings.TrimSpace(tc.ID) == "" {
|
|
continue
|
|
}
|
|
if tc.Type != "" && tc.Type != "function" {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(tc.Function.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
inputItems = append(inputItems, map[string]any{
|
|
"type": "function_call",
|
|
"call_id": tc.ID,
|
|
"name": name,
|
|
"arguments": tc.Function.Arguments,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
inputRaw, err := kitutil.Marshal(inputItems)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var instructionsRaw json.RawMessage
|
|
if len(instructionsParts) > 0 {
|
|
instructions := strings.Join(instructionsParts, "\n\n")
|
|
instructionsRaw, _ = kitutil.Marshal(instructions)
|
|
}
|
|
|
|
var toolsRaw json.RawMessage
|
|
if req.Tools != nil {
|
|
tools := make([]map[string]any, 0, len(req.Tools))
|
|
for _, tool := range req.Tools {
|
|
switch tool.Type {
|
|
case "function":
|
|
tools = append(tools, map[string]any{
|
|
"type": "function",
|
|
"name": tool.Function.Name,
|
|
"description": tool.Function.Description,
|
|
"parameters": tool.Function.Parameters,
|
|
})
|
|
default:
|
|
// Best-effort: keep original tool shape for unknown types.
|
|
var m map[string]any
|
|
if b, err := kitutil.Marshal(tool); err == nil {
|
|
_ = kitutil.Unmarshal(b, &m)
|
|
}
|
|
if len(m) == 0 {
|
|
m = map[string]any{"type": tool.Type}
|
|
}
|
|
tools = append(tools, m)
|
|
}
|
|
}
|
|
toolsRaw, _ = kitutil.Marshal(tools)
|
|
}
|
|
|
|
var toolChoiceRaw json.RawMessage
|
|
if req.ToolChoice != nil {
|
|
switch v := req.ToolChoice.(type) {
|
|
case string:
|
|
toolChoiceRaw, _ = kitutil.Marshal(v)
|
|
default:
|
|
var m map[string]any
|
|
if b, err := kitutil.Marshal(v); err == nil {
|
|
_ = kitutil.Unmarshal(b, &m)
|
|
}
|
|
if m == nil {
|
|
toolChoiceRaw, _ = kitutil.Marshal(v)
|
|
} else if t, _ := m["type"].(string); t == "function" {
|
|
// Chat: {"type":"function","function":{"name":"..."}}
|
|
// Responses: {"type":"function","name":"..."}
|
|
if name, ok := m["name"].(string); ok && name != "" {
|
|
toolChoiceRaw, _ = kitutil.Marshal(map[string]any{
|
|
"type": "function",
|
|
"name": name,
|
|
})
|
|
} else if fn, ok := m["function"].(map[string]any); ok {
|
|
if name, ok := fn["name"].(string); ok && name != "" {
|
|
toolChoiceRaw, _ = kitutil.Marshal(map[string]any{
|
|
"type": "function",
|
|
"name": name,
|
|
})
|
|
} else {
|
|
toolChoiceRaw, _ = kitutil.Marshal(v)
|
|
}
|
|
} else {
|
|
toolChoiceRaw, _ = kitutil.Marshal(v)
|
|
}
|
|
} else {
|
|
toolChoiceRaw, _ = kitutil.Marshal(v)
|
|
}
|
|
}
|
|
}
|
|
|
|
var parallelToolCallsRaw json.RawMessage
|
|
if req.ParallelTooCalls != nil {
|
|
parallelToolCallsRaw, _ = kitutil.Marshal(*req.ParallelTooCalls)
|
|
}
|
|
|
|
textRaw := convertChatResponseFormatToResponsesText(req.ResponseFormat)
|
|
|
|
maxOutputTokens := lo.FromPtrOr(req.MaxTokens, uint(0))
|
|
if req.MaxCompletionTokens != nil {
|
|
maxOutputTokens = *req.MaxCompletionTokens
|
|
}
|
|
// OpenAI Responses API rejects max_output_tokens < 16 when explicitly provided.
|
|
//if maxOutputTokens > 0 && maxOutputTokens < 16 {
|
|
// maxOutputTokens = 16
|
|
//}
|
|
|
|
var topP *float64
|
|
if req.TopP != nil {
|
|
topP = kitutil.GetPointer(lo.FromPtr(req.TopP))
|
|
}
|
|
|
|
var frequencyPenaltyRaw, presencePenaltyRaw json.RawMessage
|
|
if req.FrequencyPenalty != nil {
|
|
frequencyPenaltyRaw, _ = kitutil.Marshal(req.FrequencyPenalty)
|
|
}
|
|
if req.PresencePenalty != nil {
|
|
presencePenaltyRaw, _ = kitutil.Marshal(req.PresencePenalty)
|
|
}
|
|
|
|
var promptCacheKeyRaw json.RawMessage
|
|
if req.PromptCacheKey != "" {
|
|
promptCacheKeyRaw, err = kitutil.Marshal(req.PromptCacheKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal prompt_cache_key: %w", err)
|
|
}
|
|
}
|
|
|
|
out := &dto.OpenAIResponsesRequest{
|
|
Model: req.Model,
|
|
Input: inputRaw,
|
|
Instructions: instructionsRaw,
|
|
Stream: req.Stream,
|
|
Temperature: req.Temperature,
|
|
Text: textRaw,
|
|
ToolChoice: toolChoiceRaw,
|
|
Tools: toolsRaw,
|
|
TopP: topP,
|
|
FrequencyPenalty: frequencyPenaltyRaw,
|
|
PresencePenalty: presencePenaltyRaw,
|
|
User: req.User,
|
|
ParallelToolCalls: parallelToolCallsRaw,
|
|
Store: req.Store,
|
|
Metadata: req.Metadata,
|
|
PromptCacheKey: promptCacheKeyRaw,
|
|
EnableThinking: req.EnableThinking,
|
|
ThinkingBudget: req.ThinkingBudget,
|
|
}
|
|
if req.MaxTokens != nil || req.MaxCompletionTokens != nil {
|
|
out.MaxOutputTokens = lo.ToPtr(maxOutputTokens)
|
|
}
|
|
|
|
reasoningIntent, diagnostics, err := reasoning.FromOpenAIChat(req)
|
|
if err != nil {
|
|
return nil, reasoning.AsClientError(err)
|
|
}
|
|
convdiag.Add(ctx, diagnostics...)
|
|
if err := reasoning.ApplyToOpenAIResponses(out, reasoningIntent); err != nil {
|
|
return nil, reasoning.AsClientError(err)
|
|
}
|
|
|
|
return out, nil
|
|
}
|