feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

@@ -5,12 +5,16 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
)
const (
@@ -38,7 +42,7 @@ func ResponsesRequestToChatCompletionsRequest(ctx context.Context, req *dto.Open
return nil, err
}
messages, err := responsesRequestMessagesToChat(req)
messages, err := responsesRequestMessagesToChat(ctx, req)
if err != nil {
return nil, err
}
@@ -139,7 +143,7 @@ func ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error
return validateResponsesRequestChatUnsupportedFields(req)
}
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
func responsesRequestMessagesToChat(ctx context.Context, req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
messages := make([]dto.Message, 0)
if rawJSONPresent(req.Instructions) {
instructions, err := responsesJSONString(req.Instructions)
@@ -168,12 +172,25 @@ func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Mess
if err := kitutil.Unmarshal(req.Input, &items); err != nil {
return nil, fmt.Errorf("invalid input array: %w", err)
}
// Chat Completions requires the tool messages answering one assistant tool_calls
// batch to stay contiguous, so media hoisted out of tool output items is
// held back and emitted as a single user message once the batch ends.
var pendingMedia []any
for _, item := range items {
nextMessages, err := responsesInputItemToChatMessages(item, messages)
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
if len(pendingMedia) > 0 && itemType != responsesInputTypeFunctionCallOutput && itemType != responsesInputTypeCustomToolOutput {
messages = append(messages, dto.Message{Role: "user", Content: pendingMedia})
pendingMedia = nil
}
nextMessages, media, err := responsesInputItemToChatMessages(ctx, item, messages)
if err != nil {
return nil, err
}
messages = nextMessages
pendingMedia = append(pendingMedia, media...)
}
if len(pendingMedia) > 0 {
messages = append(messages, dto.Message{Role: "user", Content: pendingMedia})
}
return messages, nil
default:
@@ -181,39 +198,42 @@ func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Mess
}
}
func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) {
// responsesInputItemToChatMessages appends the Chat messages for one Responses input item.
// The second result carries media content parts hoisted out of a tool output item,
// already in Chat shape; the caller decides where that user message lands.
func responsesInputItemToChatMessages(ctx context.Context, item map[string]any, messages []dto.Message) ([]dto.Message, []any, error) {
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
switch itemType {
case responsesInputTypeFunctionCall:
toolCall, err := responsesFunctionCallItemToChatToolCall(item)
if err != nil {
return nil, err
return nil, nil, err
}
return appendToolCallToLastAssistant(messages, toolCall), nil
return appendToolCallToLastAssistant(messages, toolCall), nil, nil
case responsesInputTypeCustomToolCall:
toolCall, err := responsesCustomToolCallItemToChatToolCall(item)
if err != nil {
return nil, err
return nil, nil, err
}
return appendToolCallToLastAssistant(messages, toolCall), nil
case responsesInputTypeFunctionCallOutput:
return appendToolCallToLastAssistant(messages, toolCall), nil, nil
case responsesInputTypeFunctionCallOutput, responsesInputTypeCustomToolOutput:
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
content := responseToolOutputToChatContent(item["output"])
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil
content, media := responsesToolOutputToChat(ctx, item["output"])
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), media, nil
}
role := strings.TrimSpace(kitutil.Interface2String(item["role"]))
if role == "" {
role = "user"
}
content, err := responsesInputContentToChatContent(item["content"])
content, err := responsesInputContentToChatContent(ctx, item["content"])
if err != nil {
return nil, err
return nil, nil, err
}
return append(messages, dto.Message{Role: role, Content: content}), nil
return append(messages, dto.Message{Role: role, Content: content}), nil, nil
}
func responsesInputContentToChatContent(content any) (any, error) {
func responsesInputContentToChatContent(ctx context.Context, content any) (any, error) {
if content == nil {
return "", nil
}
@@ -222,22 +242,23 @@ func responsesInputContentToChatContent(content any) (any, error) {
case string:
return value, nil
case []any:
return responsesContentPartsToChatContent(value)
return responsesContentPartsToChatContent(ctx, value)
case []map[string]any:
parts := make([]any, 0, len(value))
for _, part := range value {
parts = append(parts, part)
}
return responsesContentPartsToChatContent(parts)
return responsesContentPartsToChatContent(ctx, parts)
default:
return content, nil
}
}
func responsesContentPartsToChatContent(parts []any) (any, error) {
func responsesContentPartsToChatContent(ctx context.Context, parts []any) (any, error) {
chatParts := make([]any, 0, len(parts))
var textOnly strings.Builder
onlyText := true
droppedFile := false
for _, rawPart := range parts {
part, ok := rawPart.(map[string]any)
@@ -264,10 +285,18 @@ func responsesContentPartsToChatContent(parts []any) (any, error) {
})
case "input_file":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeFile,
"file": responsesFilePartToChatFile(part),
})
chatPart, reason := responsesFilePartToChat(ctx, part)
if reason != "" {
convdiag.Add(ctx, types.ConversionDiagnostic{
Code: "unsupported_media_type",
Path: "input.content",
Message: reason + "; the file was omitted",
Severity: types.ConversionDiagnosticError,
})
droppedFile = true
continue
}
chatParts = append(chatParts, chatPart)
case "input_audio":
onlyText = false
chatParts = append(chatParts, map[string]any{
@@ -289,11 +318,15 @@ func responsesContentPartsToChatContent(parts []any) (any, error) {
if onlyText {
return textOnly.String(), nil
}
if len(chatParts) == 0 && droppedFile {
// Chat rejects an empty content array; name what was omitted instead.
return "[file]", nil
}
return chatParts, nil
}
func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
name := responsesCallName(item)
if name == "" {
return dto.ToolCallRequest{}, errors.New("function_call item is missing name")
}
@@ -307,18 +340,26 @@ func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallR
}, nil
}
// responsesCustomToolCallItemToChatToolCall encodes a custom tool call the same
// way its definition reaches Chat Completions: a function call whose arguments
// carry the raw input under the single "input" key.
func responsesCustomToolCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
raw, err := kitutil.Marshal(item)
name := responsesCallName(item)
if name == "" {
return dto.ToolCallRequest{}, errors.New("custom_tool_call item is missing name")
}
arguments, err := kitutil.Marshal(map[string]string{
convmeta.CustomToolInputArgument: responsesArgumentsString(item["input"]),
})
if err != nil {
return dto.ToolCallRequest{}, err
}
return dto.ToolCallRequest{
ID: responsesCallID(item),
Type: dto.CustomType,
Custom: raw,
ID: responsesCallID(item),
Type: "function",
Function: dto.FunctionRequest{
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
Arguments: responsesArgumentsString(item["input"]),
Name: name,
Arguments: string(arguments),
},
}, nil
}
@@ -443,6 +484,12 @@ func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat,
func responsesImagePartToChatImageURL(part map[string]any) any {
if imageURL, ok := part["image_url"]; ok {
// Chat Completions takes the URL inside an object. detail is left out:
// Chat upstreams that validate it with older OpenAI SDKs reject newer
// values such as "original".
if url, ok := imageURL.(string); ok {
return map[string]any{"url": url}
}
return imageURL
}
imageURL := map[string]any{}
@@ -457,20 +504,52 @@ func responsesImagePartToChatImageURL(part map[string]any) any {
return imageURL
}
func responsesFilePartToChatFile(part map[string]any) any {
if file, ok := part["file"]; ok {
return file
// responsesFilePartToChat maps a Responses input_file onto Chat Completions,
// whose file part takes only PDF data or an uploaded file_id and no file URL.
// Files given by data or URL are loaded through the media resolver: PDF
// becomes a Chat file part with inline data and text files become a text
// part. A non-empty reason means Chat cannot carry the file.
func responsesFilePartToChat(ctx context.Context, part map[string]any) (map[string]any, string) {
fields := part
if nested, ok := part["file"].(map[string]any); ok {
fields = nested
}
file := map[string]any{}
for _, key := range []string{"file_id", "file_data", "filename", "file_url"} {
if value, ok := part[key]; ok {
file[key] = value
filename := strings.TrimSpace(kitutil.Interface2String(fields["filename"]))
source := ContentPartToFileSource(part)
if source == nil {
fileID := strings.TrimSpace(kitutil.Interface2String(fields["file_id"]))
if fileID == "" {
return nil, "input_file carries no file_data, file_url, or file_id"
}
file := map[string]any{"file_id": fileID}
if filename != "" {
file["filename"] = filename
}
return map[string]any{"type": dto.ContentTypeFile, "file": file}, ""
}
if len(file) == 0 {
return part
base64Data, mimeType, err := relaymedia.ResolveBase64Data(ctx, source, "formatting Responses input file for Chat")
if err != nil {
return nil, fmt.Sprintf("input_file could not be loaded: %s", err.Error())
}
return file
mediaType, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(mimeType)), ";")
mediaType = strings.TrimSpace(mediaType)
switch {
case mediaType == "application/pdf":
if filename == "" {
filename = "document.pdf"
}
return map[string]any{"type": dto.ContentTypeFile, "file": map[string]any{
"filename": filename,
"file_data": "data:application/pdf;base64," + base64Data,
}}, ""
case relaymedia.IsTextMimeType(mediaType):
text, ok := relaymedia.DecodeText(base64Data)
if !ok {
return nil, fmt.Sprintf("input_file of MIME type %q could not be decoded as UTF-8 text", mimeType)
}
return map[string]any{"type": dto.ContentTypeText, "text": text}, ""
}
return nil, fmt.Sprintf("OpenAI Chat Completions file parts accept only PDF, not %q", mimeType)
}
func responsesVideoPartToChatVideoURL(part map[string]any) any {
@@ -514,6 +593,17 @@ func CallID(item map[string]any) string {
return responsesCallID(item)
}
// responsesCallName is the upstream function name of a function_call or
// custom_tool_call item: a call made in a tool namespace uses the name its
// flattened definition was sent with.
func responsesCallName(item map[string]any) string {
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
return ""
}
return convmeta.NamespacedToolName(strings.TrimSpace(kitutil.Interface2String(item["namespace"])), name)
}
func responsesArgumentsString(value any) string {
switch v := value.(type) {
case nil:
@@ -544,6 +634,57 @@ func responseToolOutputToChatContent(value any) any {
}
}
// responsesToolOutputToChat maps a function_call_output payload onto Chat Completions, where a
// tool message may only carry text. A Responses content-part array keeps its text on the tool
// message and returns the media parts in Chat shape for the caller to hoist into a user message;
// stringifying them instead would hand base64 image data to the upstream text tokenizer. Any
// other payload shape (string, object, plain JSON array) keeps the historical stringified form.
func responsesToolOutputToChat(ctx context.Context, value any) (any, []any) {
rawParts, ok := value.([]any)
if !ok || len(rawParts) == 0 {
return responseToolOutputToChatContent(value), nil
}
texts := make([]string, 0, len(rawParts))
mediaParts := make([]any, 0, len(rawParts))
labels := make([]string, 0, len(rawParts))
for _, rawPart := range rawParts {
part, ok := rawPart.(map[string]any)
if !ok {
return responseToolOutputToChatContent(value), nil
}
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
switch partType {
case "input_text", "output_text", "text":
if text := kitutil.Interface2String(part["text"]); text != "" {
texts = append(texts, text)
}
case "input_image", "input_file", "input_audio", "input_video":
mediaParts = append(mediaParts, part)
label := "[" + strings.TrimPrefix(partType, "input_") + "]"
if !slices.Contains(labels, label) {
labels = append(labels, label)
}
default:
return responseToolOutputToChatContent(value), nil
}
}
if len(mediaParts) == 0 {
return strings.Join(texts, "\n"), nil
}
converted, err := responsesContentPartsToChatContent(ctx, mediaParts)
if err != nil {
return responseToolOutputToChatContent(value), nil
}
media, _ := converted.([]any)
if len(texts) == 0 {
// Upstreams reject empty tool content; the media itself rides on the hoisted user message.
return strings.Join(labels, " "), media
}
return strings.Join(texts, "\n"), media
}
func responsesRawFloat(raw json.RawMessage) (*float64, error) {
if !rawJSONPresent(raw) {
return nil, nil