* feat: support upstream model fetch for advanced custom channels * fix: add advanced custom routes as separate groups * fix: select advanced custom route entry before adding --------- Co-authored-by: CaIon <i@caion.me>
480 lines
14 KiB
Go
480 lines
14 KiB
Go
package dto
|
|
|
|
import (
|
|
"regexp"
|
|
"testing"
|
|
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestAdvancedCustomValidateResponsesToChatConverterPath(t *testing.T) {
|
|
valid := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/chat/completions",
|
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, valid.Validate())
|
|
|
|
validGemini := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, validGemini.Validate())
|
|
|
|
tests := []struct {
|
|
name string
|
|
incomingPath string
|
|
}{
|
|
{name: "chat completions", incomingPath: "/v1/chat/completions"},
|
|
{name: "responses compact", incomingPath: "/v1/responses/compact"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: tt.incomingPath,
|
|
UpstreamPath: "/v1/chat/completions",
|
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
|
},
|
|
},
|
|
}
|
|
err := config.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "converter does not match incoming_path")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdvancedCustomValidateModelListRouteConstraints(t *testing.T) {
|
|
valid := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: AdvancedCustomModelListPath,
|
|
UpstreamPath: "https://upstream.example/custom/models",
|
|
Converter: advancedCustomConverterNone,
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, valid.Validate())
|
|
|
|
tests := []struct {
|
|
name string
|
|
routes []AdvancedCustomRoute
|
|
want string
|
|
}{
|
|
{
|
|
name: "model matching rules",
|
|
routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: AdvancedCustomModelListPath,
|
|
UpstreamPath: "/v1/models",
|
|
Models: []string{"gpt-4o"},
|
|
},
|
|
},
|
|
want: "models must be empty",
|
|
},
|
|
{
|
|
name: "converter",
|
|
routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: AdvancedCustomModelListPath,
|
|
UpstreamPath: "/v1/models",
|
|
Converter: advancedCustomConverterOpenAIChatToOpenAIResponses,
|
|
},
|
|
},
|
|
want: "converter must be none",
|
|
},
|
|
{
|
|
name: "model placeholder",
|
|
routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: AdvancedCustomModelListPath,
|
|
UpstreamPath: "/v1/models/{model}",
|
|
},
|
|
},
|
|
want: "upstream_path must not contain {model}",
|
|
},
|
|
{
|
|
name: "duplicate routes",
|
|
routes: []AdvancedCustomRoute{
|
|
{IncomingPath: AdvancedCustomModelListPath, UpstreamPath: "/v1/models"},
|
|
{IncomingPath: AdvancedCustomModelListPath, UpstreamPath: "/provider/models"},
|
|
},
|
|
want: "duplicates the /v1/models route",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := (&AdvancedCustomConfig{Routes: tt.routes}).Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdvancedCustomModelListRouteRequiresExactIncomingPath(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/{model}",
|
|
UpstreamPath: "/generic/{model}",
|
|
},
|
|
{
|
|
IncomingPath: AdvancedCustomModelListPath,
|
|
UpstreamPath: "/provider/models",
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, config.Validate())
|
|
|
|
route, ok := config.ModelListRoute()
|
|
require.True(t, ok)
|
|
assert.Equal(t, "/provider/models", route.UpstreamPath)
|
|
}
|
|
|
|
func TestAdvancedCustomValidateDuplicateIncomingPathWithDisjointModels(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/chat/completions",
|
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
|
Models: []string{"gpt-4o"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
Models: []string{"gemini-2.5-flash"},
|
|
},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, config.Validate())
|
|
}
|
|
|
|
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsOverlappingModels(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/chat/completions",
|
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
|
Models: []string{"shared-model"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
Models: []string{"shared-model"},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := config.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "models overlaps")
|
|
}
|
|
|
|
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsMultipleCatchAllRoutes(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/chat/completions",
|
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
|
},
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
},
|
|
},
|
|
}
|
|
|
|
err := config.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "catch-all already exists")
|
|
}
|
|
|
|
func TestAdvancedCustomValidateDuplicateIncomingPathRequiresCatchAllLast(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/chat/completions",
|
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
|
},
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
Models: []string{"gemini-2.5-flash"},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := config.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "catch-all route must be last")
|
|
}
|
|
|
|
func TestAdvancedCustomMatchPathForModel(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
Models: []string{"gemini-2.5-flash"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/chat/completions",
|
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
|
Models: []string{"gpt-4o"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/responses",
|
|
Converter: advancedCustomConverterNone,
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, config.Validate())
|
|
|
|
geminiRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
|
|
require.True(t, ok)
|
|
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, geminiRoute.Converter)
|
|
|
|
chatRoute, ok := config.MatchPathForModel("/v1/responses", "gpt-4o")
|
|
require.True(t, ok)
|
|
assert.Equal(t, advancedCustomConverterOpenAIResponsesToOpenAIChat, chatRoute.Converter)
|
|
|
|
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "unknown-model")
|
|
require.True(t, ok)
|
|
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
|
|
}
|
|
|
|
func TestAdvancedCustomMatchPathForModelRegexRules(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
Models: []string{"re:^gemini-"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/chat/completions",
|
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
|
Models: []string{"re:(?i)^OAI-"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/responses",
|
|
Converter: advancedCustomConverterNone,
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, config.Validate())
|
|
|
|
geminiRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
|
|
require.True(t, ok)
|
|
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, geminiRoute.Converter)
|
|
|
|
chatRoute, ok := config.MatchPathForModel("/v1/responses", "oai-test")
|
|
require.True(t, ok)
|
|
assert.Equal(t, advancedCustomConverterOpenAIResponsesToOpenAIChat, chatRoute.Converter)
|
|
|
|
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "gpt-4o")
|
|
require.True(t, ok)
|
|
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
|
|
}
|
|
|
|
func TestAdvancedCustomRouteModelRegexRulesAreCachedCompiled(t *testing.T) {
|
|
require.True(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "cache-probe-model"))
|
|
|
|
cached, ok := advancedCustomModelRegexCache.Load("^cache-probe-")
|
|
require.True(t, ok)
|
|
require.NotNil(t, cached)
|
|
_, isRegexp := cached.(*regexp.Regexp)
|
|
require.True(t, isRegexp)
|
|
|
|
// Invalid patterns never match and are cached as nil so they are not recompiled.
|
|
require.False(t, matchAdvancedCustomRouteModelRule("re:(", "anything"))
|
|
cached, ok = advancedCustomModelRegexCache.Load("(")
|
|
require.True(t, ok)
|
|
re, _ := cached.(*regexp.Regexp)
|
|
require.Nil(t, re)
|
|
|
|
// Cached entries keep matching correctly on subsequent calls.
|
|
require.True(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "cache-probe-other"))
|
|
require.False(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "other-model"))
|
|
}
|
|
|
|
func TestAdvancedCustomMatchPathForModelExactRuleDoesNotMatchPrefix(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
Models: []string{"gemini"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/responses",
|
|
Converter: advancedCustomConverterNone,
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, config.Validate())
|
|
|
|
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
|
|
require.True(t, ok)
|
|
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
|
|
}
|
|
|
|
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsInvalidRegexModels(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
models []string
|
|
want string
|
|
}{
|
|
{name: "empty regex", models: []string{"re:"}, want: "regex is empty"},
|
|
{name: "invalid regex", models: []string{"re:["}, want: "regex is invalid"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
Models: tt.models,
|
|
},
|
|
},
|
|
}
|
|
|
|
err := config.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsDuplicateRegexModels(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
Models: []string{"re:^gemini-"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/chat/completions",
|
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
|
Models: []string{"re:^gemini-"},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := config.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "models overlaps")
|
|
}
|
|
|
|
func TestAdvancedCustomMatchPathForModelUsesFirstMatchingRegexRoute(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
Models: []string{"re:^gemini-"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1/chat/completions",
|
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
|
Models: []string{"gemini-2.5-flash"},
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, config.Validate())
|
|
|
|
route, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
|
|
require.True(t, ok)
|
|
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, route.Converter)
|
|
}
|
|
|
|
func TestAdvancedCustomSupportedEndpointTypesForModel(t *testing.T) {
|
|
config := &AdvancedCustomConfig{
|
|
Routes: []AdvancedCustomRoute{
|
|
{
|
|
IncomingPath: "/v1/responses",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
|
Models: []string{"re:^gemini-"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1beta/models/{model}:generateContent",
|
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
|
Models: []string{"re:^gemini-"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1beta/models/{model}:streamGenerateContent",
|
|
UpstreamPath: "/v1beta/models/{model}:streamGenerateContent",
|
|
Models: []string{"re:^gemini-"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1/chat/completions",
|
|
UpstreamPath: "/v1/chat/completions",
|
|
Models: []string{"gpt-4o"},
|
|
},
|
|
{
|
|
IncomingPath: "/v1/messages",
|
|
UpstreamPath: "/v1/messages",
|
|
},
|
|
{
|
|
IncomingPath: "/custom/endpoint",
|
|
UpstreamPath: "/custom/endpoint",
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, config.Validate())
|
|
|
|
assert.Equal(t, []constant.EndpointType{
|
|
constant.EndpointTypeOpenAIResponse,
|
|
constant.EndpointTypeGemini,
|
|
constant.EndpointTypeAnthropic,
|
|
}, config.SupportedEndpointTypesForModel("gemini-2.5-flash"))
|
|
assert.Equal(t, []constant.EndpointType{
|
|
constant.EndpointTypeOpenAI,
|
|
constant.EndpointTypeAnthropic,
|
|
}, config.SupportedEndpointTypesForModel("gpt-4o"))
|
|
assert.Equal(t, []constant.EndpointType{
|
|
constant.EndpointTypeAnthropic,
|
|
}, config.SupportedEndpointTypesForModel("other-model"))
|
|
}
|