Files
new-api/relaykit/dto/channel_settings_test.go
T
Seefs 2b0efd8484 refactor: advanced custom channel route editor (#6865)
* refactor: advanced custom channel route editor

* fix(channels): show raw balance response from balance cell
2026-08-18 17:31:21 +08:00

645 lines
19 KiB
Go

package dto
import (
"encoding/json"
"regexp"
"testing"
"github.com/QuantumNous/new-api/relaykit/types"
"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 TestAdvancedCustomValidateBalanceRouteConstraints(t *testing.T) {
valid := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/balance",
Converter: advancedCustomConverterNone,
}},
}
require.NoError(t, valid.Validate())
route, ok := valid.BalanceRoute()
require.True(t, ok)
assert.Equal(t, "/provider/balance", route.UpstreamPath)
tests := []struct {
name string
routes []AdvancedCustomRoute
want string
}{
{
name: "model matching rules",
routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/balance",
Models: []string{"gpt-4o"},
}},
want: "models must be empty",
},
{
name: "converter",
routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/balance",
Converter: advancedCustomConverterOpenAIChatToOpenAIResponses,
}},
want: "converter must be none",
},
{
name: "model placeholder",
routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/{model}/balance",
}},
want: "upstream_path must not contain {model}",
},
{
name: "duplicate routes",
routes: []AdvancedCustomRoute{
{IncomingPath: AdvancedCustomBalancePath, UpstreamPath: "/provider/balance"},
{IncomingPath: AdvancedCustomBalancePath, UpstreamPath: "/provider/credits"},
},
want: "duplicates the /v1/dashboard/billing/credit_grants 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 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, []types.EndpointType{
types.EndpointTypeOpenAIResponse,
types.EndpointTypeGemini,
types.EndpointTypeAnthropic,
}, config.SupportedEndpointTypesForModel("gemini-2.5-flash"))
assert.Equal(t, []types.EndpointType{
types.EndpointTypeOpenAI,
types.EndpointTypeAnthropic,
}, config.SupportedEndpointTypesForModel("gpt-4o"))
assert.Equal(t, []types.EndpointType{
types.EndpointTypeAnthropic,
}, config.SupportedEndpointTypesForModel("other-model"))
}
func TestAdvancedCustomValidateAlphaSearchConverterPath(t *testing.T) {
valid := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/alpha/search",
UpstreamPath: "/v1/alpha/search",
Converter: advancedCustomConverterNone,
},
},
}
require.NoError(t, valid.Validate())
assert.Equal(t, []types.EndpointType{
types.EndpointTypeOpenAIAlphaSearch,
}, valid.SupportedEndpointTypesForModel("gpt-5.1"))
nonNoneConverters := []string{
advancedCustomConverterClaudeMessagesToOpenAIChat,
advancedCustomConverterOpenAIChatToClaudeMessages,
advancedCustomConverterOpenAIChatToOpenAIResponses,
advancedCustomConverterOpenAIResponsesToOpenAIChat,
advancedCustomConverterOpenAIResponsesToGemini,
advancedCustomConverterGeminiContentToOpenAIChat,
advancedCustomConverterOpenAIChatToGeminiContent,
}
for _, converter := range nonNoneConverters {
t.Run(converter, func(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/alpha/search",
UpstreamPath: "/v1/alpha/search",
Converter: converter,
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "converter does not match incoming_path")
})
}
}
func TestChannelSettingsHTTPTransportJSONRoundTrip(t *testing.T) {
legacy := `{"proxy":"http://127.0.0.1:8080","force_format":true}`
var settings ChannelSettings
require.NoError(t, json.Unmarshal([]byte(legacy), &settings))
assert.Equal(t, "http://127.0.0.1:8080", settings.Proxy)
assert.True(t, settings.ForceFormat)
assert.Empty(t, settings.HTTPProtocol)
assert.Zero(t, settings.HTTP2ConnectionShards)
encoded, err := json.Marshal(settings)
require.NoError(t, err)
assert.NotContains(t, string(encoded), "http_protocol")
assert.NotContains(t, string(encoded), "http2_connection_shards")
explicit := ChannelSettings{
Proxy: "socks5://127.0.0.1:1080",
HTTPProtocol: HTTPProtocolHTTP1,
HTTP2ConnectionShards: 1,
}
encoded, err = json.Marshal(explicit)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"http_protocol":"http1"`)
var decoded ChannelSettings
require.NoError(t, json.Unmarshal(encoded, &decoded))
assert.Equal(t, explicit.HTTPProtocol, decoded.HTTPProtocol)
assert.Equal(t, 1, decoded.HTTP2ConnectionShards)
sharded := ChannelSettings{HTTP2ConnectionShards: 4}
encoded, err = json.Marshal(sharded)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"http2_connection_shards":4`)
assert.NotContains(t, string(encoded), "http_protocol")
}
func TestChannelSettingsValidateHTTPTransport(t *testing.T) {
require.NoError(t, (&ChannelSettings{}).ValidateHTTPTransport())
require.NoError(t, (&ChannelSettings{HTTPProtocol: "AUTO"}).ValidateHTTPTransport())
require.NoError(t, (&ChannelSettings{HTTPProtocol: "http1"}).ValidateHTTPTransport())
require.NoError(t, (&ChannelSettings{HTTP2ConnectionShards: 8}).ValidateHTTPTransport())
err := (&ChannelSettings{HTTPProtocol: "http2"}).ValidateHTTPTransport()
require.Error(t, err)
assert.Contains(t, err.Error(), "http_protocol")
err = (&ChannelSettings{HTTP2ConnectionShards: -1}).ValidateHTTPTransport()
require.Error(t, err)
assert.Contains(t, err.Error(), "http2_connection_shards")
err = (&ChannelSettings{HTTP2ConnectionShards: 9}).ValidateHTTPTransport()
require.Error(t, err)
assert.Contains(t, err.Error(), "http2_connection_shards")
err = (&ChannelSettings{HTTPProtocol: "http1", HTTP2ConnectionShards: 2}).ValidateHTTPTransport()
require.Error(t, err)
assert.Contains(t, err.Error(), "http2_connection_shards")
}