| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776 |
- //go:build unit
- package service
- import (
- "testing"
- "github.com/stretchr/testify/require"
- )
- func TestGetModelPricing(t *testing.T) {
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(3e-6)},
- {ID: 3, Models: []string{"gpt-5.1"}, BillingMode: BillingModePerRequest},
- },
- }
- tests := []struct {
- name string
- model string
- wantID int64
- wantNil bool
- }{
- {"exact match", "claude-sonnet-4", 1, false},
- {"case insensitive", "Claude-Sonnet-4", 1, false},
- {"not found", "gemini-3.1-pro", 0, true},
- {"wildcard pattern not matched", "claude-opus-4-20250514", 0, true},
- {"per_request model", "gpt-5.1", 3, false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := ch.GetModelPricing(tt.model)
- if tt.wantNil {
- require.Nil(t, result)
- return
- }
- require.NotNil(t, result)
- require.Equal(t, tt.wantID, result.ID)
- })
- }
- }
- func TestGetModelPricing_ReturnsCopy(t *testing.T) {
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Models: []string{"claude-sonnet-4"}, InputPrice: testPtrFloat64(3e-6)},
- },
- }
- result := ch.GetModelPricing("claude-sonnet-4")
- require.NotNil(t, result)
- // Modify the returned copy's slice — original should be unchanged
- result.Models = append(result.Models, "hacked")
- // Original should be unchanged
- require.Equal(t, 1, len(ch.ModelPricing[0].Models))
- }
- func TestGetModelPricing_EmptyPricing(t *testing.T) {
- ch := &Channel{ModelPricing: nil}
- require.Nil(t, ch.GetModelPricing("any-model"))
- ch2 := &Channel{ModelPricing: []ChannelModelPricing{}}
- require.Nil(t, ch2.GetModelPricing("any-model"))
- }
- func TestGetIntervalForContext(t *testing.T) {
- p := &ChannelModelPricing{
- Intervals: []PricingInterval{
- {MinTokens: 0, MaxTokens: testPtrInt(128000), InputPrice: testPtrFloat64(1e-6)},
- {MinTokens: 128000, MaxTokens: nil, InputPrice: testPtrFloat64(2e-6)},
- },
- }
- tests := []struct {
- name string
- tokens int
- wantPrice *float64
- wantNil bool
- }{
- {"first interval", 50000, testPtrFloat64(1e-6), false},
- // (min, max] — 128000 在第一个区间的 max,包含,所以匹配第一个
- {"boundary: max of first (inclusive)", 128000, testPtrFloat64(1e-6), false},
- // 128001 > 128000,匹配第二个区间
- {"boundary: just above first max", 128001, testPtrFloat64(2e-6), false},
- {"unbounded interval", 500000, testPtrFloat64(2e-6), false},
- // (0, max] — 0 不匹配任何区间(左开)
- {"zero tokens: no match", 0, nil, true},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := p.GetIntervalForContext(tt.tokens)
- if tt.wantNil {
- require.Nil(t, result)
- return
- }
- require.NotNil(t, result)
- require.InDelta(t, *tt.wantPrice, *result.InputPrice, 1e-12)
- })
- }
- }
- func TestGetIntervalForContext_NoMatch(t *testing.T) {
- p := &ChannelModelPricing{
- Intervals: []PricingInterval{
- {MinTokens: 10000, MaxTokens: testPtrInt(50000)},
- },
- }
- require.Nil(t, p.GetIntervalForContext(5000)) // 5000 <= 10000, not > min
- require.Nil(t, p.GetIntervalForContext(10000)) // 10000 not > 10000 (left-open)
- require.NotNil(t, p.GetIntervalForContext(50000)) // 50000 <= 50000 (right-closed)
- require.Nil(t, p.GetIntervalForContext(50001)) // 50001 > 50000
- }
- func TestGetIntervalForContext_Empty(t *testing.T) {
- p := &ChannelModelPricing{Intervals: nil}
- require.Nil(t, p.GetIntervalForContext(1000))
- }
- func TestGetTierByLabel(t *testing.T) {
- p := &ChannelModelPricing{
- Intervals: []PricingInterval{
- {TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)},
- {TierLabel: "2K", PerRequestPrice: testPtrFloat64(0.08)},
- {TierLabel: "HD", PerRequestPrice: testPtrFloat64(0.12)},
- },
- }
- tests := []struct {
- name string
- label string
- wantNil bool
- want float64
- }{
- {"exact match", "1K", false, 0.04},
- {"case insensitive", "hd", false, 0.12},
- {"not found", "4K", true, 0},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := p.GetTierByLabel(tt.label)
- if tt.wantNil {
- require.Nil(t, result)
- return
- }
- require.NotNil(t, result)
- require.InDelta(t, tt.want, *result.PerRequestPrice, 1e-12)
- })
- }
- }
- func TestGetTierByLabel_Empty(t *testing.T) {
- p := &ChannelModelPricing{Intervals: nil}
- require.Nil(t, p.GetTierByLabel("1K"))
- }
- func TestChannelClone(t *testing.T) {
- original := &Channel{
- ID: 1,
- Name: "test",
- GroupIDs: []int64{10, 20},
- ModelPricing: []ChannelModelPricing{
- {
- ID: 100,
- Models: []string{"model-a"},
- InputPrice: testPtrFloat64(5e-6),
- },
- },
- }
- cloned := original.Clone()
- require.NotNil(t, cloned)
- require.Equal(t, original.ID, cloned.ID)
- require.Equal(t, original.Name, cloned.Name)
- // Modify clone slices — original should not change
- cloned.GroupIDs[0] = 999
- require.Equal(t, int64(10), original.GroupIDs[0])
- cloned.ModelPricing[0].Models[0] = "hacked"
- require.Equal(t, "model-a", original.ModelPricing[0].Models[0])
- }
- func TestChannelClone_Nil(t *testing.T) {
- var ch *Channel
- require.Nil(t, ch.Clone())
- }
- func TestChannelModelPricingClone(t *testing.T) {
- original := ChannelModelPricing{
- Models: []string{"a", "b"},
- Intervals: []PricingInterval{
- {MinTokens: 0, TierLabel: "tier1"},
- },
- TimePricing: &ChannelTimePricing{
- Timezone: "Asia/Shanghai",
- WeekdaysOnly: true,
- Periods: []ChannelTimePricingPeriod{{
- StartTime: "09:00",
- EndTime: "12:00",
- Multiplier: 2,
- }},
- },
- }
- cloned := original.Clone()
- // Modify clone slices — original unchanged
- cloned.Models[0] = "hacked"
- require.Equal(t, "a", original.Models[0])
- cloned.Intervals[0].TierLabel = "hacked"
- require.Equal(t, "tier1", original.Intervals[0].TierLabel)
- cloned.TimePricing.Timezone = "America/New_York"
- cloned.TimePricing.WeekdaysOnly = false
- cloned.TimePricing.Periods[0].StartTime = "10:00"
- cloned.TimePricing.Periods[0].Multiplier = 3
- require.Equal(t, "Asia/Shanghai", original.TimePricing.Timezone)
- require.True(t, original.TimePricing.WeekdaysOnly)
- require.Equal(t, "09:00", original.TimePricing.Periods[0].StartTime)
- require.Equal(t, 2.0, original.TimePricing.Periods[0].Multiplier)
- }
- // --- BillingMode.IsValid ---
- func TestBillingModeIsValid(t *testing.T) {
- tests := []struct {
- name string
- mode BillingMode
- want bool
- }{
- {"token", BillingModeToken, true},
- {"per_request", BillingModePerRequest, true},
- {"image", BillingModeImage, true},
- {"empty", BillingMode(""), true},
- {"unknown", BillingMode("unknown"), false},
- {"random", BillingMode("xyz"), false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- require.Equal(t, tt.want, tt.mode.IsValid())
- })
- }
- }
- // --- Channel.IsActive ---
- func TestChannelIsActive(t *testing.T) {
- tests := []struct {
- name string
- status string
- want bool
- }{
- {"active", StatusActive, true},
- {"disabled", "disabled", false},
- {"empty", "", false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- ch := &Channel{Status: tt.status}
- require.Equal(t, tt.want, ch.IsActive())
- })
- }
- }
- // --- ChannelModelPricing.Clone edge cases ---
- func TestChannelModelPricingClone_EdgeCases(t *testing.T) {
- t.Run("nil models", func(t *testing.T) {
- original := ChannelModelPricing{Models: nil}
- cloned := original.Clone()
- require.Nil(t, cloned.Models)
- })
- t.Run("nil intervals", func(t *testing.T) {
- original := ChannelModelPricing{Intervals: nil}
- cloned := original.Clone()
- require.Nil(t, cloned.Intervals)
- })
- t.Run("empty models", func(t *testing.T) {
- original := ChannelModelPricing{Models: []string{}}
- cloned := original.Clone()
- require.NotNil(t, cloned.Models)
- require.Empty(t, cloned.Models)
- })
- }
- // --- Channel.Clone edge cases ---
- func TestChannelClone_EdgeCases(t *testing.T) {
- t.Run("nil model mapping", func(t *testing.T) {
- original := &Channel{ID: 1, ModelMapping: nil}
- cloned := original.Clone()
- require.Nil(t, cloned.ModelMapping)
- })
- t.Run("nil model pricing", func(t *testing.T) {
- original := &Channel{ID: 1, ModelPricing: nil}
- cloned := original.Clone()
- require.Nil(t, cloned.ModelPricing)
- })
- t.Run("deep copy model mapping", func(t *testing.T) {
- original := &Channel{
- ID: 1,
- ModelMapping: map[string]map[string]string{
- "openai": {"gpt-4": "gpt-4-turbo"},
- },
- }
- cloned := original.Clone()
- // Modify the cloned nested map
- cloned.ModelMapping["openai"]["gpt-4"] = "hacked"
- // Original must remain unchanged
- require.Equal(t, "gpt-4-turbo", original.ModelMapping["openai"]["gpt-4"])
- })
- }
- // --- ValidateIntervals ---
- func TestValidateIntervals_Empty(t *testing.T) {
- require.NoError(t, ValidateIntervals(nil, BillingModeToken))
- require.NoError(t, ValidateIntervals([]PricingInterval{}, BillingModeToken))
- }
- func TestValidateIntervals_ValidIntervals(t *testing.T) {
- tests := []struct {
- name string
- intervals []PricingInterval
- }{
- {
- name: "single bounded interval",
- intervals: []PricingInterval{
- {MinTokens: 0, MaxTokens: testPtrInt(128000), InputPrice: testPtrFloat64(1e-6)},
- },
- },
- {
- name: "two intervals with gap",
- intervals: []PricingInterval{
- {MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(1e-6)},
- {MinTokens: 128000, MaxTokens: nil, InputPrice: testPtrFloat64(2e-6)},
- },
- },
- {
- name: "two contiguous intervals",
- intervals: []PricingInterval{
- {MinTokens: 0, MaxTokens: testPtrInt(128000), InputPrice: testPtrFloat64(1e-6)},
- {MinTokens: 128000, MaxTokens: nil, InputPrice: testPtrFloat64(2e-6)},
- },
- },
- {
- name: "unsorted input (auto-sorted by validator)",
- intervals: []PricingInterval{
- {MinTokens: 128000, MaxTokens: nil, InputPrice: testPtrFloat64(2e-6)},
- {MinTokens: 0, MaxTokens: testPtrInt(128000), InputPrice: testPtrFloat64(1e-6)},
- },
- },
- {
- name: "single unbounded interval",
- intervals: []PricingInterval{
- {MinTokens: 0, MaxTokens: nil, InputPrice: testPtrFloat64(1e-6)},
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- require.NoError(t, ValidateIntervals(tt.intervals, BillingModeToken))
- })
- }
- }
- func TestValidateIntervals_NegativeMinTokens(t *testing.T) {
- intervals := []PricingInterval{
- {MinTokens: -1, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)},
- }
- err := ValidateIntervals(intervals, BillingModeToken)
- require.Error(t, err)
- require.Contains(t, err.Error(), "min_tokens")
- require.Contains(t, err.Error(), ">= 0")
- }
- func TestValidateIntervals_MaxTokensZero(t *testing.T) {
- intervals := []PricingInterval{
- {MinTokens: 0, MaxTokens: testPtrInt(0), InputPrice: testPtrFloat64(1e-6)},
- }
- err := ValidateIntervals(intervals, BillingModeToken)
- require.Error(t, err)
- require.Contains(t, err.Error(), "max_tokens")
- require.Contains(t, err.Error(), "> 0")
- }
- func TestValidateIntervals_MaxLessThanMin(t *testing.T) {
- intervals := []PricingInterval{
- {MinTokens: 100, MaxTokens: testPtrInt(50), InputPrice: testPtrFloat64(1e-6)},
- }
- err := ValidateIntervals(intervals, BillingModeToken)
- require.Error(t, err)
- require.Contains(t, err.Error(), "max_tokens")
- require.Contains(t, err.Error(), "> min_tokens")
- }
- func TestValidateIntervals_MaxEqualsMin(t *testing.T) {
- intervals := []PricingInterval{
- {MinTokens: 100, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)},
- }
- err := ValidateIntervals(intervals, BillingModeToken)
- require.Error(t, err)
- require.Contains(t, err.Error(), "max_tokens")
- require.Contains(t, err.Error(), "> min_tokens")
- }
- func TestValidateIntervals_NegativePrice(t *testing.T) {
- negPrice := -0.01
- intervals := []PricingInterval{
- {MinTokens: 0, MaxTokens: testPtrInt(100), InputPrice: &negPrice},
- }
- err := ValidateIntervals(intervals, BillingModeToken)
- require.Error(t, err)
- require.Contains(t, err.Error(), "input_price")
- require.Contains(t, err.Error(), ">= 0")
- }
- func TestValidateIntervals_OverlappingIntervals(t *testing.T) {
- intervals := []PricingInterval{
- {MinTokens: 0, MaxTokens: testPtrInt(200), InputPrice: testPtrFloat64(1e-6)},
- {MinTokens: 100, MaxTokens: testPtrInt(300), InputPrice: testPtrFloat64(2e-6)},
- }
- err := ValidateIntervals(intervals, BillingModeToken)
- require.Error(t, err)
- require.Contains(t, err.Error(), "overlap")
- }
- func TestValidateIntervals_UnboundedNotLast(t *testing.T) {
- intervals := []PricingInterval{
- {MinTokens: 0, MaxTokens: nil, InputPrice: testPtrFloat64(1e-6)},
- {MinTokens: 128000, MaxTokens: testPtrInt(256000), InputPrice: testPtrFloat64(2e-6)},
- }
- err := ValidateIntervals(intervals, BillingModeToken)
- require.Error(t, err)
- require.Contains(t, err.Error(), "unbounded")
- require.Contains(t, err.Error(), "last")
- }
- func TestValidateIntervals_ImageModeAllowsMultipleUnboundedTiers(t *testing.T) {
- // image / per_request 按 tier_label 匹配,多条 min=0/max=nil 是合法形态。
- intervals := []PricingInterval{
- {MinTokens: 0, MaxTokens: nil, TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)},
- {MinTokens: 0, MaxTokens: nil, TierLabel: "2K", PerRequestPrice: testPtrFloat64(0.06)},
- {MinTokens: 0, MaxTokens: nil, TierLabel: "4K", PerRequestPrice: testPtrFloat64(0.08)},
- }
- require.NoError(t, ValidateIntervals(intervals, BillingModeImage))
- require.NoError(t, ValidateIntervals(intervals, BillingModePerRequest))
- }
- func TestValidateIntervals_ImageModeStillRejectsNegativePrice(t *testing.T) {
- // image 模式只跳过区间重叠校验,单条字段自洽(价格非负)仍要校验。
- intervals := []PricingInterval{
- {MinTokens: 0, MaxTokens: nil, TierLabel: "1K", PerRequestPrice: testPtrFloat64(-1)},
- }
- err := ValidateIntervals(intervals, BillingModeImage)
- require.Error(t, err)
- require.Contains(t, err.Error(), "must be >= 0")
- }
- func TestValidateIntervals_ImageModeStillRejectsBadMaxTokens(t *testing.T) {
- // image 模式仍校验 max <= min 这种单条不合法。
- intervals := []PricingInterval{
- {MinTokens: 100, MaxTokens: testPtrInt(50), TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)},
- }
- err := ValidateIntervals(intervals, BillingModeImage)
- require.Error(t, err)
- require.Contains(t, err.Error(), "must be > min_tokens")
- }
- func TestSupportedModels_ExactKeysAndPricing(t *testing.T) {
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 10, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}, InputPrice: testPtrFloat64(3e-6)},
- {ID: 11, Platform: "anthropic", Models: []string{"claude-opus-4-6"}, InputPrice: testPtrFloat64(1.5e-5)},
- },
- ModelMapping: map[string]map[string]string{
- "anthropic": {
- "claude-sonnet-4-6": "claude-sonnet-4-6",
- "claude-opus-4-6": "claude-opus-4-6",
- },
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 2)
- require.Equal(t, "anthropic", got[0].Platform)
- require.Equal(t, "claude-opus-4-6", got[0].Name)
- require.NotNil(t, got[0].Pricing)
- require.Equal(t, int64(11), got[0].Pricing.ID)
- require.Equal(t, "claude-sonnet-4-6", got[1].Name)
- require.Equal(t, int64(10), got[1].Pricing.ID)
- }
- func TestSupportedModels_WildcardExpandedFromPricing(t *testing.T) {
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6", "claude-sonnet-4-5"}},
- {ID: 2, Platform: "anthropic", Models: []string{"claude-opus-4-6"}},
- },
- ModelMapping: map[string]map[string]string{
- "anthropic": {
- "claude-sonnet-*": "claude-sonnet-4-6",
- },
- },
- }
- got := ch.SupportedModels()
- names := make([]string, 0, len(got))
- for _, m := range got {
- names = append(names, m.Name)
- }
- require.ElementsMatch(t, []string{"claude-sonnet-4-5", "claude-sonnet-4-6", "claude-opus-4-6"}, names)
- for _, m := range got {
- require.NotContains(t, m.Name, "*")
- }
- }
- func TestSupportedModels_MissingPricingKeepsNilPricing(t *testing.T) {
- ch := &Channel{
- ModelMapping: map[string]map[string]string{
- "anthropic": {"claude-sonnet-4-6": "claude-sonnet-4-6"},
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 1)
- require.Equal(t, "claude-sonnet-4-6", got[0].Name)
- require.Nil(t, got[0].Pricing)
- }
- func TestSupportedModels_DedupAndSort(t *testing.T) {
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6", "claude-sonnet-4-5"}},
- {ID: 2, Platform: "openai", Models: []string{"gpt-4o"}},
- },
- ModelMapping: map[string]map[string]string{
- "anthropic": {
- "claude-sonnet-4-6": "upstream-a",
- "claude-sonnet-*": "upstream-a",
- },
- "openai": {"gpt-4o": "gpt-4o"},
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 3)
- require.Equal(t, "anthropic", got[0].Platform)
- require.Equal(t, "claude-sonnet-4-5", got[0].Name)
- require.Equal(t, "anthropic", got[1].Platform)
- require.Equal(t, "claude-sonnet-4-6", got[1].Name)
- require.Equal(t, "openai", got[2].Platform)
- require.Equal(t, "gpt-4o", got[2].Name)
- }
- func TestSupportedModels_NilChannelAndEmpty(t *testing.T) {
- var nilCh *Channel
- require.Nil(t, nilCh.SupportedModels())
- empty := &Channel{}
- require.Nil(t, empty.SupportedModels())
- }
- func TestGetModelPricingByPlatform(t *testing.T) {
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}, InputPrice: testPtrFloat64(3e-6)},
- {ID: 2, Platform: "openai", Models: []string{"claude-sonnet-4-6"}, InputPrice: testPtrFloat64(1e-6)},
- },
- }
- ant := ch.GetModelPricingByPlatform("anthropic", "claude-sonnet-4-6")
- require.NotNil(t, ant)
- require.Equal(t, int64(1), ant.ID)
- oa := ch.GetModelPricingByPlatform("openai", "claude-sonnet-4-6")
- require.NotNil(t, oa)
- require.Equal(t, int64(2), oa.ID)
- require.Nil(t, ch.GetModelPricingByPlatform("gemini", "claude-sonnet-4-6"))
- }
- func TestSupportedModels_WildcardOnlyPricingRowsSkipped(t *testing.T) {
- // 定价中含通配符条目(pattern),不应被当作具体模型名展开。
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-*", "claude-sonnet-4-6"}},
- },
- ModelMapping: map[string]map[string]string{
- "anthropic": {"claude-sonnet-*": "claude-sonnet-4-6"},
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 1)
- require.Equal(t, "claude-sonnet-4-6", got[0].Name)
- for _, m := range got {
- require.NotContains(t, m.Name, "*")
- }
- }
- func TestSupportedModels_WildcardPrefixMatchesNothing(t *testing.T) {
- // 通配符模式无任何对应定价模型时,该平台 mapping 路不产出;
- // 但其他平台的 pricing-only 模型仍会通过 Pass B 出现。
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "openai", Models: []string{"gpt-4o"}},
- },
- ModelMapping: map[string]map[string]string{
- "anthropic": {"gpt-foo-*": "gpt-foo-1"},
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 1)
- require.Equal(t, "openai", got[0].Platform)
- require.Equal(t, "gpt-4o", got[0].Name)
- }
- func TestSupportedModels_CrossPlatformPricingDoesNotBleed(t *testing.T) {
- // anthropic 的通配符不应把 openai 定价行拉到 anthropic 平台下;
- // openai 的 pricing-only 模型则正常通过 Pass B 暴露在 openai 平台下。
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "openai", Models: []string{"claude-sonnet-4-6"}},
- },
- ModelMapping: map[string]map[string]string{
- "anthropic": {"claude-sonnet-*": "x"},
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 1)
- require.Equal(t, "openai", got[0].Platform, "不能把 openai 定价标记为 anthropic 模型")
- require.Equal(t, "claude-sonnet-4-6", got[0].Name)
- }
- func TestSupportedModels_CaseInsensitiveDedup(t *testing.T) {
- // 两行定价用不同大小写定义了同一模型,结果应去重为 1 条;首次出现的原始大小写保留。
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "openai", Models: []string{"GPT-4o"}},
- {ID: 2, Platform: "openai", Models: []string{"gpt-4o"}},
- },
- ModelMapping: map[string]map[string]string{
- "openai": {"gpt-*": "x"},
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 1)
- require.Equal(t, "GPT-4o", got[0].Name)
- }
- func TestSupportedModels_EmptyPlatformMapping(t *testing.T) {
- // ModelMapping 平台 key 存在但 value 为空 map:mapping 路跳过该平台,
- // 但 pricing 路仍会把该平台的定价模型补齐(关键修复:azcc 这种"只配定价不配映射"渠道)。
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}},
- },
- ModelMapping: map[string]map[string]string{
- "anthropic": {},
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 1)
- require.Equal(t, "anthropic", got[0].Platform)
- require.Equal(t, "claude-sonnet-4-6", got[0].Name)
- require.NotNil(t, got[0].Pricing)
- }
- func TestSupportedModels_ExactKeyUsesPricedCaseWhenAvailable(t *testing.T) {
- // mapping key uses uppercase, pricing uses lowercase — pricing's case should win.
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "openai", Models: []string{"gpt-4o"}},
- },
- ModelMapping: map[string]map[string]string{
- "openai": {"GPT-4o": "gpt-4o"},
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 1)
- require.Equal(t, "gpt-4o", got[0].Name) // pricing's case wins
- }
- func TestSupportedModels_AsteriskOnlyMappingExpandsAllPriced(t *testing.T) {
- // 映射 key 为单独的 "*":前缀为空 → 命中该平台所有定价模型(透传场景)。
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "openai", Models: []string{"gpt-4o", "gpt-4o-mini"}},
- },
- ModelMapping: map[string]map[string]string{
- "openai": {"*": "gpt-4o"},
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 2)
- names := []string{got[0].Name, got[1].Name}
- require.ElementsMatch(t, []string{"gpt-4o", "gpt-4o-mini"}, names)
- }
- func TestSupportedModels_PricingOnlyNoMapping(t *testing.T) {
- // 渠道完全没配 mapping,只配了定价 —— 应该把所有定价模型作为支持模型返回。
- // 这是修复前的核心 bug 场景(前端显示"未配置模型")。
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "anthropic", Models: []string{"claude-opus-4-6"}, InputPrice: testPtrFloat64(1.5e-5)},
- {ID: 2, Platform: "anthropic", Models: []string{"claude-haiku-4-5"}, InputPrice: testPtrFloat64(3e-7)},
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 2)
- require.Equal(t, "claude-haiku-4-5", got[0].Name)
- require.NotNil(t, got[0].Pricing)
- require.Equal(t, int64(2), got[0].Pricing.ID)
- require.Equal(t, "claude-opus-4-6", got[1].Name)
- require.Equal(t, int64(1), got[1].Pricing.ID)
- }
- func TestSupportedModels_ExactMappingUsesTargetPricing(t *testing.T) {
- // 精确 mapping `src → target`:定价应按 target 查(实际计费的是 target),
- // 而不是按 src 自查。
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 100, Platform: "anthropic", Models: []string{"req-model"}, InputPrice: testPtrFloat64(3e-6)},
- {ID: 200, Platform: "anthropic", Models: []string{"served-model"}, InputPrice: testPtrFloat64(1.5e-5)},
- },
- ModelMapping: map[string]map[string]string{
- "anthropic": {
- "req-model": "served-model",
- },
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 2)
- require.Equal(t, "req-model", got[0].Name)
- require.NotNil(t, got[0].Pricing)
- require.Equal(t, int64(200), got[0].Pricing.ID, "req-model 显示但定价是 served-model 的(mapping target)")
- require.Equal(t, "served-model", got[1].Name)
- require.Equal(t, int64(200), got[1].Pricing.ID)
- }
- func TestSupportedModels_ExactMappingTargetMissingFromPricing(t *testing.T) {
- // `src → target` 但 target 不在渠道定价里 —— 结果中 src 的 Pricing 为 nil
- // (等待 ListAvailable 阶段的全局 LiteLLM 回落填充)。
- ch := &Channel{
- ModelPricing: []ChannelModelPricing{
- {ID: 1, Platform: "anthropic", Models: []string{"some-priced-model"}, InputPrice: testPtrFloat64(1.5e-5)},
- },
- ModelMapping: map[string]map[string]string{
- "anthropic": {
- "missing-src": "missing-target",
- },
- },
- }
- got := ch.SupportedModels()
- require.Len(t, got, 2)
- require.Equal(t, "missing-src", got[0].Name)
- require.Nil(t, got[0].Pricing, "target 在渠道定价中缺失时不虚假填充,留给 ListAvailable 走 LiteLLM 回落")
- require.Equal(t, "some-priced-model", got[1].Name)
- require.NotNil(t, got[1].Pricing)
- }
|