feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
@@ -8,10 +9,90 @@ 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/types"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClaudeMessagesToOpenAIChatPreservesAssistantTextWithToolCalls(t *testing.T) {
|
||||
text := "I will look that up."
|
||||
request := dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "text", Text: &text},
|
||||
{
|
||||
Type: "tool_use",
|
||||
Id: "call_1",
|
||||
Name: "lookup",
|
||||
Input: map[string]any{"query": "weather"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(
|
||||
context.Background(),
|
||||
&convmeta.Values{},
|
||||
types.RelayFormatOpenAI,
|
||||
&request,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
converted, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.Len(t, converted.Messages, 1)
|
||||
|
||||
assistant := converted.Messages[0]
|
||||
assert.Equal(t, "assistant", assistant.Role)
|
||||
toolCalls := assistant.ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, "call_1", toolCalls[0].ID)
|
||||
require.Len(t, assistant.ParseContent(), 1)
|
||||
assert.Equal(t, "text", assistant.ParseContent()[0].Type)
|
||||
assert.Equal(t, text, assistant.ParseContent()[0].Text)
|
||||
}
|
||||
|
||||
func TestClaudeMessagesToOpenAIChatKeepsToolOnlyContentEmpty(t *testing.T) {
|
||||
request := dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []dto.ClaudeMediaMessage{
|
||||
{
|
||||
Type: "tool_use",
|
||||
Id: "call_1",
|
||||
Name: "lookup",
|
||||
Input: map[string]any{"query": "weather"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(
|
||||
context.Background(),
|
||||
&convmeta.Values{},
|
||||
types.RelayFormatOpenAI,
|
||||
&request,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
converted, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.Len(t, converted.Messages, 1)
|
||||
|
||||
assistant := converted.Messages[0]
|
||||
assert.Empty(t, assistant.ParseContent())
|
||||
toolCalls := assistant.ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, "call_1", toolCalls[0].ID)
|
||||
}
|
||||
|
||||
func TestRequestConverterRegistryListsSupportedTextConverters(t *testing.T) {
|
||||
tests := []struct {
|
||||
converter string
|
||||
@@ -38,10 +119,11 @@ func TestRequestConverterRegistryListsSupportedTextConverters(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
converter: requestConverterClaudeToResponses,
|
||||
from: types.RelayFormatClaude,
|
||||
to: types.RelayFormatOpenAIResponses,
|
||||
quality: RequestConverterQualityFair,
|
||||
converter: requestConverterClaudeToResponses,
|
||||
from: types.RelayFormatClaude,
|
||||
to: types.RelayFormatOpenAIResponses,
|
||||
quality: RequestConverterQualityFair,
|
||||
advancedCustom: true,
|
||||
},
|
||||
{
|
||||
converter: requestConverterGeminiToClaude,
|
||||
@@ -62,6 +144,7 @@ func TestRequestConverterRegistryListsSupportedTextConverters(t *testing.T) {
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
advancedCustom: true,
|
||||
},
|
||||
{
|
||||
converter: requestConverterResponsesToClaude,
|
||||
@@ -158,6 +241,52 @@ func TestConvertRequestClaudeToResponsesUsesDirectPath(t *testing.T) {
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAIResponses}, info.ConversionChain)
|
||||
}
|
||||
|
||||
func TestConvertRequestClaudeToChatResolvesToolResultNames(t *testing.T) {
|
||||
req := &dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_result", ToolUseId: "call_1", Content: "before call"},
|
||||
}},
|
||||
{Role: "assistant", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_use", Id: "call_1", Name: "first", Input: map[string]any{}},
|
||||
}},
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_result", ToolUseId: "call_1", Content: "after call"},
|
||||
{Type: "tool_result", ToolUseId: "missing", Content: "unknown"},
|
||||
{Type: "tool_result", ToolUseId: "call_1", Name: "explicit", Content: "named"},
|
||||
}},
|
||||
{Role: "assistant", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_use", Id: "call_1", Name: "later", Input: map[string]any{}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequestByID(nil, nil, ConverterClaudeMessagesToOpenAIChat, req)
|
||||
require.NoError(t, err)
|
||||
chatReq, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.Len(t, chatReq.Messages, 6)
|
||||
for _, tt := range []struct {
|
||||
index int
|
||||
id string
|
||||
name string
|
||||
}{
|
||||
{0, "call_1", "first"},
|
||||
{2, "call_1", "first"},
|
||||
{3, "missing", ""},
|
||||
{4, "call_1", "explicit"},
|
||||
} {
|
||||
message := chatReq.Messages[tt.index]
|
||||
assert.Equal(t, "tool", message.Role)
|
||||
assert.Equal(t, tt.id, message.ToolCallId)
|
||||
require.NotNil(t, message.Name)
|
||||
assert.Equal(t, tt.name, *message.Name)
|
||||
}
|
||||
assert.Equal(t, "assistant", chatReq.Messages[1].Role)
|
||||
assert.Equal(t, "assistant", chatReq.Messages[5].Role)
|
||||
}
|
||||
|
||||
func TestConvertRequestClaudeToResponsesPreservesMixedBlockOrder(t *testing.T) {
|
||||
info := &convmeta.Values{ConversionChain: []types.RelayFormat{types.RelayFormatClaude}}
|
||||
stream := true
|
||||
@@ -219,6 +348,43 @@ func TestConvertRequestClaudeToResponsesPreservesMixedBlockOrder(t *testing.T) {
|
||||
assert.Equal(t, "continue", inputContentText(t, input[5]))
|
||||
}
|
||||
|
||||
func TestConvertRequestClaudeToResponsesDefaultsImageDetail(t *testing.T) {
|
||||
const imageData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+Xoy8AAAAASUVORK5CYII="
|
||||
req := &dto.ClaudeRequest{
|
||||
Model: "gpt-test",
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "url", Url: "https://example.test/a.png"},
|
||||
}}},
|
||||
{Role: "assistant", Content: []dto.ClaudeMediaMessage{{
|
||||
Type: "tool_use", Id: "call_1", Name: "screenshot", Input: map[string]any{},
|
||||
}}},
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{{
|
||||
Type: "tool_result", ToolUseId: "call_1",
|
||||
Content: []dto.ClaudeMediaMessage{{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: "image/png", Data: imageData},
|
||||
}},
|
||||
}}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequestByID(context.Background(), nil, ConverterClaudeMessagesToOpenAIResponses, req)
|
||||
require.NoError(t, err)
|
||||
responsesReq, ok := result.Value.(*dto.OpenAIResponsesRequest)
|
||||
require.True(t, ok)
|
||||
assert.JSONEq(t, `[
|
||||
{"role":"user","content":[
|
||||
{"type":"input_image","image_url":"https://example.test/a.png","detail":"auto"}
|
||||
]},
|
||||
{"type":"function_call","call_id":"call_1","name":"screenshot","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_1","output":[
|
||||
{"type":"input_image","image_url":"data:image/png;base64,`+imageData+`","detail":"auto"}
|
||||
]}
|
||||
]`, string(responsesReq.Input))
|
||||
}
|
||||
|
||||
func TestConvertRequestClaudeToResponsesDropsIncompatibleContextManagement(t *testing.T) {
|
||||
req := &dto.ClaudeRequest{
|
||||
Model: "gpt-test",
|
||||
@@ -374,61 +540,121 @@ func TestConvertRequestViaExecutesExplicitPath(t *testing.T) {
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.ConversionChain)
|
||||
}
|
||||
|
||||
func TestConvertRequestResponsesToGeminiAppliesResponsesPreprocess(t *testing.T) {
|
||||
info := &convmeta.Values{
|
||||
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||
ChannelMetaAttached: true,
|
||||
UpstreamModelName: "gemini-test",
|
||||
// customToolInputs are custom_tool_call inputs that must reach the upstream
|
||||
// as the raw string, including ones that happen to look like JSON.
|
||||
var customToolInputs = []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"json object", `{"a":1}`},
|
||||
{"json array", `[1,2]`},
|
||||
{"plain text", "*** Begin Patch\n*** End Patch"},
|
||||
}
|
||||
|
||||
func customToolHistoryRequest(t *testing.T, items ...map[string]any) *dto.OpenAIResponsesRequest {
|
||||
t.Helper()
|
||||
input := append([]map[string]any{{"role": "user", "content": "apply the patch"}}, items...)
|
||||
return &dto.OpenAIResponsesRequest{
|
||||
Model: "model-test",
|
||||
MaxOutputTokens: lo.ToPtr(uint(64)),
|
||||
Input: mustRawMessage(t, input),
|
||||
Tools: mustRawMessage(t, []map[string]any{{"type": "custom", "name": "apply_patch"}}),
|
||||
}
|
||||
req := &dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "next turn",
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_custom",
|
||||
"name": "apply_patch",
|
||||
"input": "patch body",
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_custom",
|
||||
"output": "ok",
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_custom",
|
||||
"output": "legacy custom output",
|
||||
},
|
||||
}),
|
||||
Tools: mustRawMessage(t, []map[string]any{
|
||||
{"type": "custom", "name": "apply_patch"},
|
||||
}),
|
||||
}
|
||||
|
||||
func TestConvertRequestResponsesToClaudeKeepsCustomToolHistory(t *testing.T) {
|
||||
for _, tc := range customToolInputs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := customToolHistoryRequest(t,
|
||||
map[string]any{"type": "custom_tool_call", "call_id": "call_custom", "name": "apply_patch", "input": tc.input},
|
||||
map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom", "output": "ok"},
|
||||
)
|
||||
|
||||
result, err := ConvertRequest(nil, &convmeta.Values{}, types.RelayFormatClaude, req)
|
||||
require.NoError(t, err)
|
||||
claudeReq, ok := result.Value.(*dto.ClaudeRequest)
|
||||
require.True(t, ok)
|
||||
require.Len(t, claudeReq.Messages, 3)
|
||||
|
||||
assert.Equal(t, "assistant", claudeReq.Messages[1].Role)
|
||||
toolUse, err := claudeReq.Messages[1].ParseContent()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []dto.ClaudeMediaMessage{{
|
||||
Type: "tool_use",
|
||||
Id: "call_custom",
|
||||
Name: "apply_patch",
|
||||
Input: map[string]any{"input": tc.input},
|
||||
}}, toolUse)
|
||||
|
||||
assert.Equal(t, "user", claudeReq.Messages[2].Role)
|
||||
toolResult, err := claudeReq.Messages[2].ParseContent()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []dto.ClaudeMediaMessage{{Type: "tool_result", ToolUseId: "call_custom", Content: "ok"}}, toolResult)
|
||||
})
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
|
||||
t.Run("missing name", func(t *testing.T) {
|
||||
req := customToolHistoryRequest(t, map[string]any{"type": "custom_tool_call", "call_id": "call_custom", "input": "ls"})
|
||||
_, err := ConvertRequest(nil, &convmeta.Values{}, types.RelayFormatClaude, req)
|
||||
require.ErrorContains(t, err, "custom_tool_call item is missing name")
|
||||
})
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, geminiReq.GetTools())
|
||||
require.Len(t, geminiReq.Contents, 1)
|
||||
assert.Equal(t, "user", geminiReq.Contents[0].Role)
|
||||
require.Len(t, geminiReq.Contents[0].Parts, 1)
|
||||
assert.Equal(t, "next turn", geminiReq.Contents[0].Parts[0].Text)
|
||||
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
|
||||
assert.Equal(t, RequestConverterQualityFair, result.Quality)
|
||||
assert.Equal(t, []RequestStep{
|
||||
{
|
||||
Converter: ConverterOpenAIResponsesToGemini,
|
||||
From: types.RelayFormatOpenAIResponses,
|
||||
To: types.RelayFormatGemini,
|
||||
},
|
||||
}, result.Steps)
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatGemini}, info.ConversionChain)
|
||||
func TestConvertRequestResponsesToGeminiKeepsCustomToolHistory(t *testing.T) {
|
||||
info := func() *convmeta.Values {
|
||||
return &convmeta.Values{Options: &convmeta.Options{Gemini: convmeta.GeminiOptions{FunctionCallThoughtSignatureEnabled: true}}}
|
||||
}
|
||||
for _, tc := range customToolInputs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Older Codex history may answer a custom call with function_call_output.
|
||||
req := customToolHistoryRequest(t,
|
||||
map[string]any{"type": "custom_tool_call", "call_id": "call_custom", "name": "apply_patch", "input": tc.input},
|
||||
map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom", "output": "ok"},
|
||||
map[string]any{"type": "function_call_output", "call_id": "call_custom", "output": "legacy"},
|
||||
)
|
||||
|
||||
result, err := ConvertRequest(nil, info(), types.RelayFormatGemini, req)
|
||||
require.NoError(t, err)
|
||||
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||
require.True(t, ok)
|
||||
tools := geminiReq.GetTools()
|
||||
require.Len(t, tools, 1)
|
||||
functions, err := kitutil.Any2Type[[]dto.FunctionRequest](tools[0].FunctionDeclarations)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, functions, 1)
|
||||
assert.Equal(t, "apply_patch", functions[0].Name)
|
||||
require.Len(t, geminiReq.Contents, 3)
|
||||
|
||||
model := geminiReq.Contents[1]
|
||||
assert.Equal(t, "model", model.Role)
|
||||
require.Len(t, model.Parts, 1)
|
||||
assert.Equal(t, &dto.FunctionCall{
|
||||
ID: "call_custom",
|
||||
FunctionName: "apply_patch",
|
||||
Arguments: map[string]any{"input": tc.input},
|
||||
}, model.Parts[0].FunctionCall)
|
||||
var thoughtSignature string
|
||||
require.NoError(t, kitutil.Unmarshal(model.Parts[0].ThoughtSignature, &thoughtSignature))
|
||||
assert.Equal(t, sharedgemini.ThoughtSignatureBypassValue, thoughtSignature)
|
||||
|
||||
user := geminiReq.Contents[2]
|
||||
assert.Equal(t, "user", user.Role)
|
||||
require.Len(t, user.Parts, 2)
|
||||
for index, output := range []string{"ok", "legacy"} {
|
||||
assert.Equal(t, &dto.GeminiFunctionResponse{
|
||||
Name: "apply_patch",
|
||||
Response: map[string]any{"content": output},
|
||||
ID: []byte(`"call_custom"`),
|
||||
}, user.Parts[index].FunctionResponse)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("missing name", func(t *testing.T) {
|
||||
req := customToolHistoryRequest(t, map[string]any{"type": "custom_tool_call", "call_id": "call_custom", "input": "ls"})
|
||||
_, err := ConvertRequest(nil, info(), types.RelayFormatGemini, req)
|
||||
require.ErrorContains(t, err, "custom_tool_call item is missing name")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertRequestResponsesToGeminiUsesDirectConverter(t *testing.T) {
|
||||
@@ -781,3 +1007,38 @@ func inputContentText(t *testing.T, item map[string]any) string {
|
||||
require.True(t, ok)
|
||||
return text
|
||||
}
|
||||
|
||||
func TestConvertRequestRecordsResponsesCustomToolsForEachTarget(t *testing.T) {
|
||||
codexRequest := func(tools ...map[string]any) *dto.OpenAIResponsesRequest {
|
||||
return &dto.OpenAIResponsesRequest{
|
||||
Model: "model-test",
|
||||
MaxOutputTokens: lo.ToPtr(uint(64)),
|
||||
Input: mustMarshalRequestJSON(t, "hello"),
|
||||
Tools: mustMarshalRequestJSON(t, tools),
|
||||
}
|
||||
}
|
||||
execTool := map[string]any{"type": "custom", "name": "exec", "description": "Run code", "format": map[string]any{"type": "text"}}
|
||||
waitTool := map[string]any{"type": "function", "name": "wait", "parameters": map[string]any{"type": "object"}}
|
||||
|
||||
for _, target := range []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatClaude, types.RelayFormatGemini} {
|
||||
t.Run(string(target), func(t *testing.T) {
|
||||
info := &convmeta.Values{}
|
||||
_, err := ConvertRequest(nil, info, target, codexRequest(execTool, waitTool))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, info.ResponsesTools)
|
||||
assert.True(t, info.ResponsesTools.IsCustomTool("exec"))
|
||||
assert.False(t, info.ResponsesTools.IsCustomTool("wait"))
|
||||
|
||||
_, err = ConvertRequest(nil, info, target, codexRequest(waitTool))
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, info.ResponsesTools, "a retry without custom tools must not keep the previous record")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshalRequestJSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
raw, err := kitutil.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
return raw
|
||||
}
|
||||
Reference in new issue
Block a user