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

@@ -2,6 +2,7 @@ package oairesponses
import (
"fmt"
"slices"
"strings"
"context"
@@ -13,21 +14,9 @@ import (
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
)
func convertOpenAIResponsesRequestToGeminiChat(c context.Context, info convmeta.Meta, request any) (any, error) {
responsesRequest, err := OpenAIResponsesRequestFromAny(request)
if err != nil {
return nil, err
}
prepared, err := PrepareOpenAIResponsesRequest(*responsesRequest)
if err != nil {
return nil, err
}
return OpenAIResponsesRequestToGeminiChat(c, &prepared, info)
}
func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIResponsesRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
opts := convmeta.OptionsOf(info)
if req == nil {
@@ -59,7 +48,7 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
if opts.Gemini.SupportsImagineModel(upstreamModelName) {
geminiRequest.GenerationConfig.ResponseModalities = []string{"TEXT", "IMAGE"}
}
if err := applyResponsesTextToGemini(req.Text, geminiRequest); err != nil {
if err := applyResponsesTextToGemini(c, req.Text, geminiRequest, opts.Gemini.SupportsJSONSchemaModel(upstreamModelName)); err != nil {
return nil, err
}
reasoningIntent, diagnostics, err := reasoning.FromOpenAIResponses(req)
@@ -134,12 +123,17 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
if err != nil {
return nil, err
}
// Multimodal function responses (media in functionResponse.parts) are
// documented for Gemini 3. Gemini 1.x and 2.x get tool media as ordinary
// inlineData parts after the function responses of the same turn.
modelID := strings.TrimPrefix(strings.ToLower(upstreamModelName), "models/")
toolMediaInFunctionResponse := !strings.HasPrefix(modelID, "gemini-1.") && !strings.HasPrefix(modelID, "gemini-2.")
callNames := make(map[string]string)
for _, item := range inputItems {
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
switch itemType {
case ResponsesInputTypeFunctionCall:
part, callID, err := responsesFunctionCallItemToGeminiPart(item)
case ResponsesInputTypeFunctionCall, ResponsesInputTypeCustomToolCall:
part, callID, err := responsesFunctionCallItemToGeminiPart(item, itemType)
if err != nil {
return nil, err
}
@@ -148,12 +142,14 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
callNames[callID] = part.FunctionCall.FunctionName
}
appendGeminiContentPart(geminiRequest, "model", part)
case ResponsesInputTypeFunctionCallOutput:
part, err := responsesFunctionOutputItemToGeminiPart(item, callNames)
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
part, media, err := responsesFunctionOutputItemToGeminiPart(c, item, callNames, toolMediaInFunctionResponse)
if err != nil {
return nil, err
}
appendGeminiContentPart(geminiRequest, "user", part)
turn := &geminiRequest.Contents[len(geminiRequest.Contents)-1]
turn.Parts = append(turn.Parts, media...)
default:
role := responsesGeminiRole(item)
parts, err := responsesInputContentToGeminiParts(c, item["content"])
@@ -176,6 +172,11 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
}
}
}
geminiRequest.Contents = omitTrailingAssistantText(c, geminiRequest.Contents,
func(content dto.GeminiChatContent) bool { return content.Role == "user" },
func(content dto.GeminiChatContent) bool {
return content.Role == "model" && !slices.ContainsFunc(content.Parts, func(part dto.GeminiPart) bool { return part.FunctionCall != nil })
})
if len(systemTexts) > 0 {
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
@@ -186,7 +187,7 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
return geminiRequest, nil
}
func applyResponsesTextToGemini(raw []byte, geminiRequest *dto.GeminiChatRequest) error {
func applyResponsesTextToGemini(c context.Context, raw []byte, geminiRequest *dto.GeminiChatRequest, jsonSchema bool) error {
responseFormat, err := RequestTextToChatResponseFormat(raw)
if err != nil {
return err
@@ -200,11 +201,7 @@ func applyResponsesTextToGemini(raw []byte, geminiRequest *dto.GeminiChatRequest
return nil
}
var jsonSchema dto.FormatJsonSchema
if err := kitutil.Unmarshal(responseFormat.JsonSchema, &jsonSchema); err != nil {
return nil
}
geminiRequest.GenerationConfig.ResponseSchema = sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
sharedgemini.ApplyResponseSchema(c, &geminiRequest.GenerationConfig, responseFormat.JsonSchema, "text.format.schema", jsonSchema)
return nil
}
@@ -243,7 +240,7 @@ func responsesContentPartToGeminiParts(c context.Context, part map[string]any) (
if err != nil {
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
}
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
if !sharedgemini.IsSupportedMimeType(mimeType) {
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
}
return []dto.GeminiPart{
@@ -259,58 +256,168 @@ func responsesContentPartToGeminiParts(c context.Context, part map[string]any) (
}
}
func responsesFunctionCallItemToGeminiPart(item map[string]any) (dto.GeminiPart, string, error) {
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
func responsesFunctionCallItemToGeminiPart(item map[string]any, itemType string) (dto.GeminiPart, string, error) {
name := responsesCallName(item)
if name == "" {
return dto.GeminiPart{}, "", fmt.Errorf("function_call item is missing name")
return dto.GeminiPart{}, "", fmt.Errorf("%s item is missing name", itemType)
}
var arguments map[string]any
if itemType == ResponsesInputTypeCustomToolCall {
// The custom tool is declared as a function taking one string
// argument, so its raw input is replayed in that shape.
arguments = map[string]any{convmeta.CustomToolInputArgument: responsesArgumentsString(item["input"])}
} else {
arguments = ObjectValue(item["arguments"], "arguments")
}
callID := CallID(item)
return dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: callID,
FunctionName: name,
Arguments: ObjectValue(item["arguments"], "arguments"),
Arguments: arguments,
},
}, callID, nil
}
func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) (dto.GeminiPart, error) {
// responsesFunctionOutputItemToGeminiPart returns the functionResponse part
// and, unless mediaInParts puts them into functionResponse.parts, the tool
// media the caller sends as separate parts after the turn's function
// responses.
func responsesFunctionOutputItemToGeminiPart(c context.Context, item map[string]any, callNames map[string]string, mediaInParts bool) (dto.GeminiPart, []dto.GeminiPart, error) {
callID := CallID(item)
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
name = callNames[callID]
}
output, media := responsesToolOutputToGemini(c, item["output"], mediaInParts)
response := &dto.GeminiFunctionResponse{
Name: name,
Response: GeminiResponseMap(item["output"]),
Response: output,
}
if mediaInParts && len(media) > 0 {
parts, err := kitutil.Marshal(media)
if err != nil {
return dto.GeminiPart{}, nil, fmt.Errorf("failed to marshal function response parts: %w", err)
}
response.Parts = parts
media = nil
}
if callID != "" {
id, err := kitutil.Marshal(callID)
if err != nil {
return dto.GeminiPart{}, fmt.Errorf("failed to marshal function response ID: %w", err)
return dto.GeminiPart{}, nil, fmt.Errorf("failed to marshal function response ID: %w", err)
}
response.ID = id
}
return dto.GeminiPart{
FunctionResponse: response,
}, nil
}, media, nil
}
// responsesToolOutputToGemini maps a function_call_output payload onto a Gemini
// functionResponse. A Responses content-part array keeps its text in response
// and returns its media as inlineData parts; stringifying them instead would
// hand base64 media to the upstream text tokenizer. Multimodal function
// responses (mediaInParts) carry images and documents only; separate parts
// carry every type Gemini accepts. Media that cannot be loaded or carried is
// omitted with a diagnostic. An array holding any other part type, and every
// other payload shape, keeps the historical response map.
func responsesToolOutputToGemini(c context.Context, value any, mediaInParts bool) (map[string]any, []dto.GeminiPart) {
rawParts, ok := value.([]any)
if !ok || len(rawParts) == 0 {
return GeminiResponseMap(value), nil
}
parts := make([]map[string]any, 0, len(rawParts))
for _, rawPart := range rawParts {
part, isMap := rawPart.(map[string]any)
if !isMap {
return GeminiResponseMap(value), nil
}
switch strings.TrimSpace(kitutil.Interface2String(part["type"])) {
case "input_text", "output_text", "text", "input_image", "input_file", "input_audio", "input_video":
default:
return GeminiResponseMap(value), nil
}
parts = append(parts, part)
}
texts := make([]string, 0, len(parts))
labels := make([]string, 0, len(parts))
media := make([]dto.GeminiPart, 0, len(parts))
for _, part := range parts {
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)
}
continue
}
kind := strings.TrimPrefix(partType, "input_")
if label := "[" + kind + "]"; !slices.Contains(labels, label) {
labels = append(labels, label)
}
source := ContentPartToFileSource(part)
if source == nil {
continue
}
// The documented MIME list for multimodal function responses has no
// audio or video, and Gemini 3 ignores audio sent there.
reason := fmt.Sprintf("Gemini function responses cannot carry %s content", kind)
if !mediaInParts || kind == "image" || kind == "file" {
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses tool output for Gemini")
topLevelType, _, _ := strings.Cut(strings.ToLower(mimeType), "/")
switch {
case err != nil:
reason = fmt.Sprintf("the %s could not be loaded", kind)
case sharedgemini.IsSupportedMimeType(mimeType) && (!mediaInParts || topLevelType != "audio" && topLevelType != "video"):
media = append(media, dto.GeminiPart{InlineData: &dto.GeminiInlineData{MimeType: mimeType, Data: base64Data}})
continue
default:
reason = fmt.Sprintf("Gemini function responses cannot carry %s content", mimeType)
}
}
convdiag.Add(c, types.ConversionDiagnostic{
Code: "unsupported_media_type",
Path: "input.output",
Message: reason + "; the part was omitted",
Severity: types.ConversionDiagnosticError,
})
}
switch {
case len(texts) > 0:
return GeminiResponseMap(strings.Join(texts, "\n")), media
case len(media) > 0:
return map[string]any{}, media
default:
// Every media part was omitted; name what was there instead of
// sending the payload as text.
return GeminiResponseMap(strings.Join(labels, " ")), nil
}
}
func appendGeminiContentPart(req *dto.GeminiChatRequest, role string, part dto.GeminiPart) {
if len(req.Contents) > 0 && req.Contents[len(req.Contents)-1].Role == role {
if role == "model" && part.FunctionCall != nil {
parts := req.Contents[len(req.Contents)-1].Parts
insertAt := 0
parts := req.Contents[len(req.Contents)-1].Parts
insertAt := len(parts)
switch {
case role == "model" && part.FunctionCall != nil:
insertAt = 0
for insertAt < len(parts) && parts[insertAt].FunctionCall != nil {
insertAt++
}
parts = append(parts, dto.GeminiPart{})
copy(parts[insertAt+1:], parts[insertAt:])
parts[insertAt] = part
req.Contents[len(req.Contents)-1].Parts = parts
return
case part.FunctionResponse != nil:
// Tool media sent as separate parts stay after every function
// response of the turn, so a later response goes before them.
for i := len(parts) - 1; i >= 0; i-- {
if parts[i].FunctionResponse != nil {
insertAt = i + 1
break
}
}
}
req.Contents[len(req.Contents)-1].Parts = append(req.Contents[len(req.Contents)-1].Parts, part)
req.Contents[len(req.Contents)-1].Parts = slices.Insert(parts, insertAt, part)
return
}
req.Contents = append(req.Contents, dto.GeminiChatContent{