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

@@ -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)