feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -7,8 +7,10 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
|
||||
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 (
|
||||
@@ -154,7 +156,13 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
}
|
||||
}
|
||||
|
||||
for _, claudeMessage := range claudeRequest.Messages {
|
||||
type unnamedToolResult struct {
|
||||
index int
|
||||
id string
|
||||
}
|
||||
toolNames := make(map[string]string)
|
||||
var unnamedToolResults []unnamedToolResult
|
||||
for messageIndex, claudeMessage := range claudeRequest.Messages {
|
||||
openAIMessage := dto.Message{
|
||||
Role: claudeMessage.Role,
|
||||
}
|
||||
@@ -168,7 +176,10 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
var toolCalls []dto.ToolCallRequest
|
||||
mediaMessages := make([]dto.MediaContent, 0, len(content))
|
||||
|
||||
for _, mediaMsg := range content {
|
||||
for blockIndex, mediaMsg := range content {
|
||||
if _, exists := toolNames[mediaMsg.Id]; !exists {
|
||||
toolNames[mediaMsg.Id] = mediaMsg.Name
|
||||
}
|
||||
switch mediaMsg.Type {
|
||||
case "text", "input_text":
|
||||
message := dto.MediaContent{
|
||||
@@ -178,12 +189,50 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
}
|
||||
mediaMessages = append(mediaMessages, message)
|
||||
case "image":
|
||||
imageData := fmt.Sprintf("data:%s;base64,%s", mediaMsg.Source.MediaType, mediaMsg.Source.Data)
|
||||
mediaMessage := dto.MediaContent{
|
||||
Type: "image_url",
|
||||
ImageUrl: &dto.MessageImageUrl{Url: imageData},
|
||||
url := claudeImageSourceURL(mediaMsg.Source)
|
||||
if url == "" {
|
||||
sourceType := ""
|
||||
if mediaMsg.Source != nil {
|
||||
sourceType = mediaMsg.Source.Type
|
||||
}
|
||||
convdiag.Add(ctx, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_source",
|
||||
Path: fmt.Sprintf("messages[%d].content[%d].source", messageIndex, blockIndex),
|
||||
Message: fmt.Sprintf("OpenAI Chat Completions cannot reference a Claude image with source type %q; the image was omitted", sourceType),
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
continue
|
||||
}
|
||||
mediaMessages = append(mediaMessages, mediaMessage)
|
||||
mediaMessages = append(mediaMessages, dto.MediaContent{
|
||||
Type: "image_url",
|
||||
ImageUrl: &dto.MessageImageUrl{Url: url},
|
||||
})
|
||||
case "document":
|
||||
if mediaMsg.Source != nil && mediaMsg.Source.Type == "text" {
|
||||
// A plain-text document keeps its content as a Chat text part.
|
||||
mediaMessages = append(mediaMessages, dto.MediaContent{Type: "text", Text: kitutil.Interface2String(mediaMsg.Source.Data)})
|
||||
continue
|
||||
}
|
||||
if mediaMsg.Source == nil || mediaMsg.Source.Type != "base64" || mediaMsg.Source.MediaType != "application/pdf" {
|
||||
sourceType, mediaType := "", ""
|
||||
if mediaMsg.Source != nil {
|
||||
sourceType, mediaType = mediaMsg.Source.Type, mediaMsg.Source.MediaType
|
||||
}
|
||||
convdiag.Add(ctx, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_source",
|
||||
Path: fmt.Sprintf("messages[%d].content[%d].source", messageIndex, blockIndex),
|
||||
Message: fmt.Sprintf("OpenAI Chat Completions carries only base64 PDF and plain-text documents, not a %q source of type %q; the document was omitted", sourceType, mediaType),
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
continue
|
||||
}
|
||||
mediaMessages = append(mediaMessages, dto.MediaContent{
|
||||
Type: dto.ContentTypeFile,
|
||||
File: &dto.MessageFile{
|
||||
FileName: "document.pdf",
|
||||
FileData: "data:application/pdf;base64," + kitutil.Interface2String(mediaMsg.Source.Data),
|
||||
},
|
||||
})
|
||||
case "tool_use":
|
||||
toolCall := dto.ToolCallRequest{
|
||||
ID: mediaMsg.Id,
|
||||
@@ -197,7 +246,7 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
case "tool_result":
|
||||
toolName := mediaMsg.Name
|
||||
if toolName == "" {
|
||||
toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId)
|
||||
unnamedToolResults = append(unnamedToolResults, unnamedToolResult{index: len(openAIMessages), id: mediaMsg.ToolUseId})
|
||||
}
|
||||
oaiToolMessage := dto.Message{
|
||||
Role: "tool",
|
||||
@@ -207,9 +256,12 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
if mediaMsg.IsStringContent() {
|
||||
oaiToolMessage.SetStringContent(mediaMsg.GetStringContent())
|
||||
} else {
|
||||
mediaContents := mediaMsg.ParseMediaContent()
|
||||
encodedJSON, _ := kitutil.Marshal(mediaContents)
|
||||
oaiToolMessage.SetStringContent(string(encodedJSON))
|
||||
content, media := claudeToolResultToChat(mediaMsg.ParseMediaContent())
|
||||
oaiToolMessage.SetStringContent(content)
|
||||
// A Chat tool message only carries text. Images from the tool result
|
||||
// join the user message this Claude message becomes, which lands right
|
||||
// after the tool batch and keeps the tool messages contiguous.
|
||||
mediaMessages = append(mediaMessages, media...)
|
||||
}
|
||||
openAIMessages = append(openAIMessages, oaiToolMessage)
|
||||
}
|
||||
@@ -218,7 +270,7 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
if len(toolCalls) > 0 {
|
||||
openAIMessage.SetToolCalls(toolCalls)
|
||||
}
|
||||
if len(mediaMessages) > 0 && len(toolCalls) == 0 {
|
||||
if len(mediaMessages) > 0 {
|
||||
openAIMessage.SetMediaContent(mediaMessages)
|
||||
}
|
||||
}
|
||||
@@ -226,11 +278,66 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
openAIMessages = append(openAIMessages, openAIMessage)
|
||||
}
|
||||
}
|
||||
for _, result := range unnamedToolResults {
|
||||
*openAIMessages[result.index].Name = toolNames[result.id]
|
||||
}
|
||||
|
||||
openAIRequest.Messages = openAIMessages
|
||||
return &openAIRequest, nil
|
||||
}
|
||||
|
||||
// claudeToolResultToChat maps a structured tool_result content array onto Chat Completions,
|
||||
// where a tool message may only carry text. Text blocks stay on the tool message and image
|
||||
// blocks come back in Chat shape for the caller to place on the following user message;
|
||||
// stringifying them instead would hand base64 image data to the upstream text tokenizer.
|
||||
// Arrays holding any other block type keep the historical stringified form so nothing is lost.
|
||||
func claudeToolResultToChat(blocks []dto.ClaudeMediaMessage) (string, []dto.MediaContent) {
|
||||
texts := make([]string, 0, len(blocks))
|
||||
media := make([]dto.MediaContent, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
switch {
|
||||
case block.Type == "text" || block.Type == "input_text":
|
||||
if text := block.GetText(); text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
case block.Type == "image":
|
||||
url := claudeImageSourceURL(block.Source)
|
||||
if url == "" {
|
||||
return requestToJSONString(blocks), nil
|
||||
}
|
||||
media = append(media, dto.MediaContent{Type: "image_url", ImageUrl: &dto.MessageImageUrl{Url: url}})
|
||||
default:
|
||||
return requestToJSONString(blocks), nil
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(texts) == 0 && len(media) == 0:
|
||||
return requestToJSONString(blocks), nil
|
||||
case len(texts) == 0:
|
||||
// Upstreams reject empty tool content; the images ride on the following user message.
|
||||
return "[image]", media
|
||||
default:
|
||||
return strings.Join(texts, "\n"), media
|
||||
}
|
||||
}
|
||||
|
||||
// claudeImageSourceURL is the Chat image_url for a Claude image source: a data
|
||||
// URL for base64 data or the URL of a url source. Other sources, such as Files
|
||||
// API references, have no Chat equivalent and yield "".
|
||||
func claudeImageSourceURL(source *dto.ClaudeMessageSource) string {
|
||||
if source == nil || source.Type == "file" {
|
||||
return ""
|
||||
}
|
||||
if source.Url != "" {
|
||||
return source.Url
|
||||
}
|
||||
data := kitutil.Interface2String(source.Data)
|
||||
if data == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("data:%s;base64,%s", source.MediaType, data)
|
||||
}
|
||||
|
||||
func requestToJSONString(v any) string {
|
||||
b, err := kitutil.Marshal(v)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package claudemessages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClaudeMessagesRequestToOpenAIChatToolResultContent(t *testing.T) {
|
||||
const imageData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
const dataURL = "data:image/png;base64," + imageData
|
||||
imageBlock := dto.ClaudeMediaMessage{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: "image/png", Data: imageData},
|
||||
}
|
||||
textBlock := func(text string) dto.ClaudeMediaMessage {
|
||||
return dto.ClaudeMediaMessage{Type: "text", Text: lo.ToPtr(text)}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content any
|
||||
wantContent string
|
||||
wantImages []string
|
||||
}{
|
||||
{
|
||||
name: "string passes through",
|
||||
content: "done",
|
||||
wantContent: "done",
|
||||
},
|
||||
{
|
||||
name: "text and image keep text on tool message",
|
||||
content: []dto.ClaudeMediaMessage{textBlock("screenshot taken"), imageBlock},
|
||||
wantContent: "screenshot taken",
|
||||
wantImages: []string{dataURL},
|
||||
},
|
||||
{
|
||||
name: "image only uses placeholder",
|
||||
content: []dto.ClaudeMediaMessage{imageBlock},
|
||||
wantContent: "[image]",
|
||||
wantImages: []string{dataURL},
|
||||
},
|
||||
{
|
||||
name: "url image source is forwarded as is",
|
||||
content: []dto.ClaudeMediaMessage{{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "url", Url: "https://example.com/shot.png"},
|
||||
}},
|
||||
wantContent: "[image]",
|
||||
wantImages: []string{"https://example.com/shot.png"},
|
||||
},
|
||||
{
|
||||
name: "text blocks join with newline",
|
||||
content: []dto.ClaudeMediaMessage{textBlock("first"), textBlock(""), textBlock("second")},
|
||||
wantContent: "first\nsecond",
|
||||
},
|
||||
{
|
||||
name: "unknown block keeps whole array",
|
||||
content: []dto.ClaudeMediaMessage{textBlock("see doc"), {Type: "document", Data: "opaque"}},
|
||||
wantContent: `[{"type":"text","text":"see doc"},{"type":"document","data":"opaque"}]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ClaudeMessagesRequestToOpenAIChat(context.Background(), dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
MaxTokens: lo.ToPtr(uint(64)),
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{Role: "user", Content: "take a screenshot"},
|
||||
{Role: "assistant", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_use", Id: "toolu_1", Name: "screenshot", Input: map[string]any{}},
|
||||
}},
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_result", ToolUseId: "toolu_1", Content: tt.content},
|
||||
textBlock("what do you see?"),
|
||||
}},
|
||||
},
|
||||
}, &convmeta.Values{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 4)
|
||||
tool := got.Messages[2]
|
||||
assert.Equal(t, "tool", tool.Role)
|
||||
assert.Equal(t, "toolu_1", tool.ToolCallId)
|
||||
if tt.wantImages == nil && tt.wantContent[0] == '[' {
|
||||
assert.JSONEq(t, tt.wantContent, tool.StringContent())
|
||||
} else {
|
||||
assert.Equal(t, tt.wantContent, tool.StringContent())
|
||||
}
|
||||
|
||||
user := got.Messages[3]
|
||||
assert.Equal(t, "user", user.Role)
|
||||
parts := user.ParseContent()
|
||||
require.Len(t, parts, len(tt.wantImages)+1)
|
||||
for i, wantURL := range tt.wantImages {
|
||||
assert.Equal(t, dto.ContentTypeImageURL, parts[i].Type)
|
||||
require.NotNil(t, parts[i].GetImageMedia())
|
||||
assert.Equal(t, wantURL, parts[i].GetImageMedia().Url)
|
||||
}
|
||||
assert.Equal(t, dto.ContentTypeText, parts[len(parts)-1].Type)
|
||||
assert.Equal(t, "what do you see?", parts[len(parts)-1].Text)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeMessagesRequestToOpenAIChatKeepsParallelToolResultsContiguous(t *testing.T) {
|
||||
const dataURL = "data:image/png;base64,iVBORw0KGgo="
|
||||
got, err := ClaudeMessagesRequestToOpenAIChat(context.Background(), dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
MaxTokens: lo.ToPtr(uint(64)),
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{Role: "user", Content: "screenshot and read the note"},
|
||||
{Role: "assistant", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "text", Text: lo.ToPtr("Let me look.")},
|
||||
{Type: "tool_use", Id: "toolu_shot", Name: "screenshot", Input: map[string]any{}},
|
||||
{Type: "tool_use", Id: "toolu_read", Name: "read_file", Input: map[string]any{"path": "note.txt"}},
|
||||
}},
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_result", ToolUseId: "toolu_shot", Content: []dto.ClaudeMediaMessage{{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: "image/png", Data: "iVBORw0KGgo="},
|
||||
}}},
|
||||
{Type: "tool_result", ToolUseId: "toolu_read", Content: "note contents"},
|
||||
}},
|
||||
},
|
||||
}, &convmeta.Values{})
|
||||
require.NoError(t, err)
|
||||
|
||||
roles := make([]string, 0, len(got.Messages))
|
||||
for _, message := range got.Messages {
|
||||
roles = append(roles, message.Role)
|
||||
}
|
||||
assert.Equal(t, []string{"user", "assistant", "tool", "tool", "user"}, roles)
|
||||
assert.Len(t, got.Messages[1].ParseToolCalls(), 2)
|
||||
assert.Equal(t, "[image]", got.Messages[2].StringContent())
|
||||
assert.Equal(t, "toolu_shot", got.Messages[2].ToolCallId)
|
||||
assert.Equal(t, "note contents", got.Messages[3].StringContent())
|
||||
assert.NotContains(t, got.Messages[2].StringContent()+got.Messages[3].StringContent(), "base64")
|
||||
|
||||
parts := got.Messages[4].ParseContent()
|
||||
require.Len(t, parts, 1)
|
||||
assert.Equal(t, dto.ContentTypeImageURL, parts[0].Type)
|
||||
require.NotNil(t, parts[0].GetImageMedia())
|
||||
assert.Equal(t, dataURL, parts[0].GetImageMedia().Url)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ type ClaudeResponseInfo struct {
|
||||
Created int64
|
||||
Model string
|
||||
ResponseText strings.Builder
|
||||
// ToolUseCount counts streamed tool_use blocks for the output-token fallback.
|
||||
ToolUseCount int
|
||||
Usage *dto.Usage
|
||||
Done bool
|
||||
|
||||
@@ -524,6 +526,9 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
|
||||
if claudeResponse.Delta.Thinking != nil {
|
||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
|
||||
}
|
||||
if claudeResponse.Delta.PartialJson != nil {
|
||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.PartialJson)
|
||||
}
|
||||
}
|
||||
} else if claudeResponse.Type == "message_delta" {
|
||||
if claudeResponse.Usage != nil {
|
||||
@@ -552,6 +557,11 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
|
||||
|
||||
claudeInfo.Done = true
|
||||
} else if claudeResponse.Type == "content_block_start" {
|
||||
// Tool calls are most of an agent turn's output; count them for usage fallback.
|
||||
if block := claudeResponse.ContentBlock; block != nil && block.Type == "tool_use" {
|
||||
claudeInfo.ResponseText.WriteString(block.Name)
|
||||
claudeInfo.ToolUseCount++
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -177,6 +178,7 @@ func claudeMessagesToResponsesInput(messages []dto.ClaudeMessage) (json.RawMessa
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_image",
|
||||
"image_url": source,
|
||||
"detail": relaymedia.DefaultResponsesImageDetail,
|
||||
})
|
||||
}
|
||||
case "document":
|
||||
@@ -298,7 +300,11 @@ func claudeToolResultToResponsesOutput(content any) (any, error) {
|
||||
parts = append(parts, map[string]any{"type": "input_text", "text": block.GetText()})
|
||||
case "image":
|
||||
if source := claudeSourceURL(block.Source); source != "" {
|
||||
parts = append(parts, map[string]any{"type": "input_image", "image_url": source})
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "input_image",
|
||||
"image_url": source,
|
||||
"detail": relaymedia.DefaultResponsesImageDetail,
|
||||
})
|
||||
}
|
||||
case "document":
|
||||
if source := claudeSourceURL(block.Source); source != "" {
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/jsonutil"
|
||||
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"
|
||||
)
|
||||
|
||||
func GeminiGenerateContentRequestToOpenAIChat(ctx context.Context, geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
|
||||
@@ -72,7 +74,7 @@ func GeminiGenerateContentRequestToOpenAIChat(ctx context.Context, geminiRequest
|
||||
|
||||
callHistory := newGeminiFunctionCallHistory(geminiRequest.Contents)
|
||||
var messages []dto.Message
|
||||
for _, content := range geminiRequest.Contents {
|
||||
for contentIndex, content := range geminiRequest.Contents {
|
||||
message := dto.Message{
|
||||
Role: convertGeminiRoleToOpenAI(content.Role),
|
||||
}
|
||||
@@ -80,7 +82,7 @@ func GeminiGenerateContentRequestToOpenAIChat(ctx context.Context, geminiRequest
|
||||
var mediaContents []dto.MediaContent
|
||||
var toolCalls []dto.ToolCallRequest
|
||||
var reasoningTexts []string
|
||||
for _, part := range content.Parts {
|
||||
for partIndex, part := range content.Parts {
|
||||
if part.Text != "" {
|
||||
if part.Thought {
|
||||
reasoningTexts = append(reasoningTexts, part.Text)
|
||||
@@ -91,24 +93,20 @@ func GeminiGenerateContentRequestToOpenAIChat(ctx context.Context, geminiRequest
|
||||
Text: part.Text,
|
||||
}
|
||||
mediaContents = append(mediaContents, mediaContent)
|
||||
} else if part.InlineData != nil {
|
||||
mediaContent := dto.MediaContent{
|
||||
Type: "image_url",
|
||||
ImageUrl: &dto.MessageImageUrl{
|
||||
Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data),
|
||||
Detail: "auto",
|
||||
MimeType: part.InlineData.MimeType,
|
||||
},
|
||||
}
|
||||
mediaContents = append(mediaContents, mediaContent)
|
||||
} else if part.FileData != nil {
|
||||
mediaContent := dto.MediaContent{
|
||||
Type: "image_url",
|
||||
ImageUrl: &dto.MessageImageUrl{
|
||||
Url: part.FileData.FileUri,
|
||||
Detail: "auto",
|
||||
MimeType: part.FileData.MimeType,
|
||||
},
|
||||
} else if part.InlineData != nil || part.FileData != nil {
|
||||
mediaContent, reason := geminiMediaToChatContent(part.InlineData, part.FileData)
|
||||
if reason != "" {
|
||||
kind := "inlineData"
|
||||
if part.InlineData == nil {
|
||||
kind = "fileData"
|
||||
}
|
||||
convdiag.Add(ctx, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_type",
|
||||
Path: fmt.Sprintf("contents[%d].parts[%d].%s", contentIndex, partIndex, kind),
|
||||
Message: reason + "; the part was omitted",
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
continue
|
||||
}
|
||||
mediaContents = append(mediaContents, mediaContent)
|
||||
} else if part.FunctionCall != nil {
|
||||
@@ -208,6 +206,71 @@ func GeminiGenerateContentRequestToOpenAIChat(ctx context.Context, geminiRequest
|
||||
return openaiRequest, nil
|
||||
}
|
||||
|
||||
// chatImageMediaTypes are the image formats Chat Completions accepts.
|
||||
var chatImageMediaTypes = map[string]struct{}{
|
||||
"image/png": {},
|
||||
"image/jpeg": {},
|
||||
"image/webp": {},
|
||||
"image/gif": {},
|
||||
}
|
||||
|
||||
// geminiMediaToChatContent maps Gemini inlineData or fileData onto the Chat
|
||||
// content part for its MIME type: PNG, JPEG, WebP, or GIF images inline or by
|
||||
// URL, PDF as an inline file, text files as text, and WAV or MP3 as inline
|
||||
// audio. Whether the model accepts the part is left to the upstream. A
|
||||
// non-empty reason means the media has no Chat content part.
|
||||
func geminiMediaToChatContent(inlineData *dto.GeminiInlineData, fileData *dto.GeminiFileData) (dto.MediaContent, string) {
|
||||
if inlineData == nil {
|
||||
mimeType := strings.ToLower(strings.TrimSpace(fileData.MimeType))
|
||||
if mimeType == "image/jpg" {
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
if _, supported := chatImageMediaTypes[mimeType]; mimeType != "" && !supported {
|
||||
return dto.MediaContent{}, fmt.Sprintf("Chat Completions content cannot reference a Gemini file of MIME type %q", fileData.MimeType)
|
||||
}
|
||||
return dto.MediaContent{
|
||||
Type: dto.ContentTypeImageURL,
|
||||
ImageUrl: &dto.MessageImageUrl{Url: fileData.FileUri, Detail: "auto", MimeType: mimeType},
|
||||
}, ""
|
||||
}
|
||||
mimeType := strings.ToLower(strings.TrimSpace(inlineData.MimeType))
|
||||
if mimeType == "image/jpg" {
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
if _, supported := chatImageMediaTypes[mimeType]; supported {
|
||||
return dto.MediaContent{
|
||||
Type: dto.ContentTypeImageURL,
|
||||
ImageUrl: &dto.MessageImageUrl{Url: fmt.Sprintf("data:%s;base64,%s", mimeType, inlineData.Data), Detail: "auto", MimeType: mimeType},
|
||||
}, ""
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(mimeType, "image/"):
|
||||
return dto.MediaContent{}, fmt.Sprintf("Chat Completions content accepts only PNG, JPEG, WebP, and GIF images, not %q", inlineData.MimeType)
|
||||
case mimeType == "application/pdf":
|
||||
return dto.MediaContent{
|
||||
Type: dto.ContentTypeFile,
|
||||
File: &dto.MessageFile{FileName: "document.pdf", FileData: "data:application/pdf;base64," + inlineData.Data},
|
||||
}, ""
|
||||
case relaymedia.IsTextMimeType(mimeType):
|
||||
text, ok := relaymedia.DecodeText(inlineData.Data)
|
||||
if !ok {
|
||||
return dto.MediaContent{}, fmt.Sprintf("Gemini inlineData of MIME type %q could not be decoded as UTF-8 text", inlineData.MimeType)
|
||||
}
|
||||
return dto.MediaContent{Type: dto.ContentTypeText, Text: text}, ""
|
||||
case mimeType == "audio/wav" || mimeType == "audio/x-wav" || mimeType == "audio/wave":
|
||||
return dto.MediaContent{
|
||||
Type: dto.ContentTypeInputAudio,
|
||||
InputAudio: &dto.MessageInputAudio{Data: inlineData.Data, Format: "wav"},
|
||||
}, ""
|
||||
case mimeType == "audio/mp3" || mimeType == "audio/mpeg":
|
||||
return dto.MediaContent{
|
||||
Type: dto.ContentTypeInputAudio,
|
||||
InputAudio: &dto.MessageInputAudio{Data: inlineData.Data, Format: "mp3"},
|
||||
}, ""
|
||||
}
|
||||
return dto.MediaContent{}, fmt.Sprintf("Gemini inlineData of MIME type %q has no Chat Completions content part", inlineData.MimeType)
|
||||
}
|
||||
|
||||
type geminiPendingFunctionCall struct {
|
||||
id string
|
||||
name string
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
@@ -44,3 +47,37 @@ func DecodeBase64FileData(base64String string) (string, string, error) {
|
||||
}
|
||||
return resolver(base64String)
|
||||
}
|
||||
|
||||
// DefaultResponsesImageDetail is used when the source protocol carries no detail
|
||||
// hint. Sending the default explicitly also supports strict Responses validators
|
||||
// that reject input_image parts without detail.
|
||||
const DefaultResponsesImageDetail = "auto"
|
||||
|
||||
// ResponsesImageDetail preserves supported image detail levels and defaults
|
||||
// missing or unrecognized hints to "auto".
|
||||
func ResponsesImageDetail(detail string) string {
|
||||
switch normalized := strings.ToLower(strings.TrimSpace(detail)); normalized {
|
||||
case "low", "high", "auto", "original":
|
||||
return normalized
|
||||
default:
|
||||
return DefaultResponsesImageDetail
|
||||
}
|
||||
}
|
||||
|
||||
// IsTextMimeType reports whether a MIME type is plain text that a target can
|
||||
// carry as text: text/* and application/json, ignoring parameters.
|
||||
func IsTextMimeType(mimeType string) bool {
|
||||
mediaType, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(mimeType)), ";")
|
||||
mediaType = strings.TrimSpace(mediaType)
|
||||
return strings.HasPrefix(mediaType, "text/") || mediaType == "application/json"
|
||||
}
|
||||
|
||||
// DecodeText decodes base64 file data as text. It reports false when the data
|
||||
// is not valid base64 or the bytes are not UTF-8.
|
||||
func DecodeText(base64Data string) (string, bool) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(base64Data))
|
||||
if err != nil || !utf8.Valid(decoded) {
|
||||
return "", false
|
||||
}
|
||||
return string(decoded), true
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package oaichat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"context"
|
||||
|
||||
@@ -13,6 +12,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 OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
|
||||
@@ -265,24 +265,27 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image 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", mediaMessage.Type)
|
||||
var block dto.ClaudeMediaMessage
|
||||
if mediaMessage.Type != dto.ContentTypeInputAudio && mediaMessage.Type != dto.ContentTypeVideoUrl {
|
||||
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image for Claude")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
||||
}
|
||||
block, reason = sharedclaude.MediaBlock(base64Data, mimeType, mediaMessage.Type == dto.ContentTypeImageURL)
|
||||
}
|
||||
claudeMediaMessage := dto.ClaudeMediaMessage{
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
},
|
||||
if reason != "" {
|
||||
convdiag.Add(c, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_type",
|
||||
Path: "messages.content",
|
||||
Message: reason + "; the part was omitted",
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "application/pdf") {
|
||||
claudeMediaMessage.Type = "document"
|
||||
} else {
|
||||
claudeMediaMessage.Type = "image"
|
||||
}
|
||||
|
||||
claudeMediaMessage.Source.MediaType = mimeType
|
||||
claudeMediaMessage.Source.Data = base64Data
|
||||
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
|
||||
claudeMediaMessages = append(claudeMediaMessages, block)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -303,6 +306,10 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(claudeMediaMessages) == 0 {
|
||||
// Every part was omitted; Claude rejects an empty content array.
|
||||
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer("...")})
|
||||
}
|
||||
claudeMessage.Content = claudeMediaMessages
|
||||
}
|
||||
claudeMessages = append(claudeMessages, claudeMessage)
|
||||
|
||||
@@ -241,9 +241,6 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
state.FinishReason = *chosenChoice.FinishReason
|
||||
}
|
||||
|
||||
var claudeResponse dto.ClaudeResponse
|
||||
var isEmpty bool
|
||||
claudeResponse.Type = "content_block_delta"
|
||||
if len(chosenChoice.Delta.ToolCalls) > 0 {
|
||||
toolCalls := chosenChoice.Delta.ToolCalls
|
||||
if state.LastMessagesType != convmeta.LastMessageTypeTools {
|
||||
@@ -339,55 +336,58 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls) - 1
|
||||
}
|
||||
} else {
|
||||
// Some upstreams put the last reasoning token and the first answer
|
||||
// text in one chunk: the thinking block closes before the text block opens.
|
||||
reasoning := chosenChoice.Delta.GetReasoningContent()
|
||||
textContent := chosenChoice.Delta.GetContentString()
|
||||
if reasoning != "" || textContent != "" {
|
||||
if reasoning != "" {
|
||||
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
|
||||
stopOpenBlocksAndAdvance()
|
||||
idx := state.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "thinking",
|
||||
Thinking: kitutil.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
}
|
||||
state.LastMessagesType = convmeta.LastMessageTypeThinking
|
||||
claudeResponse.Delta = &dto.ClaudeMediaMessage{
|
||||
if reasoning != "" {
|
||||
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
|
||||
stopOpenBlocksAndAdvance()
|
||||
idx := state.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "thinking",
|
||||
Thinking: kitutil.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
}
|
||||
state.LastMessagesType = convmeta.LastMessageTypeThinking
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: kitutil.GetPointer[int](state.Index),
|
||||
Type: "content_block_delta",
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "thinking_delta",
|
||||
Thinking: &reasoning,
|
||||
}
|
||||
} else {
|
||||
if state.LastMessagesType != convmeta.LastMessageTypeText {
|
||||
stopOpenBlocksAndAdvance()
|
||||
idx := state.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: kitutil.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
}
|
||||
state.LastMessagesType = convmeta.LastMessageTypeText
|
||||
claudeResponse.Delta = &dto.ClaudeMediaMessage{
|
||||
},
|
||||
})
|
||||
}
|
||||
if textContent != "" {
|
||||
if state.LastMessagesType != convmeta.LastMessageTypeText {
|
||||
stopOpenBlocksAndAdvance()
|
||||
idx := state.Index
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: &idx,
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: kitutil.GetPointer[string](""),
|
||||
},
|
||||
})
|
||||
}
|
||||
state.LastMessagesType = convmeta.LastMessageTypeText
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: kitutil.GetPointer[int](state.Index),
|
||||
Type: "content_block_delta",
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "text_delta",
|
||||
Text: kitutil.GetPointer[string](textContent),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isEmpty = true
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
claudeResponse.Index = kitutil.GetPointer[int](state.Index)
|
||||
if !isEmpty && claudeResponse.Delta != nil {
|
||||
claudeResponses = append(claudeResponses, &claudeResponse)
|
||||
}
|
||||
appendCitationDeltas(chosenChoice.Delta.Annotations)
|
||||
|
||||
if doneChunk || state.Done {
|
||||
|
||||
@@ -186,11 +186,8 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
|
||||
geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
|
||||
|
||||
if len(textRequest.ResponseFormat.JsonSchema) > 0 {
|
||||
var jsonSchema dto.FormatJsonSchema
|
||||
if err := kitutil.Unmarshal(textRequest.ResponseFormat.JsonSchema, &jsonSchema); err == nil {
|
||||
cleanedSchema := sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
|
||||
geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema
|
||||
}
|
||||
sharedgemini.ApplyResponseSchema(c, &geminiRequest.GenerationConfig, textRequest.ResponseFormat.JsonSchema,
|
||||
"response_format.json_schema.schema", opts.Gemini.SupportsJSONSchemaModel(upstreamModelName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +336,7 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"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/samber/lo"
|
||||
@@ -227,9 +228,14 @@ func ChatCompletionsRequestToResponsesRequest(ctx context.Context, req *dto.Gene
|
||||
"text": part.Text,
|
||||
})
|
||||
case dto.ContentTypeImageURL:
|
||||
var sourceDetail string
|
||||
if img := part.GetImageMedia(); img != nil {
|
||||
sourceDetail = img.Detail
|
||||
}
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_image",
|
||||
"image_url": normalizeChatImageURLToString(part.ImageUrl),
|
||||
"detail": relaymedia.ResponsesImageDetail(sourceDetail),
|
||||
})
|
||||
case dto.ContentTypeInputAudio:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
@@ -237,10 +243,20 @@ func ChatCompletionsRequestToResponsesRequest(ctx context.Context, req *dto.Gene
|
||||
"input_audio": part.InputAudio,
|
||||
})
|
||||
case dto.ContentTypeFile:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_file",
|
||||
"file": part.File,
|
||||
})
|
||||
// Responses carries the file fields on the input_file part itself.
|
||||
inputFile := map[string]any{"type": "input_file"}
|
||||
if file := part.GetFile(); file != nil {
|
||||
if file.FileName != "" {
|
||||
inputFile["filename"] = file.FileName
|
||||
}
|
||||
if file.FileData != "" {
|
||||
inputFile["file_data"] = file.FileData
|
||||
}
|
||||
if file.FileId != "" {
|
||||
inputFile["file_id"] = file.FileId
|
||||
}
|
||||
}
|
||||
contentParts = append(contentParts, inputFile)
|
||||
case dto.ContentTypeVideoUrl:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_video",
|
||||
|
||||
@@ -40,6 +40,37 @@ func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing
|
||||
assert.Equal(t, "function_call_output", gjson.GetBytes(got.Input, "3.type").String())
|
||||
}
|
||||
|
||||
func TestChatCompletionsRequestToResponsesRequestImageDetail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
imageURL any
|
||||
want string
|
||||
}{
|
||||
{name: "object without detail defaults to auto", imageURL: map[string]any{"url": "https://example.test/a.png"}, want: "auto"},
|
||||
{name: "low detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "low"}, want: "low"},
|
||||
{name: "high detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "high"}, want: "high"},
|
||||
{name: "auto detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "auto"}, want: "auto"},
|
||||
{name: "original detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "original"}, want: "original"},
|
||||
{name: "unknown detail falls back to auto", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "bogus"}, want: "auto"},
|
||||
{name: "plain string url defaults to auto", imageURL: "https://example.test/a.png", want: "auto"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ChatCompletionsRequestToResponsesRequest(context.Background(), &dto.GeneralOpenAIRequest{
|
||||
Model: "gpt-test",
|
||||
Messages: []dto.Message{{Role: "user", Content: []any{
|
||||
map[string]any{"type": "image_url", "image_url": tt.imageURL},
|
||||
}}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "input_image", gjson.GetBytes(got.Input, "0.content.0.type").String())
|
||||
assert.Equal(t, tt.want, gjson.GetBytes(got.Input, "0.content.0.detail").String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsRequestToResponsesRequestPreservesPromptCacheKey(t *testing.T) {
|
||||
t.Run("present", func(t *testing.T) {
|
||||
key := "session-\"quoted\"\\path\n世界"
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
)
|
||||
|
||||
@@ -24,9 +25,14 @@ const (
|
||||
responsesEventOutputItemDone = "response.output_item.done"
|
||||
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
||||
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
|
||||
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
|
||||
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
|
||||
responsesEventReasoningSummaryPartAdded = "response.reasoning_summary_part.added"
|
||||
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
|
||||
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
|
||||
responsesEventReasoningSummaryPartDone = "response.reasoning_summary_part.done"
|
||||
responsesOutputTypeFunctionCall = "function_call"
|
||||
responsesOutputTypeCustomToolCall = "custom_tool_call"
|
||||
responsesOutputTypeMessage = "message"
|
||||
responsesOutputTypeReasoning = "reasoning"
|
||||
responsesIncompleteReasonContentFilter = "content_filter"
|
||||
@@ -34,6 +40,12 @@ const (
|
||||
)
|
||||
|
||||
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
|
||||
return ChatCompletionsResponseToResponsesResponseWithTools(resp, id, nil)
|
||||
}
|
||||
|
||||
// ChatCompletionsResponseToResponsesResponseWithTools also restores function
|
||||
// calls that tools marks as Responses custom tools into custom_tool_call items.
|
||||
func ChatCompletionsResponseToResponsesResponseWithTools(resp *dto.OpenAITextResponse, id string, tools *convmeta.ResponsesToolState) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
|
||||
if resp == nil {
|
||||
return nil, nil, errors.New("response is nil")
|
||||
}
|
||||
@@ -93,7 +105,10 @@ func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id
|
||||
}
|
||||
|
||||
for i, toolCall := range choice.Message.ParseToolCalls() {
|
||||
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out))
|
||||
if (toolCall.Type == "" || toolCall.Type == "function") && strings.TrimSpace(toolCall.Function.Name) == "" {
|
||||
continue
|
||||
}
|
||||
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out), tools)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -202,18 +217,31 @@ func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
|
||||
return strings.TrimSpace(status)
|
||||
}
|
||||
|
||||
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) {
|
||||
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string, tools *convmeta.ResponsesToolState) (dto.ResponsesOutput, error) {
|
||||
callID := strings.TrimSpace(toolCall.ID)
|
||||
if callID == "" {
|
||||
callID = fmt.Sprintf("%s_call_%d", responseID, index)
|
||||
}
|
||||
if toolCall.Type == "" || toolCall.Type == "function" {
|
||||
namespace, name := tools.ResponsesToolName(toolCall.Function.Name)
|
||||
if tools.IsCustomTool(toolCall.Function.Name) {
|
||||
return dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeCustomToolCall,
|
||||
ID: callID,
|
||||
Status: status,
|
||||
CallId: callID,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Input: chatArgumentsRawMessage(customToolInputFromArguments(toolCall.Function.Arguments)),
|
||||
}, nil
|
||||
}
|
||||
return dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: callID,
|
||||
Status: status,
|
||||
CallId: callID,
|
||||
Name: toolCall.Function.Name,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments),
|
||||
}, nil
|
||||
}
|
||||
@@ -226,6 +254,28 @@ func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID stri
|
||||
}, nil
|
||||
}
|
||||
|
||||
// customToolInputFromArguments unwraps the raw custom tool input from the
|
||||
// {"input": ...} arguments a Chat function call carries. Arguments of any other
|
||||
// shape are returned unchanged so the model's output is never dropped.
|
||||
func customToolInputFromArguments(arguments string) string {
|
||||
var value map[string]any
|
||||
if err := kitutil.UnmarshalJsonStr(arguments, &value); err != nil {
|
||||
return arguments
|
||||
}
|
||||
input, ok := value[convmeta.CustomToolInputArgument]
|
||||
if !ok {
|
||||
return arguments
|
||||
}
|
||||
if text, ok := input.(string); ok {
|
||||
return text
|
||||
}
|
||||
raw, err := kitutil.Marshal(input)
|
||||
if err != nil {
|
||||
return arguments
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func chatArgumentsRawMessage(arguments string) []byte {
|
||||
raw, err := kitutil.Marshal(arguments)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -41,6 +43,54 @@ func TestChatCompletionsResponseToResponsesPreservesTextToolCallsAndUsage(t *tes
|
||||
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments))
|
||||
}
|
||||
|
||||
func TestChatCompletionsResponseToResponsesDropsEmptyNameToolCalls(t *testing.T) {
|
||||
usage := dto.Usage{PromptTokens: 20, CompletionTokens: 5, TotalTokens: 25}
|
||||
usage.PromptTokensDetails.CachedTokens = 7
|
||||
usage.BillingUsage = dto.NewOpenAIChatBillingUsage(&usage)
|
||||
message := dto.Message{Role: "assistant"}
|
||||
message.SetToolCalls([]dto.ToolCallRequest{
|
||||
{
|
||||
ID: "call_invalid",
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Arguments: `{"ok":true}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "call_whitespace",
|
||||
Function: dto.FunctionRequest{Name: " \t\n", Arguments: `{}`},
|
||||
},
|
||||
{
|
||||
ID: "call_valid",
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: "lookup",
|
||||
Arguments: `{"q":"x"}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
resp, convertedUsage, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
|
||||
Usage: usage,
|
||||
Choices: []dto.OpenAITextResponseChoice{{
|
||||
Message: message,
|
||||
FinishReason: "tool_calls",
|
||||
}},
|
||||
}, "resp_1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, resp.Output, 1)
|
||||
assert.Equal(t, "call_valid", resp.Output[0].CallId)
|
||||
assert.Equal(t, "lookup", resp.Output[0].Name)
|
||||
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[0].Arguments))
|
||||
assert.Equal(t, 20, convertedUsage.InputTokens)
|
||||
assert.Equal(t, 5, convertedUsage.OutputTokens)
|
||||
assert.Equal(t, 25, convertedUsage.TotalTokens)
|
||||
require.NotNil(t, convertedUsage.InputTokensDetails)
|
||||
assert.Equal(t, 7, convertedUsage.InputTokensDetails.CachedTokens)
|
||||
assert.Equal(t, usage.BillingUsage, convertedUsage.BillingUsage)
|
||||
}
|
||||
|
||||
func TestChatCompletionsResponseToResponsesEmitsReasoningSummaryBeforeText(t *testing.T) {
|
||||
message := dto.Message{Role: "assistant", Content: "final answer"}
|
||||
message.ReasoningContent = lo.ToPtr("thinking summary")
|
||||
@@ -153,6 +203,366 @@ func TestChatCompletionsStreamToResponsesEventsAggregatesUsageAndToolArgs(t *tes
|
||||
assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments))
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamToResponsesEmitsReasoningSummaryPartLifecycle(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
stop := "stop"
|
||||
|
||||
var events []ChatToResponsesStreamEvent
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||
Id: "chatcmpl_1", Model: "gpt-test",
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ReasoningContent: lo.ToPtr("think")},
|
||||
}},
|
||||
})...)
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("answer")},
|
||||
FinishReason: &stop,
|
||||
}},
|
||||
})...)
|
||||
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
||||
|
||||
types := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
types = append(types, event.Type)
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
responsesEventCreated,
|
||||
responsesEventOutputItemAdded,
|
||||
responsesEventReasoningSummaryPartAdded,
|
||||
responsesEventReasoningSummaryDelta,
|
||||
responsesEventOutputItemAdded,
|
||||
responsesEventOutputTextDelta,
|
||||
"response.output_text.done",
|
||||
responsesEventOutputItemDone,
|
||||
responsesEventReasoningSummaryDone,
|
||||
responsesEventReasoningSummaryPartDone,
|
||||
responsesEventOutputItemDone,
|
||||
responsesEventCompleted,
|
||||
}, types)
|
||||
|
||||
// The Responses schema requires summary on every reasoning item, including
|
||||
// the one announced before any summary text exists.
|
||||
itemAdded, err := kitutil.Marshal(events[1].Payload)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(itemAdded), `"summary":[]`)
|
||||
|
||||
partAdded := events[2].Payload
|
||||
assert.Equal(t, "resp_1_reasoning_0", partAdded.ItemID)
|
||||
assert.Equal(t, 0, lo.FromPtr(partAdded.OutputIndex))
|
||||
assert.Equal(t, 0, lo.FromPtr(partAdded.SummaryIndex))
|
||||
require.NotNil(t, partAdded.Part)
|
||||
assert.Equal(t, dto.ResponsesReasoningSummaryPart{Type: "summary_text"}, *partAdded.Part)
|
||||
|
||||
partDone := events[9].Payload
|
||||
assert.Equal(t, "resp_1_reasoning_0", partDone.ItemID)
|
||||
require.NotNil(t, partDone.Part)
|
||||
assert.Equal(t, dto.ResponsesReasoningSummaryPart{Type: "summary_text", Text: "think"}, *partDone.Part)
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamToResponsesReopensItemsAfterMidStreamFinishReason(t *testing.T) {
|
||||
for _, emitSequenceNumber := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "legacy", true: "sequence_numbers"}[emitSequenceNumber], func(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
state.EmitSequenceNumber = emitSequenceNumber
|
||||
toolIndex := 0
|
||||
toolCalls := "tool_calls"
|
||||
stop := "stop"
|
||||
|
||||
chunks := []*dto.ChatCompletionsStreamResponse{
|
||||
{Id: "chatcmpl_1", Model: "gpt-test", Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ReasoningContent: lo.ToPtr("round 1")},
|
||||
}}},
|
||||
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("calling")},
|
||||
}}},
|
||||
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
|
||||
Index: &toolIndex, ID: "call_1", Type: "function",
|
||||
Function: dto.FunctionResponse{Name: "lookup", Arguments: "{}"},
|
||||
}}},
|
||||
}}},
|
||||
{Choices: []dto.ChatCompletionsStreamResponseChoice{{FinishReason: &toolCalls}}},
|
||||
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ReasoningContent: lo.ToPtr("round 2")},
|
||||
}}},
|
||||
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("final")},
|
||||
FinishReason: &stop,
|
||||
}}},
|
||||
}
|
||||
var events []ChatToResponsesStreamEvent
|
||||
for _, chunk := range chunks {
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk)...)
|
||||
}
|
||||
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
||||
|
||||
open := map[string]bool{}
|
||||
partAdded, partDone := 0, 0
|
||||
for _, event := range events {
|
||||
itemID := event.Payload.ItemID
|
||||
if event.Payload.Item != nil {
|
||||
itemID = event.Payload.Item.ID
|
||||
}
|
||||
switch event.Type {
|
||||
case responsesEventOutputItemAdded:
|
||||
assert.Falsef(t, open[itemID], "item %q added twice", itemID)
|
||||
open[itemID] = true
|
||||
case responsesEventOutputItemDone:
|
||||
assert.Truef(t, open[itemID], "item %q done without being open", itemID)
|
||||
delete(open, itemID)
|
||||
case responsesEventReasoningSummaryPartAdded:
|
||||
partAdded++
|
||||
assert.Truef(t, open[itemID], "part added for closed item %q", itemID)
|
||||
case responsesEventReasoningSummaryPartDone:
|
||||
partDone++
|
||||
assert.Truef(t, open[itemID], "part done for closed item %q", itemID)
|
||||
case responsesEventReasoningSummaryDelta, responsesEventOutputTextDelta:
|
||||
assert.Truef(t, open[itemID], "%s for %q arrived without an active item", event.Type, itemID)
|
||||
}
|
||||
}
|
||||
assert.Empty(t, open)
|
||||
assert.Equal(t, 2, partAdded)
|
||||
assert.Equal(t, 2, partDone)
|
||||
|
||||
completed := events[len(events)-1]
|
||||
require.Equal(t, responsesEventCompleted, completed.Type)
|
||||
require.NotNil(t, completed.Payload.Response)
|
||||
output := completed.Payload.Response.Output
|
||||
require.Len(t, output, 5)
|
||||
assert.Equal(t, []string{
|
||||
responsesOutputTypeReasoning, responsesOutputTypeMessage, responsesOutputTypeFunctionCall,
|
||||
responsesOutputTypeReasoning, responsesOutputTypeMessage,
|
||||
}, []string{output[0].Type, output[1].Type, output[2].Type, output[3].Type, output[4].Type})
|
||||
assert.Equal(t, "resp_1_reasoning_0", output[0].ID)
|
||||
assert.Equal(t, "round 1", output[0].Summary[0].Text)
|
||||
assert.Equal(t, "resp_1_msg_0", output[1].ID)
|
||||
assert.Equal(t, "calling", output[1].Content[0].Text)
|
||||
assert.Equal(t, "call_1", output[2].CallId)
|
||||
assert.Equal(t, "resp_1_reasoning_1", output[3].ID)
|
||||
assert.Equal(t, "round 2", output[3].Summary[0].Text)
|
||||
assert.Equal(t, "resp_1_msg_1", output[4].ID)
|
||||
assert.Equal(t, "final", output[4].Content[0].Text)
|
||||
for _, item := range output {
|
||||
assert.Equal(t, "completed", item.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func execCustomToolState() *convmeta.ResponsesToolState {
|
||||
return &convmeta.ResponsesToolState{CustomToolNames: map[string]struct{}{"exec": {}}}
|
||||
}
|
||||
|
||||
func TestChatCompletionsResponseToResponsesRestoresCustomToolCalls(t *testing.T) {
|
||||
message := dto.Message{Role: "assistant"}
|
||||
message.SetToolCalls([]dto.ToolCallRequest{
|
||||
{ID: "call_exec", Type: "function", Function: dto.FunctionRequest{Name: "exec", Arguments: `{"input":"echo \"hi\" && ls"}`}},
|
||||
{ID: "call_wait", Type: "function", Function: dto.FunctionRequest{Name: "wait", Arguments: `{"ms":1}`}},
|
||||
})
|
||||
chat := &dto.OpenAITextResponse{
|
||||
Model: "gpt-test",
|
||||
Choices: []dto.OpenAITextResponseChoice{{Message: message, FinishReason: "tool_calls"}},
|
||||
}
|
||||
|
||||
resp, _, err := ChatCompletionsResponseToResponsesResponseWithTools(chat, "resp_1", execCustomToolState())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Output, 2)
|
||||
custom, err := kitutil.Marshal(resp.Output[0])
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"type":"custom_tool_call","id":"call_exec","status":"completed","call_id":"call_exec","name":"exec","input":"echo \"hi\" && ls"}`, string(custom))
|
||||
assert.Equal(t, responsesOutputTypeFunctionCall, resp.Output[1].Type)
|
||||
assert.Equal(t, `"{\"ms\":1}"`, string(resp.Output[1].Arguments))
|
||||
|
||||
plain, _, err := ChatCompletionsResponseToResponsesResponse(chat, "resp_1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, responsesOutputTypeFunctionCall, plain.Output[0].Type)
|
||||
}
|
||||
|
||||
func TestCustomToolInputFromArguments(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
`{"input":"ls -la"}`: "ls -la",
|
||||
`{"input":""}`: "",
|
||||
`{"input":{"cmd":"ls"}}`: `{"cmd":"ls"}`,
|
||||
`{"cmd":"ls"}`: `{"cmd":"ls"}`,
|
||||
`ls -la`: "ls -la",
|
||||
``: "",
|
||||
`{"input":"a","extra":true}`: "a",
|
||||
`{"input":"\u003cdiv\u003e"}`: "<div>",
|
||||
}
|
||||
for arguments, want := range tests {
|
||||
assert.Equalf(t, want, customToolInputFromArguments(arguments), "arguments %q", arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamToResponsesRestoresCustomToolCalls(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
state.EmitSequenceNumber = true
|
||||
state.Tools = execCustomToolState()
|
||||
execIndex, waitIndex := 0, 1
|
||||
|
||||
var events []ChatToResponsesStreamEvent
|
||||
for _, delta := range []dto.ToolCallResponse{
|
||||
{Index: &execIndex, ID: "call_exec", Type: "function", Function: dto.FunctionResponse{Name: "exec"}},
|
||||
{Index: &execIndex, Function: dto.FunctionResponse{Arguments: `{"input":"echo `}},
|
||||
{Index: &execIndex, Function: dto.FunctionResponse{Arguments: `\"hi\""}`}},
|
||||
{Index: &waitIndex, ID: "call_wait", Type: "function", Function: dto.FunctionResponse{Name: "wait", Arguments: `{"ms":1}`}},
|
||||
} {
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{delta}}},
|
||||
},
|
||||
})...)
|
||||
}
|
||||
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
||||
|
||||
types := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
types = append(types, event.Type)
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
responsesEventCreated,
|
||||
responsesEventOutputItemAdded,
|
||||
responsesEventOutputItemAdded,
|
||||
responsesEventFunctionArgsDelta,
|
||||
responsesEventCustomToolInputDelta,
|
||||
responsesEventCustomToolInputDone,
|
||||
responsesEventOutputItemDone,
|
||||
responsesEventFunctionArgsDone,
|
||||
responsesEventOutputItemDone,
|
||||
responsesEventCompleted,
|
||||
}, types)
|
||||
|
||||
added, err := kitutil.Marshal(events[1].Payload.Item)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"type":"custom_tool_call","id":"call_exec","status":"in_progress","call_id":"call_exec","name":"exec","input":""}`, string(added))
|
||||
assert.Equal(t, `echo "hi"`, events[4].Payload.Delta)
|
||||
assert.Equal(t, "call_exec", events[4].Payload.ItemID)
|
||||
require.NotNil(t, events[5].Payload.Input)
|
||||
assert.Equal(t, `echo "hi"`, *events[5].Payload.Input)
|
||||
assert.Equal(t, responsesOutputTypeCustomToolCall, events[6].Payload.Item.Type)
|
||||
assert.Equal(t, `"echo \"hi\""`, string(events[6].Payload.Item.Input))
|
||||
|
||||
output := events[len(events)-1].Payload.Response.Output
|
||||
require.Len(t, output, 2)
|
||||
assert.Equal(t, responsesOutputTypeCustomToolCall, output[0].Type)
|
||||
assert.Equal(t, `"echo \"hi\""`, string(output[0].Input))
|
||||
assert.Equal(t, responsesOutputTypeFunctionCall, output[1].Type)
|
||||
assert.Equal(t, `"{\"ms\":1}"`, string(output[1].Arguments))
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamToResponsesHoldsNamelessToolUntilNameArrives(t *testing.T) {
|
||||
toolIndex := 0
|
||||
chunk := func(call dto.ToolCallResponse) *dto.ChatCompletionsStreamResponse {
|
||||
return &dto.ChatCompletionsStreamResponse{
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{call}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("custom tool", func(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
state.Tools = execCustomToolState()
|
||||
first := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, ID: "call_exec", Function: dto.FunctionResponse{Arguments: `{"input":`}}))
|
||||
for _, event := range first {
|
||||
assert.NotEqual(t, responsesEventOutputItemAdded, event.Type)
|
||||
assert.NotEqual(t, responsesEventFunctionArgsDelta, event.Type)
|
||||
}
|
||||
second := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, Function: dto.FunctionResponse{Name: "exec", Arguments: `"ls"}`}}))
|
||||
require.Len(t, second, 1)
|
||||
assert.Equal(t, responsesEventOutputItemAdded, second[0].Type)
|
||||
assert.Equal(t, responsesOutputTypeCustomToolCall, second[0].Payload.Item.Type)
|
||||
done := FinalizeChatCompletionsStreamToResponses(state)
|
||||
require.NotEmpty(t, done)
|
||||
output := done[len(done)-1].Payload.Response.Output
|
||||
require.Len(t, output, 1)
|
||||
assert.Equal(t, `"ls"`, string(output[0].Input))
|
||||
})
|
||||
|
||||
t.Run("function tool flushes held arguments", func(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
first := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, Type: "function", Function: dto.FunctionResponse{Arguments: `{"ms":`}}))
|
||||
require.Len(t, first, 1)
|
||||
assert.Equal(t, responsesEventCreated, first[0].Type)
|
||||
second := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, ID: "call_wait", Function: dto.FunctionResponse{Name: "wait", Arguments: `1}`}}))
|
||||
require.Len(t, second, 2)
|
||||
assert.Equal(t, responsesEventOutputItemAdded, second[0].Type)
|
||||
assert.Equal(t, responsesOutputTypeFunctionCall, second[0].Payload.Item.Type)
|
||||
assert.Equal(t, "call_wait", second[0].Payload.Item.ID)
|
||||
assert.Equal(t, "call_wait", second[0].Payload.Item.CallId)
|
||||
assert.Equal(t, responsesEventFunctionArgsDelta, second[1].Type)
|
||||
assert.Equal(t, "call_wait", second[1].Payload.ItemID)
|
||||
assert.Equal(t, `{"ms":1}`, second[1].Payload.Delta)
|
||||
done := FinalizeChatCompletionsStreamToResponses(state)
|
||||
output := done[len(done)-1].Payload.Response.Output
|
||||
require.Len(t, output, 1)
|
||||
assert.Equal(t, "call_wait", output[0].ID)
|
||||
assert.Equal(t, `"{\"ms\":1}"`, string(output[0].Arguments))
|
||||
})
|
||||
|
||||
t.Run("held tool takes the next output index when announced", func(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
state.Tools = execCustomToolState()
|
||||
waitIndex := 1
|
||||
var events []ChatToResponsesStreamEvent
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, ID: "call_exec"}))...)
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &waitIndex, ID: "call_wait", Function: dto.FunctionResponse{Name: "wait", Arguments: `{}`}}))...)
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("done")}}},
|
||||
})...)
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, Function: dto.FunctionResponse{Name: "exec", Arguments: `{"input":"ls"}`}}))...)
|
||||
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
||||
|
||||
var addedIndexes []int
|
||||
var addedTypes []string
|
||||
for _, event := range events {
|
||||
if event.Type == responsesEventOutputItemAdded {
|
||||
addedIndexes = append(addedIndexes, lo.FromPtr(event.Payload.OutputIndex))
|
||||
addedTypes = append(addedTypes, event.Payload.Item.Type)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, []int{0, 1, 2}, addedIndexes)
|
||||
assert.Equal(t, []string{responsesOutputTypeFunctionCall, responsesOutputTypeMessage, responsesOutputTypeCustomToolCall}, addedTypes)
|
||||
output := events[len(events)-1].Payload.Response.Output
|
||||
require.Len(t, output, 3)
|
||||
assert.Equal(t, addedTypes, []string{output[0].Type, output[1].Type, output[2].Type})
|
||||
})
|
||||
|
||||
t.Run("name never arrives", func(t *testing.T) {
|
||||
for _, tools := range []*convmeta.ResponsesToolState{nil, execCustomToolState()} {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
state.EmitSequenceNumber = true
|
||||
state.Tools = tools
|
||||
missingIndex, validIndex, blankIndex := 0, 1, 2
|
||||
var events []ChatToResponsesStreamEvent
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
|
||||
{Index: &missingIndex, ID: "call_missing", Type: "function", Function: dto.FunctionResponse{Arguments: `{"ok":true}`}},
|
||||
{Index: &validIndex, ID: "call_valid", Type: "function", Function: dto.FunctionResponse{Name: "lookup", Arguments: `{"q":"x"}`}},
|
||||
{Index: &blankIndex, ID: "call_blank", Function: dto.FunctionResponse{Name: " \t\n", Arguments: `{}`}},
|
||||
}}}},
|
||||
})...)
|
||||
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
||||
|
||||
for i, event := range events {
|
||||
require.NotNil(t, event.Payload.SequenceNumber)
|
||||
assert.Equal(t, i, *event.Payload.SequenceNumber)
|
||||
if event.Payload.OutputIndex != nil {
|
||||
assert.Equal(t, 0, *event.Payload.OutputIndex)
|
||||
}
|
||||
if event.Payload.Item != nil {
|
||||
assert.Equal(t, "call_valid", event.Payload.Item.CallId)
|
||||
}
|
||||
}
|
||||
output := events[len(events)-1].Payload.Response.Output
|
||||
require.Len(t, output, 1)
|
||||
assert.Equal(t, "call_valid", output[0].CallId)
|
||||
assert.Equal(t, "lookup", output[0].Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
|
||||
t.Helper()
|
||||
events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,9 @@ type ChatToResponsesStreamState struct {
|
||||
// EmitSequenceNumber enables the required sequence_number field for current
|
||||
// Responses API SSE consumers while preserving the legacy relaykit default.
|
||||
EmitSequenceNumber bool
|
||||
// Tools marks the Chat function calls that encode Responses custom tools;
|
||||
// they are streamed back as custom_tool_call items.
|
||||
Tools *convmeta.ResponsesToolState
|
||||
|
||||
status string
|
||||
incompleteDetails *dto.IncompleteDetails
|
||||
@@ -44,6 +48,17 @@ type ChatToResponsesStreamState struct {
|
||||
text strings.Builder
|
||||
annotations []any
|
||||
reasoning strings.Builder
|
||||
// usageText collects every text, reasoning, and tool argument delta sent
|
||||
// to the client. It is the missing-usage estimate and tells whether any
|
||||
// output reached the client.
|
||||
usageText strings.Builder
|
||||
// A mid-stream finish_reason closes the message and reasoning items. Deltas that
|
||||
// arrive afterwards open a fresh item (next segment ordinal) instead of touching the
|
||||
// closed one; the closed items are snapshotted so the final response keeps them.
|
||||
textSegment int
|
||||
reasoningSegment int
|
||||
closedMessages []dto.ResponsesOutput
|
||||
closedReasonings []dto.ResponsesOutput
|
||||
}
|
||||
|
||||
type chatToResponsesStreamTool struct {
|
||||
@@ -53,11 +68,16 @@ type chatToResponsesStreamTool struct {
|
||||
CallID string
|
||||
Name string
|
||||
Arguments strings.Builder
|
||||
Done bool
|
||||
// Announced records that output_item.added was sent. Custom reports that
|
||||
// the call restores a Responses custom tool; it is fixed at announcement.
|
||||
Announced bool
|
||||
Custom bool
|
||||
Done bool
|
||||
}
|
||||
|
||||
type chatToResponsesOutputRef struct {
|
||||
Kind string
|
||||
Segment int
|
||||
ToolIndex int
|
||||
HostedID string
|
||||
}
|
||||
@@ -386,12 +406,13 @@ func (s *ChatToResponsesStreamState) UsageText() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.text.String()
|
||||
return s.usageText.String()
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent {
|
||||
events := s.startText()
|
||||
s.text.WriteString(delta)
|
||||
s.usageText.WriteString(delta)
|
||||
events = append(events, s.event(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputTextDelta,
|
||||
OutputIndex: intPtr(s.textOutputIndex),
|
||||
@@ -403,9 +424,16 @@ func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToRespo
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) startText() []ChatToResponsesStreamEvent {
|
||||
if s.textDone {
|
||||
s.textSegment++
|
||||
s.textStarted = false
|
||||
s.textDone = false
|
||||
s.text.Reset()
|
||||
s.annotations = nil
|
||||
}
|
||||
if !s.textStarted {
|
||||
s.textStarted = true
|
||||
s.textOutputIndex = s.nextIndex("message", -1)
|
||||
s.textOutputIndex = s.nextIndex(chatToResponsesOutputRef{Kind: "message", Segment: s.textSegment})
|
||||
return []ChatToResponsesStreamEvent{s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: intPtr(s.textOutputIndex),
|
||||
@@ -447,10 +475,16 @@ func (s *ChatToResponsesStreamState) appendAnnotationDelta(raw []byte) ([]ChatTo
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent {
|
||||
events := make([]ChatToResponsesStreamEvent, 0, 2)
|
||||
events := make([]ChatToResponsesStreamEvent, 0, 3)
|
||||
if s.reasoningDone {
|
||||
s.reasoningSegment++
|
||||
s.reasoningStarted = false
|
||||
s.reasoningDone = false
|
||||
s.reasoning.Reset()
|
||||
}
|
||||
if !s.reasoningStarted {
|
||||
s.reasoningStarted = true
|
||||
s.reasoningIndex = s.nextIndex("reasoning", -1)
|
||||
s.reasoningIndex = s.nextIndex(chatToResponsesOutputRef{Kind: "reasoning", Segment: s.reasoningSegment})
|
||||
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
@@ -461,8 +495,16 @@ func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatTo
|
||||
Summary: []dto.ResponsesReasoningSummaryPart{},
|
||||
},
|
||||
}))
|
||||
events = append(events, s.event(responsesEventReasoningSummaryPartAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventReasoningSummaryPartAdded,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
SummaryIndex: intPtr(0),
|
||||
ItemID: s.reasoningID(),
|
||||
Part: &dto.ResponsesReasoningSummaryPart{Type: "summary_text"},
|
||||
}))
|
||||
}
|
||||
s.reasoning.WriteString(delta)
|
||||
s.usageText.WriteString(delta)
|
||||
events = append(events, s.event(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventReasoningSummaryDelta,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
@@ -483,29 +525,15 @@ func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallRe
|
||||
events := make([]ChatToResponsesStreamEvent, 0, 2)
|
||||
if tool == nil {
|
||||
tool = &chatToResponsesStreamTool{
|
||||
ChatIndex: chatIndex,
|
||||
OutputIndex: s.nextIndex("tool", chatIndex),
|
||||
CallID: incomingID,
|
||||
Name: strings.TrimSpace(toolCall.Function.Name),
|
||||
ChatIndex: chatIndex,
|
||||
CallID: incomingID,
|
||||
Name: strings.TrimSpace(toolCall.Function.Name),
|
||||
}
|
||||
tool.ItemID = incomingID
|
||||
if tool.ItemID == "" {
|
||||
tool.ItemID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
|
||||
}
|
||||
s.toolsByIndex[chatIndex] = tool
|
||||
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
Item: &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: tool.ItemID,
|
||||
Status: "in_progress",
|
||||
CallId: tool.callID(),
|
||||
Name: tool.Name,
|
||||
Arguments: []byte(`""`),
|
||||
},
|
||||
}))
|
||||
}
|
||||
if tool.Done {
|
||||
return nil, fmt.Errorf("tool-call stream index %d received data after completion", chatIndex)
|
||||
@@ -515,6 +543,9 @@ func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallRe
|
||||
return nil, fmt.Errorf("tool-call stream index %d changed id from %q to %q", chatIndex, tool.CallID, incomingID)
|
||||
}
|
||||
tool.CallID = incomingID
|
||||
if !tool.Announced {
|
||||
tool.ItemID = incomingID
|
||||
}
|
||||
}
|
||||
incomingName := strings.TrimSpace(toolCall.Function.Name)
|
||||
if incomingName != "" {
|
||||
@@ -523,18 +554,59 @@ func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallRe
|
||||
}
|
||||
tool.Name = incomingName
|
||||
}
|
||||
if toolCall.Function.Arguments != "" {
|
||||
tool.Arguments.WriteString(toolCall.Function.Arguments)
|
||||
delta := toolCall.Function.Arguments
|
||||
tool.Arguments.WriteString(delta)
|
||||
// A nameless call is invalid Responses output, so hold it back until a
|
||||
// valid name arrives. Announcement also determines whether it is custom.
|
||||
if !tool.Announced && tool.Name != "" {
|
||||
events = append(events, s.announceTool(tool))
|
||||
delta = tool.Arguments.String()
|
||||
}
|
||||
if tool.Announced {
|
||||
// Announced calls reach the client; custom tool input at completion.
|
||||
s.usageText.WriteString(delta)
|
||||
}
|
||||
// Custom tool input is unwrapped from the complete arguments at the end.
|
||||
if tool.Announced && !tool.Custom && delta != "" {
|
||||
events = append(events, s.event(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
Delta: toolCall.Function.Arguments,
|
||||
Delta: delta,
|
||||
}))
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// announceTool also allocates the output index, so a held tool never ends up
|
||||
// behind items that were added to the stream before it.
|
||||
func (s *ChatToResponsesStreamState) announceTool(tool *chatToResponsesStreamTool) ChatToResponsesStreamEvent {
|
||||
tool.Announced = true
|
||||
tool.OutputIndex = s.nextIndex(chatToResponsesOutputRef{Kind: "tool", ToolIndex: tool.ChatIndex})
|
||||
tool.Custom = s.Tools.IsCustomTool(tool.Name)
|
||||
namespace, name := s.Tools.ResponsesToolName(tool.Name)
|
||||
item := &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: tool.ItemID,
|
||||
Status: "in_progress",
|
||||
CallId: tool.callID(),
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Arguments: []byte(`""`),
|
||||
}
|
||||
if tool.Custom {
|
||||
item.Type = responsesOutputTypeCustomToolCall
|
||||
item.Arguments = nil
|
||||
item.Input = []byte(`""`)
|
||||
}
|
||||
return s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
Item: item,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEvent {
|
||||
events := make([]ChatToResponsesStreamEvent, 0)
|
||||
status := s.outputStatus()
|
||||
@@ -550,11 +622,13 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
|
||||
textDone.Text = kitutil.GetPointer(s.text.String())
|
||||
}
|
||||
events = append(events, s.event("response.output_text.done", textDone))
|
||||
closedMessage := s.messageOutput(status)
|
||||
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemDone,
|
||||
OutputIndex: intPtr(s.textOutputIndex),
|
||||
Item: s.messageOutput(status),
|
||||
Item: closedMessage,
|
||||
}))
|
||||
s.closedMessages = append(s.closedMessages, *closedMessage)
|
||||
}
|
||||
if s.reasoningStarted && !s.reasoningDone {
|
||||
s.reasoningDone = true
|
||||
@@ -573,27 +647,71 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
|
||||
reasoningDone.Part = nil
|
||||
}
|
||||
events = append(events, s.event(responsesEventReasoningSummaryDone, reasoningDone))
|
||||
events = append(events, s.event(responsesEventReasoningSummaryPartDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventReasoningSummaryPartDone,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
SummaryIndex: intPtr(0),
|
||||
ItemID: s.reasoningID(),
|
||||
Part: &dto.ResponsesReasoningSummaryPart{
|
||||
Type: "summary_text",
|
||||
Text: s.reasoning.String(),
|
||||
},
|
||||
}))
|
||||
closedReasoning := s.reasoningOutput(status)
|
||||
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemDone,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
Item: s.reasoningOutput(status),
|
||||
Item: closedReasoning,
|
||||
}))
|
||||
s.closedReasonings = append(s.closedReasonings, *closedReasoning)
|
||||
}
|
||||
for _, tool := range s.sortedTools() {
|
||||
if tool.Done {
|
||||
continue
|
||||
}
|
||||
tool.Done = true
|
||||
argumentsDone := dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDone,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
if !tool.Announced {
|
||||
if tool.Name == "" {
|
||||
continue
|
||||
}
|
||||
events = append(events, s.announceTool(tool))
|
||||
if !tool.Custom && tool.Arguments.Len() > 0 {
|
||||
events = append(events, s.event(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
Delta: tool.Arguments.String(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
if s.EmitSequenceNumber {
|
||||
argumentsDone.Arguments = kitutil.GetPointer(tool.Arguments.String())
|
||||
argumentsDone.Name = tool.Name
|
||||
if tool.Custom {
|
||||
input := customToolInputFromArguments(tool.Arguments.String())
|
||||
if input != "" {
|
||||
events = append(events, s.event(responsesEventCustomToolInputDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventCustomToolInputDelta,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
Delta: input,
|
||||
}))
|
||||
}
|
||||
events = append(events, s.event(responsesEventCustomToolInputDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventCustomToolInputDone,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
Input: kitutil.GetPointer(input),
|
||||
}))
|
||||
} else {
|
||||
argumentsDone := dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDone,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
}
|
||||
if s.EmitSequenceNumber {
|
||||
argumentsDone.Arguments = kitutil.GetPointer(tool.Arguments.String())
|
||||
_, argumentsDone.Name = s.Tools.ResponsesToolName(tool.Name)
|
||||
}
|
||||
events = append(events, s.event(responsesEventFunctionArgsDone, argumentsDone))
|
||||
}
|
||||
events = append(events, s.event(responsesEventFunctionArgsDone, argumentsDone))
|
||||
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemDone,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
@@ -649,8 +767,20 @@ func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesRespons
|
||||
for _, ref := range s.outputOrder {
|
||||
switch ref.Kind {
|
||||
case "message":
|
||||
if ref.Segment < len(s.closedMessages) {
|
||||
closed := s.closedMessages[ref.Segment]
|
||||
closed.Status = status
|
||||
output = append(output, closed)
|
||||
continue
|
||||
}
|
||||
output = append(output, *s.messageOutput(status))
|
||||
case "reasoning":
|
||||
if ref.Segment < len(s.closedReasonings) {
|
||||
closed := s.closedReasonings[ref.Segment]
|
||||
closed.Status = status
|
||||
output = append(output, closed)
|
||||
continue
|
||||
}
|
||||
output = append(output, *s.reasoningOutput(status))
|
||||
case "tool":
|
||||
if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil {
|
||||
@@ -685,10 +815,10 @@ func (s *ChatToResponsesStreamState) createdResponse() *dto.OpenAIResponsesRespo
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int {
|
||||
func (s *ChatToResponsesStreamState) nextIndex(ref chatToResponsesOutputRef) int {
|
||||
index := s.nextOutputIndex
|
||||
s.nextOutputIndex++
|
||||
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: kind, ToolIndex: toolIndex})
|
||||
s.outputOrder = append(s.outputOrder, ref)
|
||||
return index
|
||||
}
|
||||
|
||||
@@ -720,11 +850,11 @@ func (s *ChatToResponsesStreamState) outputStatus() string {
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) messageID() string {
|
||||
return fmt.Sprintf("%s_msg_0", s.ID)
|
||||
return fmt.Sprintf("%s_msg_%d", s.ID, s.textSegment)
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) reasoningID() string {
|
||||
return fmt.Sprintf("%s_reasoning_0", s.ID)
|
||||
return fmt.Sprintf("%s_reasoning_%d", s.ID, s.reasoningSegment)
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput {
|
||||
@@ -762,12 +892,25 @@ func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.Respons
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput {
|
||||
namespace, name := s.Tools.ResponsesToolName(tool.Name)
|
||||
if tool.Custom {
|
||||
return &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeCustomToolCall,
|
||||
ID: tool.ItemID,
|
||||
Status: status,
|
||||
CallId: tool.callID(),
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Input: chatArgumentsRawMessage(customToolInputFromArguments(tool.Arguments.String())),
|
||||
}
|
||||
}
|
||||
return &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: tool.ItemID,
|
||||
Status: status,
|
||||
CallId: tool.callID(),
|
||||
Name: tool.Name,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
|
||||
)
|
||||
|
||||
// imageMediaTypes are the image formats Claude accepts.
|
||||
var imageMediaTypes = map[string]struct{}{
|
||||
"image/jpeg": {},
|
||||
"image/png": {},
|
||||
"image/gif": {},
|
||||
"image/webp": {},
|
||||
}
|
||||
|
||||
// MediaBlock builds the Claude content block for resolved base64 media: an
|
||||
// image in a format Claude accepts, a PDF document, or a plain-text document
|
||||
// for text files. declaredImage marks media sent as an image part, which
|
||||
// passes through when its MIME type is unknown. A non-empty reason means
|
||||
// Claude cannot carry the media and the block must be omitted.
|
||||
func MediaBlock(base64Data string, mimeType string, declaredImage bool) (dto.ClaudeMediaMessage, string) {
|
||||
mediaType, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(mimeType)), ";")
|
||||
mediaType = strings.TrimSpace(mediaType)
|
||||
if mediaType == "image/jpg" {
|
||||
mediaType = "image/jpeg"
|
||||
}
|
||||
switch {
|
||||
case mediaType == "application/pdf":
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "document",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: mediaType, Data: base64Data},
|
||||
}, ""
|
||||
case relaymedia.IsTextMimeType(mediaType):
|
||||
text, ok := relaymedia.DecodeText(base64Data)
|
||||
if !ok {
|
||||
return dto.ClaudeMediaMessage{}, fmt.Sprintf("text file of MIME type %q could not be decoded as UTF-8 text", mimeType)
|
||||
}
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "document",
|
||||
Source: &dto.ClaudeMessageSource{Type: "text", MediaType: "text/plain", Data: text},
|
||||
}, ""
|
||||
case mediaType == "" && declaredImage:
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: mimeType, Data: base64Data},
|
||||
}, ""
|
||||
case mediaType == "application/octet-stream" && declaredImage:
|
||||
return dto.ClaudeMediaMessage{}, "the image format could not be identified"
|
||||
}
|
||||
if _, ok := imageMediaTypes[mediaType]; ok {
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: mediaType, Data: base64Data},
|
||||
}, ""
|
||||
}
|
||||
if strings.HasPrefix(mediaType, "image/") {
|
||||
return dto.ClaudeMediaMessage{}, fmt.Sprintf("Claude accepts only JPEG, PNG, GIF, and WebP images, not %q", mimeType)
|
||||
}
|
||||
return dto.ClaudeMediaMessage{}, fmt.Sprintf("Claude Messages cannot carry content of MIME type %q", mimeType)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
|
||||
case req.Thinking.BudgetTokens != nil:
|
||||
effort = string(reasoning.EffortFromBudget(*req.Thinking.BudgetTokens))
|
||||
case req.Thinking.Type == "enabled" || req.Thinking.Type == "adaptive":
|
||||
effort = string(reasoning.EffortHigh)
|
||||
effort = string(reasoning.ClaudeDefaultEffort(req.Model))
|
||||
}
|
||||
}
|
||||
info.SetReasoningEffort(effort)
|
||||
@@ -81,6 +81,16 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
|
||||
intent.Effort = reasoning.EffortHigh
|
||||
}
|
||||
}
|
||||
requestEffort, err := reasoning.ParseEffort(req.GetEfforts())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if knownClaudeModel && intent.Mode == reasoning.ModeDisabled && (intent.Effort == "" || intent.Effort == reasoning.EffortNone) &&
|
||||
requestEffort != "" && requestEffort != reasoning.EffortNone {
|
||||
// Effort works without thinking, so the request's own effort is
|
||||
// rendered for the thinking-off state rather than left unchecked.
|
||||
intent.Effort = requestEffort
|
||||
}
|
||||
if req.MaxTokens == nil && intent.HasStrength() {
|
||||
// Adapter-provided defaults may be raised to accommodate an exact
|
||||
// cross-protocol budget. Values too small for manual thinking are
|
||||
@@ -117,7 +127,14 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
|
||||
if rendered.Thinking != nil {
|
||||
req.Thinking = rendered.Thinking
|
||||
}
|
||||
if rendered.OutputEffort != "" {
|
||||
outputEffort := rendered.OutputEffort
|
||||
// An effort the request still carries reaches a known model only at a
|
||||
// level it accepts, and is removed where the model takes none.
|
||||
rewriteRequestEffort := outputEffort == "" && knownClaudeModel && requestEffort != ""
|
||||
if rewriteRequestEffort {
|
||||
outputEffort = reasoning.ClaudeEffort(capabilityModel, requestEffort)
|
||||
}
|
||||
if outputEffort != "" || rewriteRequestEffort {
|
||||
outputConfig := make(map[string]any)
|
||||
if len(req.OutputConfig) > 0 {
|
||||
if kitutil.GetJsonType(req.OutputConfig) != "object" {
|
||||
@@ -130,12 +147,20 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
|
||||
outputConfig = make(map[string]any)
|
||||
}
|
||||
}
|
||||
outputConfig["effort"] = string(rendered.OutputEffort)
|
||||
encoded, err := kitutil.Marshal(outputConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal Claude output_config: %w", err)
|
||||
if outputEffort != "" {
|
||||
outputConfig["effort"] = string(outputEffort)
|
||||
} else {
|
||||
delete(outputConfig, "effort")
|
||||
}
|
||||
if len(outputConfig) == 0 {
|
||||
req.OutputConfig = nil
|
||||
} else {
|
||||
encoded, err := kitutil.Marshal(outputConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal Claude output_config: %w", err)
|
||||
}
|
||||
req.OutputConfig = encoded
|
||||
}
|
||||
req.OutputConfig = encoded
|
||||
}
|
||||
if rendered.ClearSampling {
|
||||
if req.Temperature != nil || req.TopP != nil || req.TopK != nil {
|
||||
|
||||
@@ -3,6 +3,8 @@ package gemini
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -13,26 +15,43 @@ import (
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
var SupportedMimeTypes = map[string]bool{
|
||||
"application/pdf": true,
|
||||
"audio/mpeg": true,
|
||||
"audio/mp3": true,
|
||||
"audio/wav": true,
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/jpg": true,
|
||||
"image/webp": true,
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
"text/plain": true,
|
||||
"video/mov": true,
|
||||
"video/mpeg": true,
|
||||
"video/mp4": true,
|
||||
"video/mpg": true,
|
||||
"video/avi": true,
|
||||
"video/wmv": true,
|
||||
"video/mpegps": true,
|
||||
"video/flv": true,
|
||||
// supportedMimeTypes follows the Blob.mimeType list in the Gemini API
|
||||
// reference (https://ai.google.dev/api/generate-content#Blob). Audio and
|
||||
// video are listed there as audio/* and video/*; IsSupportedMimeType accepts
|
||||
// them by prefix.
|
||||
var supportedMimeTypes = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/jpg": true,
|
||||
"image/webp": true,
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
"image/gif": true,
|
||||
"image/avif": true,
|
||||
"text/plain": true,
|
||||
"text/html": true,
|
||||
"text/css": true,
|
||||
"text/javascript": true,
|
||||
"text/x-typescript": true,
|
||||
"text/csv": true,
|
||||
"text/markdown": true,
|
||||
"text/x-python": true,
|
||||
"text/xml": true,
|
||||
"text/rtf": true,
|
||||
"application/x-javascript": true,
|
||||
"application/x-typescript": true,
|
||||
"application/x-python-code": true,
|
||||
"application/json": true,
|
||||
"application/x-ipynb+json": true,
|
||||
"application/rtf": true,
|
||||
"application/pdf": true,
|
||||
}
|
||||
|
||||
// IsSupportedMimeType reports whether Gemini accepts inline data of the MIME
|
||||
// type.
|
||||
func IsSupportedMimeType(mimeType string) bool {
|
||||
mimeType = strings.ToLower(mimeType)
|
||||
return supportedMimeTypes[mimeType] || strings.HasPrefix(mimeType, "audio/") || strings.HasPrefix(mimeType, "video/")
|
||||
}
|
||||
|
||||
var SafetySettingCategories = []string{
|
||||
@@ -272,9 +291,5 @@ func HasFunctionCallContent(call *dto.FunctionCall) bool {
|
||||
}
|
||||
|
||||
func SupportedMimeTypesList() []string {
|
||||
keys := make([]string, 0, len(SupportedMimeTypes))
|
||||
for key := range SupportedMimeTypes {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
return append(slices.Sorted(maps.Keys(supportedMimeTypes)), "audio/*", "video/*")
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"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"
|
||||
)
|
||||
|
||||
var geminiOpenAPISchemaAllowedFields = map[string]struct{}{
|
||||
@@ -56,6 +64,7 @@ func cleanGeminiFunctionParametersWithDepth(params interface{}, depth int) inter
|
||||
}
|
||||
|
||||
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
|
||||
sanitizeGeminiEnum(cleanedMap)
|
||||
|
||||
if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil {
|
||||
cleanedProps := make(map[string]interface{})
|
||||
@@ -102,6 +111,7 @@ func cleanGeminiFunctionParametersShallow(params interface{}) interface{} {
|
||||
}
|
||||
}
|
||||
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
|
||||
sanitizeGeminiEnum(cleanedMap)
|
||||
delete(cleanedMap, "properties")
|
||||
delete(cleanedMap, "items")
|
||||
delete(cleanedMap, "anyOf")
|
||||
@@ -113,6 +123,30 @@ func cleanGeminiFunctionParametersShallow(params interface{}) interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeGeminiEnum keeps the enum constraint only when every element is a
|
||||
// string, the only form Gemini's legacy Schema accepts. Any other shape is
|
||||
// dropped (e.g. boolean or number elements, which valid JSON Schema allows but
|
||||
// Gemini rejects with a 400). This widens the value domain for that schema, a
|
||||
// deliberate lossy relaxation: the declared type is preserved, string enums
|
||||
// pass through untouched.
|
||||
func sanitizeGeminiEnum(schema map[string]interface{}) {
|
||||
raw, exists := schema["enum"]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
values, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
delete(schema, "enum")
|
||||
return
|
||||
}
|
||||
for _, value := range values {
|
||||
if _, isString := value.(string); !isString {
|
||||
delete(schema, "enum")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) {
|
||||
rawType, ok := schema["type"]
|
||||
if !ok || rawType == nil {
|
||||
@@ -175,6 +209,148 @@ func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// FunctionParameters returns the Gemini function declaration field that
|
||||
// carries schema, and its value. A schema that fits Gemini's typed Schema is
|
||||
// cleaned as before, and still left out when its properties are empty. Any
|
||||
// other schema goes unchanged to parametersJsonSchema when jsonSchema is
|
||||
// true; otherwise it is cleaned anyway and misfits names what the typed
|
||||
// Schema cannot carry.
|
||||
func FunctionParameters(schema any, jsonSchema bool) (field string, value any, misfits []string) {
|
||||
misfits = schemaMisfits(schema)
|
||||
object, _ := schema.(map[string]any)
|
||||
properties, hasProperties := object["properties"].(map[string]any)
|
||||
emptyObject := hasProperties && len(properties) == 0
|
||||
if emptyObject {
|
||||
for key, item := range object {
|
||||
switch key {
|
||||
case "type", "properties":
|
||||
continue
|
||||
case "required":
|
||||
if required, _ := item.([]any); len(required) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, allowed := geminiOpenAPISchemaAllowedFields[key]; allowed {
|
||||
misfits = append(misfits, "constraints on an empty object")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(misfits) > 0 && jsonSchema {
|
||||
return "parametersJsonSchema", schema, nil
|
||||
}
|
||||
if emptyObject {
|
||||
return "parameters", nil, misfits
|
||||
}
|
||||
return "parameters", CleanFunctionParameters(schema), misfits
|
||||
}
|
||||
|
||||
// ApplyResponseSchema sets the structured output schema from the json_schema
|
||||
// object of an OpenAI response format. A schema that fits Gemini's typed
|
||||
// Schema keeps the responseSchema cleanup. Any other schema is sent unchanged
|
||||
// as responseJsonSchema when jsonSchema is true; otherwise it is cleaned
|
||||
// anyway and a diagnostic at path names what responseSchema cannot carry.
|
||||
func ApplyResponseSchema(ctx context.Context, config *dto.GeminiChatGenerationConfig, rawFormat json.RawMessage, path string, jsonSchema bool) {
|
||||
var format dto.FormatJsonSchema
|
||||
if err := kitutil.Unmarshal(rawFormat, &format); err != nil {
|
||||
return
|
||||
}
|
||||
misfits := schemaMisfits(format.Schema)
|
||||
if len(misfits) > 0 && jsonSchema {
|
||||
// The raw member keeps the client's key order.
|
||||
var raw struct {
|
||||
Schema json.RawMessage `json:"schema"`
|
||||
}
|
||||
if err := kitutil.Unmarshal(rawFormat, &raw); err == nil {
|
||||
config.ResponseJsonSchema = raw.Schema
|
||||
return
|
||||
}
|
||||
}
|
||||
config.ResponseSchema = RemoveAdditionalProperties(format.Schema, 0)
|
||||
if len(misfits) > 0 {
|
||||
convdiag.Add(ctx, types.ConversionDiagnostic{
|
||||
Code: "gemini_schema_keyword_dropped",
|
||||
Path: path,
|
||||
Message: fmt.Sprintf("Gemini response schema cannot carry %s", strings.Join(misfits, ", ")),
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// schemaMisfits lists what Gemini's typed Schema cannot hold of a JSON Schema
|
||||
// as written: keywords outside its fields, type arrays, non-string enum
|
||||
// values, tuple items, boolean schemas, and nesting deeper than
|
||||
// CleanFunctionParameters keeps. additionalProperties and $schema are not
|
||||
// misfits: agent tools and OpenAI strict schemas carry them on nearly every
|
||||
// object, and dropping them as before keeps those requests unchanged for
|
||||
// upstreams that do not pass the JSON Schema fields through.
|
||||
func schemaMisfits(schema any) []string {
|
||||
found := make(map[string]struct{})
|
||||
collectSchemaMisfits(schema, 0, found)
|
||||
misfits := make([]string, 0, len(found))
|
||||
for misfit := range found {
|
||||
misfits = append(misfits, misfit)
|
||||
}
|
||||
slices.Sort(misfits)
|
||||
return misfits
|
||||
}
|
||||
|
||||
func collectSchemaMisfits(schema any, depth int, found map[string]struct{}) {
|
||||
if depth > geminiFunctionSchemaMaxDepth {
|
||||
found[fmt.Sprintf("nesting deeper than %d levels", geminiFunctionSchemaMaxDepth)] = struct{}{}
|
||||
return
|
||||
}
|
||||
value, ok := schema.(map[string]any)
|
||||
if !ok {
|
||||
if _, isBool := schema.(bool); isBool {
|
||||
found["boolean schemas"] = struct{}{}
|
||||
}
|
||||
return
|
||||
}
|
||||
for key, item := range value {
|
||||
if _, allowed := geminiOpenAPISchemaAllowedFields[key]; !allowed {
|
||||
if key != "additionalProperties" && key != "$schema" {
|
||||
found[key] = struct{}{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "type":
|
||||
if _, isArray := item.([]any); isArray {
|
||||
found["type arrays"] = struct{}{}
|
||||
}
|
||||
case "enum":
|
||||
values, _ := item.([]any)
|
||||
if slices.ContainsFunc(values, func(value any) bool {
|
||||
_, isString := value.(string)
|
||||
return !isString
|
||||
}) {
|
||||
found["non-string enum values"] = struct{}{}
|
||||
}
|
||||
case "properties":
|
||||
properties, _ := item.(map[string]any)
|
||||
for _, property := range properties {
|
||||
collectSchemaMisfits(property, depth+1, found)
|
||||
}
|
||||
case "items":
|
||||
entries, isTuple := item.([]any)
|
||||
if !isTuple {
|
||||
collectSchemaMisfits(item, depth+1, found)
|
||||
continue
|
||||
}
|
||||
found["tuple items"] = struct{}{}
|
||||
for _, entry := range entries {
|
||||
collectSchemaMisfits(entry, depth+1, found)
|
||||
}
|
||||
case "anyOf":
|
||||
variants, _ := item.([]any)
|
||||
for _, variant := range variants {
|
||||
collectSchemaMisfits(variant, depth+1, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveAdditionalProperties(schema interface{}, depth int) interface{} {
|
||||
if depth >= 5 {
|
||||
return schema
|
||||
@@ -254,3 +430,112 @@ func OpenAIToolChoiceToConfig(toolChoice any) *dto.ToolConfig {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// geminiSchemaInt64Fields are Gemini Schema bounds typed int64, which the
|
||||
// proto JSON encoding may send as strings.
|
||||
var geminiSchemaInt64Fields = map[string]struct{}{
|
||||
"maxItems": {},
|
||||
"minItems": {},
|
||||
"maxLength": {},
|
||||
"minLength": {},
|
||||
"maxProperties": {},
|
||||
"minProperties": {},
|
||||
}
|
||||
|
||||
// OpenAPISchemaToJSONSchema rewrites a schema in Gemini's OpenAPI subset as
|
||||
// JSON Schema: type names are lowercased, nullable becomes a "null" type,
|
||||
// example becomes examples, and int64 bounds sent as strings become numbers.
|
||||
// It also returns the Gemini-only keywords JSON Schema cannot express, which
|
||||
// are dropped (propertyOrdering).
|
||||
func OpenAPISchemaToJSONSchema(schema any) (any, []string) {
|
||||
dropped := make(map[string]struct{})
|
||||
converted := openAPISchemaToJSONSchema(schema, 0, dropped)
|
||||
keywords := make([]string, 0, len(dropped))
|
||||
for keyword := range dropped {
|
||||
keywords = append(keywords, keyword)
|
||||
}
|
||||
slices.Sort(keywords)
|
||||
return converted, keywords
|
||||
}
|
||||
|
||||
func openAPISchemaToJSONSchema(schema any, depth int, dropped map[string]struct{}) any {
|
||||
value, ok := schema.(map[string]any)
|
||||
if !ok || depth >= geminiFunctionSchemaMaxDepth {
|
||||
return schema
|
||||
}
|
||||
converted := make(map[string]any, len(value))
|
||||
nullable := false
|
||||
for key, item := range value {
|
||||
switch key {
|
||||
case "type":
|
||||
typeName, isString := item.(string)
|
||||
if !isString {
|
||||
converted[key] = item
|
||||
continue
|
||||
}
|
||||
typeName = strings.ToLower(strings.TrimSpace(typeName))
|
||||
if typeName != "" && typeName != "type_unspecified" {
|
||||
converted[key] = typeName
|
||||
}
|
||||
case "nullable":
|
||||
nullable, _ = item.(bool)
|
||||
case "propertyOrdering":
|
||||
dropped[key] = struct{}{}
|
||||
case "example":
|
||||
if _, exists := value["examples"]; exists {
|
||||
dropped[key] = struct{}{}
|
||||
continue
|
||||
}
|
||||
converted["examples"] = []any{item}
|
||||
case "properties":
|
||||
properties, isMap := item.(map[string]any)
|
||||
if !isMap {
|
||||
converted[key] = item
|
||||
continue
|
||||
}
|
||||
convertedProperties := make(map[string]any, len(properties))
|
||||
for name, property := range properties {
|
||||
convertedProperties[name] = openAPISchemaToJSONSchema(property, depth+1, dropped)
|
||||
}
|
||||
converted[key] = convertedProperties
|
||||
case "items":
|
||||
converted[key] = openAPISchemaToJSONSchema(item, depth+1, dropped)
|
||||
case "anyOf":
|
||||
variants, isArray := item.([]any)
|
||||
if !isArray {
|
||||
converted[key] = item
|
||||
continue
|
||||
}
|
||||
convertedVariants := make([]any, len(variants))
|
||||
for index, variant := range variants {
|
||||
convertedVariants[index] = openAPISchemaToJSONSchema(variant, depth+1, dropped)
|
||||
}
|
||||
converted[key] = convertedVariants
|
||||
default:
|
||||
if _, isInt64 := geminiSchemaInt64Fields[key]; isInt64 {
|
||||
if text, isString := item.(string); isString {
|
||||
if number, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64); err == nil {
|
||||
converted[key] = number
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
converted[key] = item
|
||||
}
|
||||
}
|
||||
if !nullable {
|
||||
return converted
|
||||
}
|
||||
switch typed := converted["type"].(type) {
|
||||
case string:
|
||||
converted["type"] = []any{typed, "null"}
|
||||
default:
|
||||
if variants, isArray := converted["anyOf"].([]any); isArray {
|
||||
converted["anyOf"] = append(variants, map[string]any{"type": "null"})
|
||||
}
|
||||
}
|
||||
if values, isArray := converted["enum"].([]any); isArray && !slices.Contains(values, nil) {
|
||||
converted["enum"] = append(values, nil)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
@@ -142,11 +143,11 @@ func extractOpenAIResponsesRequest(request any) (any, Set, error) {
|
||||
return nil, Set{}, fmt.Errorf("invalid Responses tools: %w", err)
|
||||
}
|
||||
for index, rawTool := range rawTools {
|
||||
definition, err := decodeOpenAIResponsesDefinition(rawTool)
|
||||
definitions, err := decodeOpenAIResponsesDefinitions(rawTool)
|
||||
if err != nil {
|
||||
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
|
||||
}
|
||||
set.Definitions = append(set.Definitions, definition)
|
||||
set.Definitions = append(set.Definitions, definitions...)
|
||||
}
|
||||
}
|
||||
choice, err := decodeOpenAIResponsesChoice(source.ToolChoice)
|
||||
@@ -155,11 +156,41 @@ func extractOpenAIResponsesRequest(request any) (any, Set, error) {
|
||||
}
|
||||
set.Choice = choice
|
||||
|
||||
input, additionalDefinitions, additionalDiagnostics, err := extractOpenAIResponsesAdditionalTools(source.Input)
|
||||
if err != nil {
|
||||
return nil, Set{}, err
|
||||
}
|
||||
set.Definitions = append(set.Definitions, additionalDefinitions...)
|
||||
set.Diagnostics = append(set.Diagnostics, additionalDiagnostics...)
|
||||
// Flattened namespace names can collide with other tools.
|
||||
seenNames := make(map[string]struct{}, len(set.Definitions))
|
||||
seenNamespaces := make(map[string]struct{})
|
||||
definitions := set.Definitions[:0]
|
||||
for _, definition := range set.Definitions {
|
||||
if _, seen := seenNamespaces[definition.Namespace]; !seen && definition.Namespace != "" && definition.Namespace != convmeta.DefaultResponsesToolNamespace {
|
||||
seenNamespaces[definition.Namespace] = struct{}{}
|
||||
set.Diagnostics = append(set.Diagnostics, presentationLoss("tools", "tool_namespace_flattened",
|
||||
fmt.Sprintf("tools in namespace %q are sent as functions named %q", definition.Namespace, convmeta.NamespacedToolName(definition.Namespace, "<name>"))))
|
||||
}
|
||||
name := definition.Name
|
||||
_, duplicate := seenNames[name]
|
||||
if definition.Namespace != "" && name != "" && duplicate {
|
||||
set.Diagnostics = append(set.Diagnostics, semanticLoss("tools", "namespaced_tool_dropped",
|
||||
fmt.Sprintf("tool %q from namespace %q shares its flattened name with another tool and was omitted", name, definition.Namespace)))
|
||||
continue
|
||||
}
|
||||
if name != "" {
|
||||
seenNames[name] = struct{}{}
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
set.Definitions = definitions
|
||||
|
||||
clone := *source
|
||||
clone.Tools = nil
|
||||
clone.ToolChoice = nil
|
||||
clone.ParallelToolCalls = nil
|
||||
sanitizedInput, history, err := extractOpenAIResponsesHostedHistory(source.Input)
|
||||
sanitizedInput, history, err := extractOpenAIResponsesHostedHistory(input)
|
||||
if err != nil {
|
||||
return nil, Set{}, err
|
||||
}
|
||||
@@ -168,6 +199,103 @@ func extractOpenAIResponsesRequest(request any) (any, Set, error) {
|
||||
return &clone, set, nil
|
||||
}
|
||||
|
||||
// extractOpenAIResponsesAdditionalTools removes the additional_tools input
|
||||
// items Codex uses to declare tools and returns their definitions. No target
|
||||
// protocol has an input item that declares tools, so they join the top-level
|
||||
// tool list.
|
||||
func extractOpenAIResponsesAdditionalTools(input json.RawMessage) (json.RawMessage, []Definition, []types.ConversionDiagnostic, error) {
|
||||
if len(input) == 0 || kitutil.GetJsonType(input) != "array" {
|
||||
return input, nil, nil, nil
|
||||
}
|
||||
var rawItems []json.RawMessage
|
||||
if err := kitutil.Unmarshal(input, &rawItems); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("invalid Responses input: %w", err)
|
||||
}
|
||||
var (
|
||||
filtered []json.RawMessage
|
||||
definitions []Definition
|
||||
diagnostics []types.ConversionDiagnostic
|
||||
)
|
||||
for index, rawItem := range rawItems {
|
||||
var item struct {
|
||||
Type string `json:"type"`
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
}
|
||||
if err := kitutil.Unmarshal(rawItem, &item); err != nil || strings.TrimSpace(item.Type) != "additional_tools" {
|
||||
filtered = append(filtered, rawItem)
|
||||
continue
|
||||
}
|
||||
for toolIndex, rawTool := range item.Tools {
|
||||
decoded, err := decodeOpenAIResponsesDefinitions(rawTool)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("input[%d].tools[%d]: %w", index, toolIndex, err)
|
||||
}
|
||||
definitions = append(definitions, decoded...)
|
||||
}
|
||||
diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("input[%d]", index), "additional_tools_flattened",
|
||||
"additional_tools items are sent as top-level tools; their position in the input is not preserved"))
|
||||
}
|
||||
if len(diagnostics) == 0 {
|
||||
return input, nil, nil, nil
|
||||
}
|
||||
encoded, err := kitutil.Marshal(filtered)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return encoded, definitions, diagnostics, nil
|
||||
}
|
||||
|
||||
// decodeOpenAIResponsesDefinitions decodes one Responses tool. A namespace
|
||||
// tool expands into its member tools, each renamed with
|
||||
// convmeta.NamespacedToolName and described with the namespace description.
|
||||
func decodeOpenAIResponsesDefinitions(raw json.RawMessage) ([]Definition, error) {
|
||||
var namespace struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tools []map[string]any `json:"tools"`
|
||||
}
|
||||
if err := kitutil.Unmarshal(raw, &namespace); err != nil || strings.TrimSpace(namespace.Type) != "namespace" {
|
||||
definition, err := decodeOpenAIResponsesDefinition(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []Definition{definition}, nil
|
||||
}
|
||||
namespaceName := strings.TrimSpace(namespace.Name)
|
||||
if namespaceName == "" {
|
||||
return nil, fmt.Errorf("namespace tool requires name")
|
||||
}
|
||||
definitions := make([]Definition, 0, len(namespace.Tools))
|
||||
for index, tool := range namespace.Tools {
|
||||
name := strings.TrimSpace(kitutil.Interface2String(tool["name"]))
|
||||
if name != "" {
|
||||
tool["name"] = convmeta.NamespacedToolName(namespaceName, name)
|
||||
}
|
||||
description := strings.TrimSpace(kitutil.Interface2String(tool["description"]))
|
||||
if namespaceDescription := strings.TrimSpace(namespace.Description); namespaceDescription != "" {
|
||||
description = strings.TrimSpace(namespaceDescription + "\n\n" + description)
|
||||
}
|
||||
if description != "" {
|
||||
tool["description"] = description
|
||||
}
|
||||
encoded, err := kitutil.Marshal(tool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definition, err := decodeOpenAIResponsesDefinition(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("namespace %q tools[%d]: %w", namespaceName, index, err)
|
||||
}
|
||||
definition.Namespace = namespaceName
|
||||
if definition.Name == "" {
|
||||
definition.Name = kitutil.Interface2String(tool["name"])
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
return definitions, nil
|
||||
}
|
||||
|
||||
func extractClaudeRequest(request any) (any, Set, error) {
|
||||
source, ok := request.(*dto.ClaudeRequest)
|
||||
if !ok {
|
||||
@@ -428,9 +556,10 @@ func decodeGeminiDefinitions(raw json.RawMessage) ([]Definition, error) {
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
|
||||
Raw: functionRaw,
|
||||
Function: &Function{
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
|
||||
Description: kitutil.Interface2String(function["description"]),
|
||||
Parameters: parameters,
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
|
||||
Description: kitutil.Interface2String(function["description"]),
|
||||
Parameters: parameters,
|
||||
OpenAPISchema: function["parameters"] != nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -610,6 +739,7 @@ func decodeOpenAIResponsesChoice(raw json.RawMessage) (*Choice, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("Responses function tool_choice requires name")
|
||||
}
|
||||
name = convmeta.NamespacedToolName(strings.TrimSpace(kitutil.Interface2String(value["namespace"])), name)
|
||||
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
|
||||
}
|
||||
if isOpenAIResponsesWebSearchType(toolType) {
|
||||
|
||||
@@ -24,16 +24,17 @@ func AttachRequest(format types.RelayFormat, request any, set Set, options *conv
|
||||
)
|
||||
switch format {
|
||||
case types.RelayFormatOpenAI:
|
||||
value, diagnostics, err = attachOpenAIChatRequest(request, set)
|
||||
value, diagnostics, err = attachOpenAIChatRequest(request, set, options)
|
||||
case types.RelayFormatOpenAIResponses:
|
||||
value, diagnostics, err = attachOpenAIResponsesRequest(request, set)
|
||||
value, diagnostics, err = attachOpenAIResponsesRequest(request, set, options)
|
||||
case types.RelayFormatClaude:
|
||||
value, diagnostics, err = attachClaudeRequest(request, set, options)
|
||||
case types.RelayFormatGemini:
|
||||
value, diagnostics, err = attachGeminiRequest(request, set)
|
||||
value, diagnostics, err = attachGeminiRequest(request, set, options)
|
||||
default:
|
||||
value = request
|
||||
}
|
||||
diagnostics = append(set.Diagnostics, diagnostics...)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
@@ -47,24 +48,30 @@ func AttachRequest(format types.RelayFormat, request any, set Set, options *conv
|
||||
return value, diagnostics, nil
|
||||
}
|
||||
|
||||
func attachOpenAIChatRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
|
||||
func attachOpenAIChatRequest(request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
|
||||
target, ok := request.(*dto.GeneralOpenAIRequest)
|
||||
if !ok || target == nil {
|
||||
return nil, nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
|
||||
}
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
customTools, diagnostics := responsesCustomTools(set)
|
||||
// chatWebSearchEncoded reports that the host encoder handled the hosted
|
||||
// web search; it may have written a tools[] entry or top-level fields
|
||||
// instead of web_search_options.
|
||||
chatWebSearchEncoded := false
|
||||
for index, definition := range set.Definitions {
|
||||
switch definition.Kind {
|
||||
case KindFunction:
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
||||
diagnostics = append(diagnostics, schemaDiagnostics...)
|
||||
target.Tools = append(target.Tools, dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: definition.Function.Name,
|
||||
Description: definition.Function.Description,
|
||||
Parameters: definition.Function.Parameters,
|
||||
Parameters: parameters,
|
||||
Strict: definition.Function.Strict,
|
||||
},
|
||||
})
|
||||
@@ -72,23 +79,52 @@ func attachOpenAIChatRequest(request any, set Set) (any, []types.ConversionDiagn
|
||||
if set.Source == types.RelayFormatGemini {
|
||||
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatOpenAI)...)
|
||||
}
|
||||
if target.WebSearchOptions != nil {
|
||||
return nil, diagnostics, fmt.Errorf("tools[%d]: multiple hosted web-search definitions cannot be represented by Chat Completions", index)
|
||||
if target.WebSearchOptions != nil || chatWebSearchEncoded {
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"duplicate_hosted_web_search",
|
||||
"Chat Completions carries one hosted web search; this definition was dropped",
|
||||
))
|
||||
continue
|
||||
}
|
||||
options := &dto.WebSearchOptions{}
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatOpenAI)
|
||||
call.Chat = target
|
||||
tool, encoderDiagnostics, handled, err := encodeWebSearch(call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
diagnostics = append(diagnostics, encoderDiagnostics...)
|
||||
chatWebSearchEncoded = true
|
||||
if tool != nil {
|
||||
chatTool, ok := tool.(dto.ToolCallRequest)
|
||||
if !ok {
|
||||
return nil, diagnostics, fmt.Errorf("%s: Chat web-search encoder returned %T, want dto.ToolCallRequest", call.Path(), tool)
|
||||
}
|
||||
target.Tools = append(target.Tools, chatTool)
|
||||
}
|
||||
continue
|
||||
}
|
||||
webSearchOptions := &dto.WebSearchOptions{}
|
||||
if definition.WebSearch != nil {
|
||||
options.SearchContextSize = definition.WebSearch.SearchContextSize
|
||||
webSearchOptions.SearchContextSize = definition.WebSearch.SearchContextSize
|
||||
if definition.WebSearch.Location != nil {
|
||||
location := map[string]any{
|
||||
"type": "approximate",
|
||||
"approximate": locationMap(definition.WebSearch.Location),
|
||||
}
|
||||
options.UserLocation, _ = kitutil.Marshal(location)
|
||||
webSearchOptions.UserLocation, _ = kitutil.Marshal(location)
|
||||
}
|
||||
diagnostics = append(diagnostics, openAIChatWebSearchDiagnostics(index, definition.WebSearch)...)
|
||||
}
|
||||
target.WebSearchOptions = options
|
||||
target.WebSearchOptions = webSearchOptions
|
||||
default:
|
||||
if tool, handled := customTools[index]; handled {
|
||||
if tool != nil {
|
||||
target.Tools = append(target.Tools, tool.chatTool())
|
||||
}
|
||||
continue
|
||||
}
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"unsupported_hosted_tool",
|
||||
@@ -97,17 +133,281 @@ func attachOpenAIChatRequest(request any, set Set) (any, []types.ConversionDiagn
|
||||
}
|
||||
}
|
||||
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatOpenAI)
|
||||
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatOpenAI, set.History)...)
|
||||
if chatWebSearchEncoded && forcedWebSearch(set.Choice) {
|
||||
// The encoder received Forced and expressed it in its own encoding.
|
||||
set.Choice = nil
|
||||
}
|
||||
if len(target.Tools) == 0 {
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(responsesCustomToolChoice(set.Choice, customTools), types.RelayFormatOpenAI)
|
||||
choice, choiceDiagnostics := encodeOpenAIChatChoice(normalizedChoice)
|
||||
target.ToolChoice = choice
|
||||
target.ParallelTooCalls = set.ParallelAllowed
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatOpenAI, set.History)...)
|
||||
return target, diagnostics, nil
|
||||
}
|
||||
|
||||
func attachOpenAIResponsesRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
|
||||
// newWebSearchCall describes one hosted web-search definition for the host
|
||||
// encoder; the caller sets the typed target request.
|
||||
func newWebSearchCall(set Set, index int, definition Definition, target types.RelayFormat) convmeta.WebSearchCall {
|
||||
call := convmeta.WebSearchCall{
|
||||
Source: set.Source,
|
||||
Target: target,
|
||||
NativeType: definition.NativeType,
|
||||
Index: index,
|
||||
Forced: forcedWebSearch(set.Choice),
|
||||
}
|
||||
if definition.WebSearch != nil {
|
||||
call.Spec = *definition.WebSearch
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
func forcedWebSearch(choice *Choice) bool {
|
||||
return choice != nil && choice.Mode == ChoiceNamed && choice.Kind == KindWebSearch
|
||||
}
|
||||
|
||||
// encodeWebSearch runs the host web-search encoder for one definition.
|
||||
// handled is false when no encoder is set or it returned nil; the caller then
|
||||
// keeps its default encoding and default diagnostics. When handled, the
|
||||
// encoder's diagnostics replace the default ones and tool (possibly nil) is
|
||||
// what the caller appends.
|
||||
func encodeWebSearch(call convmeta.WebSearchCall, options *convmeta.Options) (tool any, diagnostics []types.ConversionDiagnostic, handled bool, err error) {
|
||||
if options == nil || options.WebSearch == nil {
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
encoding, err := options.WebSearch(call)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("%s: %w", call.Path(), err)
|
||||
}
|
||||
if encoding == nil {
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
if encoding.Omitted {
|
||||
return nil, append(encoding.Diagnostics, call.SemanticLoss(
|
||||
"hosted_web_search_omitted",
|
||||
"the upstream has no hosted web search for this protocol; the web search tool was omitted",
|
||||
)), true, nil
|
||||
}
|
||||
return encoding.Tool, encoding.Diagnostics, true, nil
|
||||
}
|
||||
|
||||
// appendEncodedWebSearch runs the host encoder for a Responses, Claude, or
|
||||
// Gemini target. When the encoder handled the definition, its diagnostics and
|
||||
// tool (if any) are appended and the caller skips its default encoding.
|
||||
func appendEncodedWebSearch(tools *[]any, diagnostics *[]types.ConversionDiagnostic, call convmeta.WebSearchCall, options *convmeta.Options) (bool, error) {
|
||||
tool, encoderDiagnostics, handled, err := encodeWebSearch(call, options)
|
||||
if err != nil || !handled {
|
||||
return false, err
|
||||
}
|
||||
*diagnostics = append(*diagnostics, encoderDiagnostics...)
|
||||
if tool != nil {
|
||||
*tools = append(*tools, tool)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// toolChoiceWithoutToolsDiagnostics reports the tool_choice and
|
||||
// parallel_tool_calls controls omitted because no tool reached the target;
|
||||
// upstreams reject either control in a request without tools.
|
||||
func toolChoiceWithoutToolsDiagnostics(set Set) []types.ConversionDiagnostic {
|
||||
if set.Choice == nil && set.ParallelAllowed == nil {
|
||||
return nil
|
||||
}
|
||||
return []types.ConversionDiagnostic{presentationLoss("tool_choice", "tool_choice_without_tools",
|
||||
"the target request has no function tools, so tool_choice and parallel_tool_calls were omitted")}
|
||||
}
|
||||
|
||||
// jsonSchemaParameters returns function parameters as JSON Schema for a
|
||||
// non-Gemini target, rewriting Gemini's OpenAPI schema subset.
|
||||
func jsonSchemaParameters(index int, function *Function) (any, []types.ConversionDiagnostic) {
|
||||
if !function.OpenAPISchema {
|
||||
return function.Parameters, nil
|
||||
}
|
||||
parameters, dropped := sharedgemini.OpenAPISchemaToJSONSchema(function.Parameters)
|
||||
if len(dropped) == 0 {
|
||||
return parameters, nil
|
||||
}
|
||||
return parameters, []types.ConversionDiagnostic{presentationLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"gemini_schema_keyword_dropped",
|
||||
fmt.Sprintf("JSON Schema has no equivalent for Gemini schema keywords %s; they were dropped", strings.Join(dropped, ", ")),
|
||||
)}
|
||||
}
|
||||
|
||||
// responsesCustomTool is a Responses custom (freeform) tool sent upstream as
|
||||
// a function whose only argument is the raw input string.
|
||||
type responsesCustomTool struct {
|
||||
name string
|
||||
description string
|
||||
}
|
||||
|
||||
// inputSchema is the parameters schema of the function that carries a custom
|
||||
// tool: a single required string argument holding the raw input.
|
||||
func (tool *responsesCustomTool) inputSchema() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
convmeta.CustomToolInputArgument: map[string]any{
|
||||
"type": "string",
|
||||
"description": "Raw input for the tool.",
|
||||
},
|
||||
},
|
||||
"required": []string{convmeta.CustomToolInputArgument},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
func (tool *responsesCustomTool) chatTool() dto.ToolCallRequest {
|
||||
return dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: tool.name,
|
||||
Description: tool.description,
|
||||
Parameters: tool.inputSchema(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const responsesCustomToolType = "custom"
|
||||
|
||||
// ResponsesCustomToolNames returns the Responses custom tools that
|
||||
// AttachRequest sends upstream as functions. The response side restores
|
||||
// function calls with these names as custom_tool_call items.
|
||||
func ResponsesCustomToolNames(set Set) map[string]struct{} {
|
||||
customTools, _ := responsesCustomTools(set)
|
||||
var names map[string]struct{}
|
||||
for _, tool := range customTools {
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
if names == nil {
|
||||
names = make(map[string]struct{}, len(customTools))
|
||||
}
|
||||
names[tool.name] = struct{}{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ResponsesToolNamespaces maps the upstream name of every tool flattened
|
||||
// from a non-default Responses tool namespace to that namespace. The response
|
||||
// side restores the namespace on calls to these tools.
|
||||
func ResponsesToolNamespaces(set Set) map[string]string {
|
||||
if set.Source != types.RelayFormatOpenAIResponses {
|
||||
return nil
|
||||
}
|
||||
var namespaces map[string]string
|
||||
for _, definition := range set.Definitions {
|
||||
if definition.Namespace == "" || definition.Namespace == convmeta.DefaultResponsesToolNamespace || definition.Name == "" {
|
||||
continue
|
||||
}
|
||||
if namespaces == nil {
|
||||
namespaces = make(map[string]string)
|
||||
}
|
||||
namespaces[definition.Name] = definition.Namespace
|
||||
}
|
||||
return namespaces
|
||||
}
|
||||
|
||||
// responsesCustomTools selects, by definition index, the Responses custom
|
||||
// tools that the upstream receives as functions. A nil entry marks a custom
|
||||
// tool that was dropped with a diagnostic; indexes without an entry fall back
|
||||
// to the generic unsupported-tool loss. Request encoding and
|
||||
// ResponsesCustomToolNames share this selection so the restored names always
|
||||
// match the functions that were sent.
|
||||
func responsesCustomTools(set Set) (map[int]*responsesCustomTool, []types.ConversionDiagnostic) {
|
||||
if set.Source != types.RelayFormatOpenAIResponses {
|
||||
return nil, nil
|
||||
}
|
||||
functionNames := make(map[string]struct{})
|
||||
for _, definition := range set.Definitions {
|
||||
if definition.Kind == KindFunction && definition.Function != nil {
|
||||
functionNames[definition.Function.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
var (
|
||||
tools map[int]*responsesCustomTool
|
||||
diagnostics []types.ConversionDiagnostic
|
||||
)
|
||||
for index, definition := range set.Definitions {
|
||||
if definition.Kind != KindNative || definition.NativeType != responsesCustomToolType {
|
||||
continue
|
||||
}
|
||||
tool := decodeResponsesCustomTool(definition.Raw)
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
if tools == nil {
|
||||
tools = make(map[int]*responsesCustomTool)
|
||||
}
|
||||
path := fmt.Sprintf("tools[%d]", index)
|
||||
if _, exists := functionNames[tool.name]; exists {
|
||||
tools[index] = nil
|
||||
diagnostics = append(diagnostics, semanticLoss(path, "custom_tool_name_conflict",
|
||||
fmt.Sprintf("custom tool %q shares its name with another tool, so the target protocol cannot tell their calls apart", tool.name)))
|
||||
continue
|
||||
}
|
||||
functionNames[tool.name] = struct{}{}
|
||||
tools[index] = tool
|
||||
diagnostics = append(diagnostics, presentationLoss(path, "custom_tool_as_function",
|
||||
fmt.Sprintf("the target protocol has no freeform tools; custom tool %q is sent as a function with one string argument and its input format becomes a description hint", tool.name)))
|
||||
}
|
||||
return tools, diagnostics
|
||||
}
|
||||
|
||||
func decodeResponsesCustomTool(raw json.RawMessage) *responsesCustomTool {
|
||||
var value map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &value); err != nil {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if description := strings.TrimSpace(kitutil.Interface2String(value["description"])); description != "" {
|
||||
parts = append(parts, description)
|
||||
}
|
||||
parts = append(parts, `This tool takes freeform text. Put the complete raw text in the "input" argument.`)
|
||||
if format, ok := value["format"].(map[string]any); ok && strings.TrimSpace(kitutil.Interface2String(format["type"])) == "grammar" {
|
||||
if definition := strings.TrimSpace(kitutil.Interface2String(format["definition"])); definition != "" {
|
||||
switch syntax := strings.TrimSpace(kitutil.Interface2String(format["syntax"])); syntax {
|
||||
case "lark":
|
||||
parts = append(parts, "The input must match this Lark grammar:\n"+definition)
|
||||
case "regex":
|
||||
parts = append(parts, "The input must match this regular expression:\n"+definition)
|
||||
default:
|
||||
parts = append(parts, fmt.Sprintf("The input must match this %s grammar:\n%s", syntax, definition))
|
||||
}
|
||||
}
|
||||
}
|
||||
return &responsesCustomTool{name: name, description: strings.Join(parts, "\n\n")}
|
||||
}
|
||||
|
||||
// responsesCustomToolChoice rewrites a Responses {"type":"custom"} tool choice
|
||||
// into a named function choice when that custom tool was sent as a function.
|
||||
// Other choices are returned unchanged.
|
||||
func responsesCustomToolChoice(choice *Choice, customTools map[int]*responsesCustomTool) *Choice {
|
||||
if choice == nil || choice.Mode != ChoiceOpaque || choice.NativeType != responsesCustomToolType {
|
||||
return choice
|
||||
}
|
||||
var value map[string]any
|
||||
if err := kitutil.Unmarshal(choice.Raw, &value); err != nil {
|
||||
return choice
|
||||
}
|
||||
name := convmeta.NamespacedToolName(strings.TrimSpace(kitutil.Interface2String(value["namespace"])), strings.TrimSpace(kitutil.Interface2String(value["name"])))
|
||||
for _, tool := range customTools {
|
||||
if tool != nil && tool.name == name {
|
||||
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}
|
||||
}
|
||||
}
|
||||
return choice
|
||||
}
|
||||
|
||||
func attachOpenAIResponsesRequest(request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
|
||||
target, ok := request.(*dto.OpenAIResponsesRequest)
|
||||
if !ok || target == nil {
|
||||
return nil, nil, fmt.Errorf("expected OpenAI Responses request, got %T", request)
|
||||
@@ -120,11 +420,13 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
||||
diagnostics = append(diagnostics, schemaDiagnostics...)
|
||||
tool := map[string]any{
|
||||
"type": "function",
|
||||
"name": definition.Function.Name,
|
||||
"description": definition.Function.Description,
|
||||
"parameters": definition.Function.Parameters,
|
||||
"parameters": parameters,
|
||||
}
|
||||
if definition.Function.Strict != nil {
|
||||
tool["strict"] = *definition.Function.Strict
|
||||
@@ -135,6 +437,15 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
if set.Source == types.RelayFormatGemini {
|
||||
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatOpenAIResponses)...)
|
||||
}
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatOpenAIResponses)
|
||||
call.Responses = target
|
||||
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
webSearch := definition.WebSearch
|
||||
toolType := "web_search"
|
||||
if set.Source == types.RelayFormatOpenAIResponses && definition.NativeType != "" {
|
||||
@@ -182,13 +493,19 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
))
|
||||
}
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
encoded, err := kitutil.Marshal(tools)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
target.Tools = encoded
|
||||
historyDiagnostics, err := appendHostedHistoryToOpenAIResponses(target, set)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
diagnostics = append(diagnostics, historyDiagnostics...)
|
||||
if len(tools) == 0 {
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
encoded, err := kitutil.Marshal(tools)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
target.Tools = encoded
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatOpenAIResponses)
|
||||
choice, choiceDiagnostics, err := encodeOpenAIResponsesChoice(normalizedChoice, set.Source)
|
||||
if err != nil {
|
||||
@@ -200,11 +517,6 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
}
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
historyDiagnostics, err := appendHostedHistoryToOpenAIResponses(target, set)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
diagnostics = append(diagnostics, historyDiagnostics...)
|
||||
return target, diagnostics, nil
|
||||
}
|
||||
|
||||
@@ -214,14 +526,16 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
return nil, nil, fmt.Errorf("expected Claude Messages request, got %T", request)
|
||||
}
|
||||
tools := make([]any, 0, len(set.Definitions))
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
customTools, diagnostics := responsesCustomTools(set)
|
||||
for index, definition := range set.Definitions {
|
||||
switch definition.Kind {
|
||||
case KindFunction:
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
inputSchema, err := functionParametersMap(definition.Function.Parameters)
|
||||
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
||||
diagnostics = append(diagnostics, schemaDiagnostics...)
|
||||
inputSchema, err := functionParametersMap(parameters)
|
||||
if err != nil {
|
||||
return nil, diagnostics, fmt.Errorf("tools[%d].input_schema: %w", index, err)
|
||||
}
|
||||
@@ -235,6 +549,15 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
if set.Source == types.RelayFormatGemini {
|
||||
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatClaude)...)
|
||||
}
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatClaude)
|
||||
call.Claude = target
|
||||
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
toolType := "web_search_20250305"
|
||||
if set.Source == types.RelayFormatClaude && isKnownClaudeWebSearchType(definition.NativeType) {
|
||||
toolType = definition.NativeType
|
||||
@@ -285,6 +608,16 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
tools = append(tools, tool)
|
||||
continue
|
||||
}
|
||||
if tool, handled := customTools[index]; handled {
|
||||
if tool != nil {
|
||||
tools = append(tools, &dto.Tool{
|
||||
Name: tool.name,
|
||||
Description: tool.description,
|
||||
InputSchema: tool.inputSchema(),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"unsupported_hosted_tool",
|
||||
@@ -292,23 +625,33 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
))
|
||||
}
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
target.Tools = tools
|
||||
}
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatClaude)
|
||||
choice, choiceDiagnostics := encodeClaudeChoice(normalizedChoice, set.ParallelAllowed, set.Source)
|
||||
target.ToolChoice = choice
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
historyDiagnostics, err := appendHostedHistoryToClaude(target, set)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
diagnostics = append(diagnostics, historyDiagnostics...)
|
||||
if len(tools) == 0 {
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
target.Tools = tools
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(responsesCustomToolChoice(set.Choice, customTools), types.RelayFormatClaude)
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
forced := normalizedChoice != nil && (normalizedChoice.Mode == ChoiceRequired || normalizedChoice.Mode == ChoiceNamed)
|
||||
manualThinking := target.Thinking != nil && target.Thinking.Type == "enabled"
|
||||
if forced && manualThinking && set.Source != types.RelayFormatClaude {
|
||||
downgraded := *normalizedChoice
|
||||
downgraded.Mode = ChoiceAuto
|
||||
normalizedChoice = &downgraded
|
||||
diagnostics = append(diagnostics, semanticLoss("tool_choice", "forced_tool_choice_downgraded",
|
||||
"Claude rejects forced tool use while manual extended thinking is enabled; tool_choice was sent as auto, so the model may answer without calling a tool"))
|
||||
}
|
||||
choice, choiceDiagnostics := encodeClaudeChoice(normalizedChoice, set.ParallelAllowed, set.Source)
|
||||
target.ToolChoice = choice
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
return target, diagnostics, nil
|
||||
}
|
||||
|
||||
func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
|
||||
func attachGeminiRequest(request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
|
||||
target, ok := request.(*dto.GeminiChatRequest)
|
||||
if !ok || target == nil {
|
||||
return nil, nil, fmt.Errorf("expected Gemini generateContent request, got %T", request)
|
||||
@@ -334,33 +677,34 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
return target, unsupportedHostedHistoryDiagnostics(types.RelayFormatGemini, set.History), nil
|
||||
}
|
||||
var (
|
||||
functions []map[string]any
|
||||
tools []map[string]any
|
||||
diagnostics []types.ConversionDiagnostic
|
||||
functions []map[string]any
|
||||
tools []any
|
||||
)
|
||||
jsonSchema := options != nil && options.Gemini.SupportsJSONSchemaModel(set.Model)
|
||||
customTools, diagnostics := responsesCustomTools(set)
|
||||
for index, definition := range set.Definitions {
|
||||
switch definition.Kind {
|
||||
case KindFunction:
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
parameters := definition.Function.Parameters
|
||||
parametersField, parameters := "parameters", definition.Function.Parameters
|
||||
if parameters != nil {
|
||||
cloned, err := kitutil.Any2Type[any](parameters)
|
||||
if err != nil {
|
||||
return nil, diagnostics, fmt.Errorf("tools[%d].parameters: %w", index, err)
|
||||
}
|
||||
if params, ok := cloned.(map[string]any); ok {
|
||||
if properties, exists := params["properties"].(map[string]any); exists && len(properties) == 0 {
|
||||
cloned = nil
|
||||
}
|
||||
var misfits []string
|
||||
parametersField, parameters, misfits = sharedgemini.FunctionParameters(cloned, jsonSchema)
|
||||
if len(misfits) > 0 {
|
||||
diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("tools[%d].parameters", index), "gemini_schema_keyword_dropped",
|
||||
fmt.Sprintf("Gemini function parameters cannot carry %s", strings.Join(misfits, ", "))))
|
||||
}
|
||||
parameters = sharedgemini.CleanFunctionParameters(cloned)
|
||||
}
|
||||
function := map[string]any{
|
||||
"name": definition.Function.Name,
|
||||
"description": definition.Function.Description,
|
||||
"parameters": parameters,
|
||||
"name": definition.Function.Name,
|
||||
"description": definition.Function.Description,
|
||||
parametersField: parameters,
|
||||
}
|
||||
deleteEmptyStrings(function)
|
||||
functions = append(functions, function)
|
||||
@@ -368,6 +712,15 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("tools[%d].strict", index), "unsupported_function_strict", "Gemini does not expose OpenAI function strictness"))
|
||||
}
|
||||
case KindWebSearch:
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatGemini)
|
||||
call.Gemini = target
|
||||
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
if set.Source == types.RelayFormatGemini && len(definition.Raw) > 0 {
|
||||
var tool map[string]any
|
||||
if err := kitutil.Unmarshal(definition.Raw, &tool); err != nil {
|
||||
@@ -401,19 +754,18 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
tools = append(tools, tool)
|
||||
continue
|
||||
}
|
||||
// The established Responses-to-Gemini compatibility path removes
|
||||
// free-form/unknown tools together with custom call history in
|
||||
// PrepareOpenAIResponsesRequest. Keep that explicit downgrade as a
|
||||
// diagnostic; other opaque tools may be server-executed and remain a
|
||||
// semantic loss under the default Safe policy.
|
||||
if set.Source == types.RelayFormatOpenAIResponses && (definition.NativeType == "custom" || definition.NativeType == "unknown") {
|
||||
diagnostics = append(diagnostics, presentationLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"custom_tool_omitted",
|
||||
"Gemini cannot represent this OpenAI free-form or unknown tool; its preprocessed call history and definition were omitted",
|
||||
))
|
||||
if tool, handled := customTools[index]; handled {
|
||||
if tool != nil {
|
||||
functions = append(functions, map[string]any{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": sharedgemini.CleanFunctionParameters(tool.inputSchema()),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Other opaque tools may be server-executed and remain a semantic
|
||||
// loss under the default Safe policy.
|
||||
if set.Source == types.RelayFormatOpenAIResponses && definition.Kind == KindNative {
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
@@ -439,7 +791,11 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
}
|
||||
target.Tools = encoded
|
||||
}
|
||||
config, choiceDiagnostics := encodeGeminiChoice(set.Choice)
|
||||
if len(functions) == 0 {
|
||||
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatGemini, set.History)...)
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
config, choiceDiagnostics := encodeGeminiChoice(responsesCustomToolChoice(set.Choice, customTools))
|
||||
target.ToolConfig = config
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
if set.ParallelAllowed != nil && !*set.ParallelAllowed {
|
||||
|
||||
@@ -3,6 +3,7 @@ package toolconv
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
@@ -33,26 +34,18 @@ type Function struct {
|
||||
Description string
|
||||
Parameters any
|
||||
Strict *bool
|
||||
// OpenAPISchema marks Parameters written in Gemini's OpenAPI schema
|
||||
// subset (uppercase type names, nullable, propertyOrdering) rather than
|
||||
// JSON Schema.
|
||||
OpenAPISchema bool
|
||||
}
|
||||
|
||||
type ApproximateLocation struct {
|
||||
City string
|
||||
Region string
|
||||
Country string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
type WebSearch struct {
|
||||
Location *ApproximateLocation
|
||||
AllowedDomains []string
|
||||
BlockedDomains []string
|
||||
SearchContextSize string
|
||||
MaxUses *int
|
||||
AllowedCallers []string
|
||||
ResponseInclusion string
|
||||
ExternalWebAccess *bool
|
||||
ReturnTokenBudget json.RawMessage
|
||||
}
|
||||
// ApproximateLocation and WebSearch are the convmeta types, so a host
|
||||
// web-search encoder receives the decoded specification unchanged.
|
||||
type (
|
||||
ApproximateLocation = convmeta.ApproximateLocation
|
||||
WebSearch = convmeta.WebSearch
|
||||
)
|
||||
|
||||
type Definition struct {
|
||||
Kind Kind
|
||||
@@ -63,6 +56,9 @@ type Definition struct {
|
||||
WebSearch *WebSearch
|
||||
Raw json.RawMessage
|
||||
Group int
|
||||
// Namespace is the Responses tool namespace the definition was declared
|
||||
// in. Name and Function.Name already hold the flattened upstream name.
|
||||
Namespace string
|
||||
}
|
||||
|
||||
type ChoiceMode string
|
||||
@@ -92,10 +88,16 @@ type Set struct {
|
||||
ParallelAllowed *bool
|
||||
NativeToolConfig json.RawMessage
|
||||
History []HostedHistoryItem
|
||||
// Diagnostics reports losses found while extracting the definitions; they
|
||||
// are returned with the attach diagnostics.
|
||||
Diagnostics []types.ConversionDiagnostic
|
||||
// Model is the upstream model the definitions are attached for; target
|
||||
// capability checks read it. The caller of AttachRequest sets it.
|
||||
Model string
|
||||
}
|
||||
|
||||
func (s Set) Empty() bool {
|
||||
return len(s.Definitions) == 0 && s.Choice == nil && s.ParallelAllowed == nil && len(s.NativeToolConfig) == 0 && len(s.History) == 0
|
||||
return len(s.Definitions) == 0 && s.Choice == nil && s.ParallelAllowed == nil && len(s.NativeToolConfig) == 0 && len(s.History) == 0 && len(s.Diagnostics) == 0
|
||||
}
|
||||
|
||||
type HostedHistoryItem struct {
|
||||
|
||||
@@ -101,3 +101,181 @@ func TestSafePolicyRejectsRequestPhaseHostedToolLoss(t *testing.T) {
|
||||
assert.True(t, hasDiagnosticCode(loss.Diagnostics, "unsupported_hosted_tool"))
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
|
||||
}
|
||||
|
||||
func codexResponsesToolsRequest(t *testing.T, toolChoice any, extraTools ...map[string]any) *dto.OpenAIResponsesRequest {
|
||||
t.Helper()
|
||||
tools := []map[string]any{
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "exec",
|
||||
"description": "Run code.",
|
||||
"format": map[string]any{"type": "grammar", "syntax": "lark", "definition": "start: /.+/"},
|
||||
},
|
||||
{"type": "function", "name": "wait", "parameters": map[string]any{"type": "object"}},
|
||||
}
|
||||
tools = append(tools, extraTools...)
|
||||
rawTools, err := kitutil.Marshal(tools)
|
||||
require.NoError(t, err)
|
||||
request := &dto.OpenAIResponsesRequest{Model: "gpt-test", Tools: rawTools}
|
||||
if toolChoice != nil {
|
||||
request.ToolChoice, err = kitutil.Marshal(toolChoice)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
// responsesCustomToolTargets are the upstream protocols that receive a
|
||||
// Responses custom tool as a function taking one string argument.
|
||||
var responsesCustomToolTargets = []struct {
|
||||
format types.RelayFormat
|
||||
request func() any
|
||||
}{
|
||||
{types.RelayFormatOpenAI, func() any { return &dto.GeneralOpenAIRequest{Model: "gpt-test"} }},
|
||||
{types.RelayFormatClaude, func() any { return &dto.ClaudeRequest{Model: "claude-test"} }},
|
||||
{types.RelayFormatGemini, func() any { return &dto.GeminiChatRequest{} }},
|
||||
}
|
||||
|
||||
// attachedFunctions returns the function declarations and the tool choice
|
||||
// that AttachRequest wrote into a Chat, Claude, or Gemini request, decoded
|
||||
// from their wire JSON.
|
||||
func attachedFunctions(t *testing.T, out any) ([]map[string]any, any) {
|
||||
t.Helper()
|
||||
var (
|
||||
functions []map[string]any
|
||||
choice any
|
||||
err error
|
||||
)
|
||||
switch target := out.(type) {
|
||||
case *dto.GeneralOpenAIRequest:
|
||||
for _, tool := range target.Tools {
|
||||
function, err := kitutil.Any2Type[map[string]any](tool.Function)
|
||||
require.NoError(t, err)
|
||||
functions = append(functions, function)
|
||||
}
|
||||
choice, err = kitutil.Any2Type[any](target.ToolChoice)
|
||||
case *dto.ClaudeRequest:
|
||||
functions, err = kitutil.Any2Type[[]map[string]any](target.Tools)
|
||||
require.NoError(t, err)
|
||||
choice, err = kitutil.Any2Type[any](target.ToolChoice)
|
||||
case *dto.GeminiChatRequest:
|
||||
var groups []struct {
|
||||
FunctionDeclarations []map[string]any `json:"functionDeclarations"`
|
||||
}
|
||||
require.NoError(t, kitutil.Unmarshal(target.Tools, &groups))
|
||||
require.Len(t, groups, 1)
|
||||
functions = groups[0].FunctionDeclarations
|
||||
choice, err = kitutil.Any2Type[any](target.ToolConfig)
|
||||
default:
|
||||
require.FailNow(t, "unexpected request type", "%T", out)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
return functions, choice
|
||||
}
|
||||
|
||||
func TestResponsesCustomToolReachesEachTargetAsStringInputFunction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const execDescription = "Run code.\n\nThis tool takes freeform text. Put the complete raw text in the \"input\" argument.\n\nThe input must match this Lark grammar:\nstart: /.+/"
|
||||
inputSchema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"input": map[string]any{"type": "string", "description": "Raw input for the tool."},
|
||||
},
|
||||
"required": []any{"input"},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
expected := map[types.RelayFormat]struct {
|
||||
functions []map[string]any
|
||||
choice any
|
||||
}{
|
||||
types.RelayFormatOpenAI: {
|
||||
functions: []map[string]any{
|
||||
{"name": "exec", "description": execDescription, "parameters": inputSchema},
|
||||
{"name": "wait", "parameters": map[string]any{"type": "object"}},
|
||||
},
|
||||
choice: map[string]any{"type": "function", "function": map[string]any{"name": "exec"}},
|
||||
},
|
||||
types.RelayFormatClaude: {
|
||||
functions: []map[string]any{
|
||||
{"name": "exec", "description": execDescription, "input_schema": inputSchema},
|
||||
{"name": "wait", "input_schema": map[string]any{"type": "object", "properties": map[string]any{}}},
|
||||
},
|
||||
choice: map[string]any{"type": "tool", "name": "exec"},
|
||||
},
|
||||
// Gemini receives the cleaned OpenAPI schema without additionalProperties.
|
||||
types.RelayFormatGemini: {
|
||||
functions: []map[string]any{
|
||||
{"name": "exec", "description": execDescription, "parameters": map[string]any{
|
||||
"type": "OBJECT",
|
||||
"properties": map[string]any{
|
||||
"input": map[string]any{"type": "STRING", "description": "Raw input for the tool."},
|
||||
},
|
||||
"required": []any{"input"},
|
||||
}},
|
||||
{"name": "wait", "parameters": map[string]any{"type": "OBJECT"}},
|
||||
},
|
||||
choice: map[string]any{"functionCallingConfig": map[string]any{"mode": "ANY", "allowedFunctionNames": []any{"exec"}}},
|
||||
},
|
||||
}
|
||||
|
||||
_, set, err := ExtractRequest(types.RelayFormatOpenAIResponses, codexResponsesToolsRequest(t, map[string]any{"type": "custom", "name": "exec"}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]struct{}{"exec": {}}, ResponsesCustomToolNames(set))
|
||||
|
||||
for _, target := range responsesCustomToolTargets {
|
||||
t.Run(string(target.format), func(t *testing.T) {
|
||||
out, diagnostics, err := AttachRequest(target.format, target.request(), set, &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe})
|
||||
require.NoError(t, err)
|
||||
functions, choice := attachedFunctions(t, out)
|
||||
assert.Equal(t, expected[target.format].functions, functions)
|
||||
assert.Equal(t, expected[target.format].choice, choice)
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "custom_tool_as_function"))
|
||||
assert.False(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
|
||||
assert.False(t, hasDiagnosticCode(diagnostics, "unsupported_tool_choice"))
|
||||
|
||||
_, _, err = AttachRequest(target.format, target.request(), set, &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicyStrict})
|
||||
var loss *types.ConversionLossError
|
||||
require.ErrorAs(t, err, &loss)
|
||||
assert.True(t, hasDiagnosticCode(loss.Diagnostics, "custom_tool_as_function"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesCustomToolNameConflictIsDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The custom exec loses to the function exec, so a choice naming it has no
|
||||
// sent tool to point at and must stay unconverted.
|
||||
request := codexResponsesToolsRequest(t, map[string]any{"type": "custom", "name": "exec"},
|
||||
map[string]any{"type": "function", "name": "exec", "parameters": map[string]any{"type": "object"}},
|
||||
map[string]any{"type": "custom", "name": "apply_patch"},
|
||||
map[string]any{"type": "custom", "name": "apply_patch", "description": "duplicate"},
|
||||
)
|
||||
_, set, err := ExtractRequest(types.RelayFormatOpenAIResponses, request)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]struct{}{"apply_patch": {}}, ResponsesCustomToolNames(set))
|
||||
|
||||
for _, target := range responsesCustomToolTargets {
|
||||
t.Run(string(target.format), func(t *testing.T) {
|
||||
out, diagnostics, err := AttachRequest(target.format, target.request(), set, &convmeta.Options{})
|
||||
require.NoError(t, err)
|
||||
functions, choice := attachedFunctions(t, out)
|
||||
require.Len(t, functions, 3)
|
||||
names := make([]string, 0, len(functions))
|
||||
for _, function := range functions {
|
||||
names = append(names, function["name"].(string))
|
||||
}
|
||||
assert.Equal(t, []string{"wait", "exec", "apply_patch"}, names)
|
||||
// Only the custom definitions carry a description, so the kept exec
|
||||
// is the function one.
|
||||
assert.NotContains(t, functions[1], "description")
|
||||
assert.NotContains(t, functions[2]["description"], "duplicate")
|
||||
assert.Nil(t, choice)
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "custom_tool_name_conflict"))
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_tool_choice"))
|
||||
|
||||
_, _, err = AttachRequest(target.format, target.request(), set, &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user