Files
new-api/dto/channel_settings_test.go
T
Calcium-Ion c36418c863 feat: enhance text protocol conversion and advanced custom routing (#5825)
* 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
2026-07-11 20:44:12 +08:00

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"))
}