Files
new-api/relaykit/relayconvert/internal/oai_responses/to_oai_chat_req.go
T
Scott 66ee6b8f98 fix: preserve Qwen thinking_budget passthrough (#5836)
* fix: preserve qwen thinking budget

* test: address qwen thinking budget review comments

* chore: remove unreachable adaptor code

* test: cover zero Qwen thinking budgets
2026-07-29 17:45:14 +08:00

555 lines
15 KiB
Go

package oairesponses
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const (
responsesInputTypeFunctionCall = "function_call"
responsesInputTypeFunctionCallOutput = "function_call_output"
responsesInputTypeCustomToolCall = "custom_tool_call"
responsesInputTypeCustomToolOutput = "custom_tool_call_output"
)
const (
ResponsesInputTypeFunctionCall = responsesInputTypeFunctionCall
ResponsesInputTypeFunctionCallOutput = responsesInputTypeFunctionCallOutput
ResponsesInputTypeCustomToolCall = responsesInputTypeCustomToolCall
ResponsesInputTypeCustomToolOutput = responsesInputTypeCustomToolOutput
)
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,
ThinkingBudget: req.ThinkingBudget,
}
if req.Reasoning != nil {
out.ReasoningEffort = req.Reasoning.Effort
}
if req.ServiceTier != "" {
out.ServiceTier, _ = kitutil.Marshal(req.ServiceTier)
}
if len(req.ParallelToolCalls) > 0 && kitutil.GetJsonType(req.ParallelToolCalls) == "boolean" {
var parallelToolCalls bool
if err := kitutil.Unmarshal(req.ParallelToolCalls, &parallelToolCalls); err == nil {
out.ParallelTooCalls = &parallelToolCalls
}
}
if len(req.PromptCacheKey) > 0 && kitutil.GetJsonType(req.PromptCacheKey) == "string" {
var promptCacheKey string
if err := kitutil.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 ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
return validateResponsesRequestChatUnsupportedFields(req)
}
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 kitutil.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 := kitutil.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", kitutil.GetJsonType(req.Input))
}
}
func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) {
itemType := strings.TrimSpace(kitutil.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(kitutil.Interface2String(item["call_id"]))
content := responseToolOutputToChatContent(item["output"])
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil
}
role := strings.TrimSpace(kitutil.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(kitutil.Interface2String(part["type"]))
switch partType {
case "input_text", "output_text", "text":
text := kitutil.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(kitutil.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 := kitutil.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(kitutil.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, _ := kitutil.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 := kitutil.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(kitutil.Interface2String(tool["type"]))
if toolType == "function" {
out = append(out, dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["parameters"],
},
})
continue
}
rawTool, err := kitutil.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 kitutil.GetJsonType(raw) == "string" {
var choice string
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid tool_choice: %w", err)
}
return choice, nil
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid tool_choice: %w", err)
}
if kitutil.Interface2String(choice["type"]) == "function" {
name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
if name != "" {
return map[string]any{
"type": "function",
"function": map[string]any{
"name": name,
},
}, nil
}
}
return choice, nil
}
func RequestToolChoiceToChat(raw json.RawMessage) (any, error) {
return responsesRequestToolChoiceToChat(raw)
}
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
var textConfig map[string]any
if err := kitutil.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(kitutil.Interface2String(format["type"]))
if formatType == "" {
return nil, nil
}
out := &dto.ResponseFormat{Type: formatType}
if formatType == "json_schema" {
schemaRaw, err := kitutil.Marshal(format)
if err != nil {
return nil, err
}
out.JsonSchema = schemaRaw
}
return out, nil
}
func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
return responsesRequestTextToChatResponseFormat(raw)
}
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 := kitutil.Interface2String(videoURLMap["url"]); url != "" {
return url
}
}
return videoURL
}
if url := kitutil.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(kitutil.Interface2String(item["call_id"]))
if callID != "" {
return callID
}
return strings.TrimSpace(kitutil.Interface2String(item["id"]))
}
func CallID(item map[string]any) string {
return responsesCallID(item)
}
func responsesArgumentsString(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
default:
raw, err := kitutil.Marshal(v)
if err != nil {
return kitutil.Interface2String(v)
}
return string(raw)
}
}
func responseToolOutputToChatContent(value any) any {
switch v := value.(type) {
case nil:
return ""
case string:
return v
default:
raw, err := kitutil.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(raw)
}
}
func responsesJSONString(raw json.RawMessage) (string, error) {
if kitutil.GetJsonType(raw) != "string" {
return string(raw), nil
}
var value string
if err := kitutil.Unmarshal(raw, &value); err != nil {
return "", err
}
return value, nil
}
func rawJSONPresent(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
return kitutil.GetJsonType(raw) != "null"
}
func JSONString(raw json.RawMessage) (string, error) {
return responsesJSONString(raw)
}
func RawJSONPresent(raw json.RawMessage) bool {
return rawJSONPresent(raw)
}