* refactor: consolidate relay protocol converters * refactor relayconvert text converters * feat: refine relay converters and advanced custom routing * refactor: enhance logging and add thought signature handling for Gemini requests * refactor: enhance channel cache and pricing endpoint handling for advanced custom models * feat: preserve billing usage semantics * feat: add protocol-aware billing usage * Delete useless files * chore: update action versions in workflow files * chore: update Docker action versions in workflow files * fix: harden billing usage settlement and hot-path route matching - estimate Gemini completion tokens locally when billable usageMetadata is prompt-only but output content was received (e.g. client aborts the stream before the final chunk), and rebuild the attached billing_usage as estimated so settlement does not bill zero output tokens - guard NewClaudeMessagesBillingUsage against all-zero ClaudeUsage, matching the OpenAI/Gemini constructors, so a zero billing_usage cannot override a non-zero top-level usage during settlement - cache compiled advanced-custom route model regexes; they run on the request hot path and were recompiled per request - move the effectiveBillingUsage remap to PostTextConsumeQuota only, and document that calculateTextQuotaSummary expects remapped usage - document the updatePricingLock -> channelSyncLock lock ordering that InitChannelCache/CacheUpdateChannel rely on, and the aux-struct pitfall in GeminiChatResponse.UnmarshalJSON
392 lines
12 KiB
Go
392 lines
12 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 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"))
|
|
}
|