feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -1,10 +1,12 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/types"
|
||||
)
|
||||
@@ -58,6 +60,29 @@ func InputItems(raw []byte) ([]map[string]any, error) {
|
||||
return responsesInputItems(raw)
|
||||
}
|
||||
|
||||
// omitTrailingAssistantText drops the assistant turns without tool calls that
|
||||
// end the converted conversation after a user turn or tool result. In
|
||||
// Responses they are history and the model answers with a new item; Claude and
|
||||
// Gemini would continue them as a prefill, which newer models reject with a
|
||||
// 400. Codex leaves them at the end of input when it retries a stream that
|
||||
// failed after an assistant message completed.
|
||||
func omitTrailingAssistantText[T any](c context.Context, turns []T, isUser, isAssistantText func(T) bool) []T {
|
||||
end := len(turns)
|
||||
for end > 0 && isAssistantText(turns[end-1]) {
|
||||
end--
|
||||
}
|
||||
if end == len(turns) || end == 0 || !isUser(turns[end-1]) {
|
||||
return turns
|
||||
}
|
||||
convdiag.Add(c, types.ConversionDiagnostic{
|
||||
Code: "trailing_assistant_omitted",
|
||||
Path: "input",
|
||||
Message: "assistant output after the last user message or tool result was omitted; the target protocol would continue it as a prefill",
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
})
|
||||
return turns[:end]
|
||||
}
|
||||
|
||||
func responsesContentParts(content any) ([]map[string]any, error) {
|
||||
switch typed := content.(type) {
|
||||
case nil:
|
||||
|
||||
@@ -3,6 +3,7 @@ package oairesponses
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
|
||||
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 convertOpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
@@ -94,11 +96,26 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
|
||||
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case ResponsesInputTypeFunctionCall:
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "arguments"))
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item))
|
||||
case ResponsesInputTypeCustomToolCall:
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "input"))
|
||||
name := responsesCallName(item)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("custom_tool_call item is missing name")
|
||||
}
|
||||
// The custom tool is declared as a function taking one string
|
||||
// argument, so its raw input is replayed in that shape.
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, dto.ClaudeMediaMessage{
|
||||
Type: "tool_use",
|
||||
Id: CallID(item),
|
||||
Name: name,
|
||||
Input: map[string]any{convmeta.CustomToolInputArgument: responsesArgumentsString(item["input"])},
|
||||
})
|
||||
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
|
||||
claudeRequest.Messages = appendClaudeToolResult(claudeRequest.Messages, responsesFunctionOutputItemToClaudeToolResult(item))
|
||||
claudeRequest.Messages = appendClaudeToolResult(claudeRequest.Messages, dto.ClaudeMediaMessage{
|
||||
Type: "tool_result",
|
||||
ToolUseId: CallID(item),
|
||||
Content: responsesToolOutputToClaude(c, item["output"]),
|
||||
})
|
||||
default:
|
||||
sourceRole := strings.TrimSpace(kitutil.Interface2String(item["role"]))
|
||||
role := responsesClaudeRole(sourceRole)
|
||||
@@ -131,6 +148,11 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
|
||||
})
|
||||
}
|
||||
}
|
||||
claudeRequest.Messages = omitTrailingAssistantText(c, claudeRequest.Messages,
|
||||
func(message dto.ClaudeMessage) bool { return message.Role == "user" },
|
||||
func(message dto.ClaudeMessage) bool {
|
||||
return message.Role == "assistant" && !slices.ContainsFunc(claudeMessageContentParts(message.Content), func(part dto.ClaudeMediaMessage) bool { return part.Type == "tool_use" })
|
||||
})
|
||||
|
||||
if len(systemMessages) > 0 {
|
||||
claudeRequest.System = systemMessages
|
||||
@@ -181,50 +203,81 @@ func responsesInputContentToClaudeMediaMessages(c context.Context, content any)
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input for Claude")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
||||
// Claude content blocks carry images, PDF documents, and text
|
||||
// documents; audio and video have no Claude block.
|
||||
reason := fmt.Sprintf("Claude Messages cannot carry %s content", partType)
|
||||
var block dto.ClaudeMediaMessage
|
||||
if partType == "input_image" || partType == "input_file" {
|
||||
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input for Claude")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
||||
}
|
||||
block, reason = sharedclaude.MediaBlock(base64Data, mimeType, partType == "input_image")
|
||||
}
|
||||
claudePart := dto.ClaudeMediaMessage{
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
MediaType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
if reason != "" {
|
||||
convdiag.Add(c, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_type",
|
||||
Path: "input.content",
|
||||
Message: reason + "; the part was omitted",
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "application/pdf") {
|
||||
claudePart.Type = "document"
|
||||
} else {
|
||||
claudePart.Type = "image"
|
||||
}
|
||||
parts = append(parts, claudePart)
|
||||
parts = append(parts, block)
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func responsesFunctionCallItemToClaudeToolUse(item map[string]any, inputKey string) dto.ClaudeMediaMessage {
|
||||
func responsesFunctionCallItemToClaudeToolUse(item map[string]any) dto.ClaudeMediaMessage {
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "tool_use",
|
||||
Id: CallID(item),
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
|
||||
Input: ObjectValue(item[inputKey], inputKey),
|
||||
Name: responsesCallName(item),
|
||||
Input: ObjectValue(item["arguments"], "arguments"),
|
||||
}
|
||||
}
|
||||
|
||||
func responsesFunctionOutputItemToClaudeToolResult(item map[string]any) dto.ClaudeMediaMessage {
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "tool_result",
|
||||
ToolUseId: CallID(item),
|
||||
Content: responsesToolOutputValue(item["output"]),
|
||||
// responsesToolOutputToClaude maps a function_call_output payload onto Claude
|
||||
// tool_result content. A Responses content-part array becomes the text, image,
|
||||
// and document blocks tool_result accepts; stringifying it instead would hand
|
||||
// base64 media to the upstream text tokenizer. An array holding any other part
|
||||
// type, or media that cannot be resolved, keeps the stringified form, and other
|
||||
// payload shapes pass through unchanged.
|
||||
func responsesToolOutputToClaude(c context.Context, value any) any {
|
||||
rawParts, ok := value.([]any)
|
||||
if !ok || len(rawParts) == 0 {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func responsesToolOutputValue(value any) any {
|
||||
if value == nil {
|
||||
return ""
|
||||
labels := make([]string, 0, len(rawParts))
|
||||
for _, rawPart := range rawParts {
|
||||
part, isMap := rawPart.(map[string]any)
|
||||
if !isMap {
|
||||
return responseToolOutputToChatContent(value)
|
||||
}
|
||||
switch partType := strings.TrimSpace(kitutil.Interface2String(part["type"])); partType {
|
||||
case "input_text", "output_text", "text":
|
||||
case "input_image", "input_file", "input_audio", "input_video":
|
||||
if label := "[" + strings.TrimPrefix(partType, "input_") + "]"; !slices.Contains(labels, label) {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
default:
|
||||
return responseToolOutputToChatContent(value)
|
||||
}
|
||||
}
|
||||
return value
|
||||
blocks, err := responsesInputContentToClaudeMediaMessages(c, rawParts)
|
||||
if err != nil {
|
||||
return responseToolOutputToChatContent(value)
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
// Every media part was omitted with a diagnostic; name what was there
|
||||
// instead of sending the payload as text.
|
||||
return strings.Join(labels, " ")
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func appendClaudeToolUse(messages []dto.ClaudeMessage, toolUse dto.ClaudeMediaMessage) []dto.ClaudeMessage {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
)
|
||||
|
||||
const (
|
||||
geminiResponsesInputTypeCustomToolCall = "custom_tool_call"
|
||||
geminiResponsesInputTypeCustomToolCallOutput = "custom_tool_call_output"
|
||||
geminiResponsesInputTypeFunctionCallOutput = "function_call_output"
|
||||
)
|
||||
|
||||
const (
|
||||
ResponsesInputTypeCustomToolCallOutput = geminiResponsesInputTypeCustomToolCallOutput
|
||||
)
|
||||
|
||||
func PrepareOpenAIResponsesRequest(request dto.OpenAIResponsesRequest) (dto.OpenAIResponsesRequest, error) {
|
||||
tools, err := filterGeminiResponsesTools(request.Tools)
|
||||
if err != nil {
|
||||
return request, err
|
||||
}
|
||||
request.Tools = tools
|
||||
|
||||
input, err := filterGeminiResponsesInput(request.Input)
|
||||
if err != nil {
|
||||
return request, err
|
||||
}
|
||||
request.Input = input
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func filterGeminiResponsesTools(raw []byte) ([]byte, error) {
|
||||
if !geminiRawJSONPresent(raw) || kitutil.GetJsonType(raw) != "array" {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
var tools []map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &tools); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
if strings.TrimSpace(kitutil.Interface2String(tool["type"])) != "function" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, tool)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return kitutil.Marshal(filtered)
|
||||
}
|
||||
|
||||
func filterGeminiResponsesInput(raw []byte) ([]byte, error) {
|
||||
if !geminiRawJSONPresent(raw) || kitutil.GetJsonType(raw) != "array" {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
var items []map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
skippedCustomCallIDs := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(kitutil.Interface2String(item["type"])) != geminiResponsesInputTypeCustomToolCall {
|
||||
continue
|
||||
}
|
||||
if callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"])); callID != "" {
|
||||
skippedCustomCallIDs[callID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case geminiResponsesInputTypeCustomToolCall, geminiResponsesInputTypeCustomToolCallOutput:
|
||||
continue
|
||||
case geminiResponsesInputTypeFunctionCallOutput:
|
||||
if _, ok := skippedCustomCallIDs[strings.TrimSpace(kitutil.Interface2String(item["call_id"]))]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
|
||||
return kitutil.Marshal(filtered)
|
||||
}
|
||||
|
||||
func geminiRawJSONPresent(raw []byte) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
return kitutil.GetJsonType(raw) != "null"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -233,29 +233,44 @@ func TestResponsesRequestToChatCompletionsRequestToolsToolChoiceAndTextFormat(t
|
||||
assert.True(t, gjson.GetBytes(got.ResponseFormat.JsonSchema, "strict").Bool())
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestCustomToolCallPreservesRawShape(t *testing.T) {
|
||||
func TestResponsesRequestToChatCompletionsRequestEncodesCustomToolHistoryAsFunction(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_custom",
|
||||
"name": "apply_patch",
|
||||
"input": "patch body",
|
||||
"name": "exec",
|
||||
"input": "echo \"hi\" && ls",
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_custom",
|
||||
"output": "hi",
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 1)
|
||||
require.Len(t, got.Messages, 2)
|
||||
toolCalls := got.Messages[0].ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, dto.CustomType, toolCalls[0].Type)
|
||||
assert.Equal(t, "function", toolCalls[0].Type)
|
||||
assert.Equal(t, "call_custom", toolCalls[0].ID)
|
||||
assert.Equal(t, "apply_patch", toolCalls[0].Function.Name)
|
||||
assert.Equal(t, "patch body", toolCalls[0].Function.Arguments)
|
||||
assert.Equal(t, "custom_tool_call", gjson.GetBytes(toolCalls[0].Custom, "type").String())
|
||||
assert.Equal(t, "patch body", gjson.GetBytes(toolCalls[0].Custom, "input").String())
|
||||
assert.Equal(t, "exec", toolCalls[0].Function.Name)
|
||||
assert.Equal(t, `echo "hi" && ls`, gjson.Get(toolCalls[0].Function.Arguments, "input").String())
|
||||
assert.Empty(t, toolCalls[0].Custom)
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_custom", Content: "hi"}, got.Messages[1])
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestRejectsNamelessCustomToolCall(t *testing.T) {
|
||||
_, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{"type": "custom_tool_call", "call_id": "call_custom", "input": "ls"},
|
||||
}),
|
||||
})
|
||||
require.ErrorContains(t, err, "custom_tool_call item is missing name")
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestRejectsStatefulFields(t *testing.T) {
|
||||
@@ -351,6 +366,178 @@ func TestResponsesRequestToChatCompletionsRequestRejectsMalformedPenalty(t *test
|
||||
assert.Contains(t, err.Error(), "frequency_penalty")
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestToolOutputContentParts(t *testing.T) {
|
||||
const dataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
imagePart := map[string]any{"type": "input_image", "image_url": dataURL}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
output any
|
||||
wantContent string
|
||||
jsonContent bool
|
||||
wantMedia []string
|
||||
}{
|
||||
{
|
||||
name: "text and image keep text on tool message",
|
||||
output: []any{map[string]any{"type": "input_text", "text": "screenshot taken"}, imagePart},
|
||||
wantContent: "screenshot taken",
|
||||
wantMedia: []string{dto.ContentTypeImageURL},
|
||||
},
|
||||
{
|
||||
name: "image only uses placeholder",
|
||||
output: []any{imagePart},
|
||||
wantContent: "[image]",
|
||||
wantMedia: []string{dto.ContentTypeImageURL},
|
||||
},
|
||||
{
|
||||
name: "mixed media dedupes placeholder labels",
|
||||
output: []any{imagePart, map[string]any{"type": "input_file", "file_id": "file_1"}, imagePart},
|
||||
wantContent: "[image] [file]",
|
||||
wantMedia: []string{dto.ContentTypeImageURL, dto.ContentTypeFile, dto.ContentTypeImageURL},
|
||||
},
|
||||
{
|
||||
name: "text parts join with newline",
|
||||
output: []any{
|
||||
map[string]any{"type": "input_text", "text": "first"},
|
||||
map[string]any{"type": "output_text", "text": ""},
|
||||
map[string]any{"type": "text", "text": "second"},
|
||||
},
|
||||
wantContent: "first\nsecond",
|
||||
},
|
||||
{
|
||||
name: "string passes through",
|
||||
output: "done",
|
||||
wantContent: "done",
|
||||
},
|
||||
{
|
||||
name: "object stays json",
|
||||
output: map[string]any{"ok": true},
|
||||
wantContent: `{"ok":true}`,
|
||||
jsonContent: true,
|
||||
},
|
||||
{
|
||||
name: "plain array stays json",
|
||||
output: []any{1, 2},
|
||||
wantContent: `[1,2]`,
|
||||
jsonContent: true,
|
||||
},
|
||||
{
|
||||
name: "unknown part keeps whole array",
|
||||
output: []any{imagePart, map[string]any{"type": "refusal", "refusal": "no"}},
|
||||
wantContent: `[{"type":"input_image","image_url":"` + dataURL + `"},{"type":"refusal","refusal":"no"}]`,
|
||||
jsonContent: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{"type": "function_call", "call_id": "call_1", "name": "view_image", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": tt.output},
|
||||
{"role": "user", "content": "next"},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
wantLen := 3
|
||||
if tt.wantMedia != nil {
|
||||
wantLen = 4
|
||||
}
|
||||
require.Len(t, got.Messages, wantLen)
|
||||
assert.Equal(t, "tool", got.Messages[1].Role)
|
||||
assert.Equal(t, "call_1", got.Messages[1].ToolCallId)
|
||||
if tt.jsonContent {
|
||||
assert.JSONEq(t, tt.wantContent, got.Messages[1].StringContent())
|
||||
} else {
|
||||
assert.Equal(t, tt.wantContent, got.Messages[1].StringContent())
|
||||
}
|
||||
assert.Equal(t, dto.Message{Role: "user", Content: "next"}, got.Messages[wantLen-1])
|
||||
if tt.wantMedia == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "user", got.Messages[2].Role)
|
||||
parts := got.Messages[2].ParseContent()
|
||||
require.Len(t, parts, len(tt.wantMedia))
|
||||
for i, wantType := range tt.wantMedia {
|
||||
assert.Equal(t, wantType, parts[i].Type)
|
||||
}
|
||||
require.NotNil(t, parts[0].GetImageMedia())
|
||||
assert.Equal(t, dataURL, parts[0].GetImageMedia().Url)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestHoistsToolOutputMediaAfterToolBatch(t *testing.T) {
|
||||
const dataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
imageOutput := []any{map[string]any{"type": "input_image", "image_url": dataURL}}
|
||||
|
||||
t.Run("parallel outputs stay contiguous", func(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{"type": "function_call", "call_id": "call_1", "name": "screenshot", "arguments": "{}"},
|
||||
{"type": "function_call", "call_id": "call_2", "name": "read_file", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": imageOutput},
|
||||
{"type": "function_call_output", "call_id": "call_2", "output": "file contents"},
|
||||
{"role": "user", "content": "what do you see?"},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 5)
|
||||
assert.Equal(t, "assistant", got.Messages[0].Role)
|
||||
assert.Len(t, got.Messages[0].ParseToolCalls(), 2)
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_1", Content: "[image]"}, got.Messages[1])
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_2", Content: "file contents"}, got.Messages[2])
|
||||
assert.Equal(t, "user", got.Messages[3].Role)
|
||||
parts := got.Messages[3].ParseContent()
|
||||
require.Len(t, parts, 1)
|
||||
assert.Equal(t, dto.ContentTypeImageURL, parts[0].Type)
|
||||
assert.Equal(t, dto.Message{Role: "user", Content: "what do you see?"}, got.Messages[4])
|
||||
})
|
||||
|
||||
t.Run("custom tool outputs stay in the same batch", func(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{"type": "function_call", "call_id": "call_1", "name": "screenshot", "arguments": "{}"},
|
||||
{"type": "custom_tool_call", "call_id": "call_2", "name": "exec", "input": "ls"},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": imageOutput},
|
||||
{"type": "custom_tool_call_output", "call_id": "call_2", "output": "file.txt"},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 4)
|
||||
assert.Len(t, got.Messages[0].ParseToolCalls(), 2)
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_1", Content: "[image]"}, got.Messages[1])
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_2", Content: "file.txt"}, got.Messages[2])
|
||||
assert.Equal(t, "user", got.Messages[3].Role)
|
||||
})
|
||||
|
||||
t.Run("trailing output flushes media at end of input", func(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{"type": "function_call", "call_id": "call_1", "name": "screenshot", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": imageOutput},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 3)
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_1", Content: "[image]"}, got.Messages[1])
|
||||
assert.Equal(t, "user", got.Messages[2].Role)
|
||||
parts := got.Messages[2].ParseContent()
|
||||
require.Len(t, parts, 1)
|
||||
require.NotNil(t, parts[0].GetImageMedia())
|
||||
assert.Equal(t, dataURL, parts[0].GetImageMedia().Url)
|
||||
})
|
||||
}
|
||||
|
||||
func mustRawMessage(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
raw, err := kitutil.Marshal(value)
|
||||
|
||||
Reference in new issue
Block a user