fix(channels): supported models = mapping ∪ pricing with global LiteLLM fallback

Why: channels with model pricing entries but no model mapping (e.g. azcc with
3 priced claude models, no mapping) were rendering as 未配置模型 in the
'Available Channels' page. The algorithm only iterated ModelMapping and
silently dropped any platform without a mapping entry.

Changes:
- channel.go: SupportedModels now unions mapping + pricing entries.
  For exact mapping src → target, pricing is looked up by target (the actually
  billed name), not by src.
- channel_available.go: ListAvailable enriches each entry with nil pricing
  via PricingService.GetModelPricing (global LiteLLM fallback) so the popover
  always shows a price.
- channel_service.go: NewChannelService takes *PricingService as 4th param.
- channel_test.go: rewrote 4 tests that froze the old mapping-only semantics;
  added pricing-only / mapping-target / target-missing coverage.
This commit is contained in:
erio
2026-04-23 00:45:10 +08:00
parent 25a5035503
commit 6cd7c60549
8 changed files with 209 additions and 61 deletions

View File

@@ -174,7 +174,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI)
digestSessionStore := service.NewDigestSessionStore()
channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository)
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService)

View File

@@ -451,19 +451,29 @@ func buildPricingIndex(pricings []ChannelModelPricing) map[string]*platformPrici
// SupportedModels 计算渠道的支持模型列表,结果保证不含通配符。
//
// 算法(以渠道自身的 ModelMapping 为唯一入口
// - 遍历 Channel.ModelMapping 的每个 platform 条目;
// - 映射 key 不带尾部 "*":直接作为一个支持模型名(即使没有匹配的定价行,也会产出 Pricing=nil 的条目);
// - 映射 key 带尾部 "*":用同 platform 的 ModelPricing.Models 做前缀匹配展开(定价中带 "*" 的条目被忽略,因为它们本身就是模式,不是具体模型名);
// - 映射 key 为 `"*"`(单独一个星号)将展开为该平台所有定价模型(前缀为空 → 全匹配)。这是刻意行为,用于"将该平台所有模型透传"的场景;
// - 未在 ModelMapping 中出现的 platform 不会产出任何条目——这是**刻意设计**"没配映射就不显示"),即使该平台有定价行。
// 算法(mapping pricing 并联
//
// 当映射 keyexact 或 wildcard 展开后的候选)能命中定价时,结果中的 Name 使用**定价的原始大小写**
// (定价是模型身份的事实来源),否则保留映射 key 的原始大小写。
// 每个结果尝试从 platform 索引查找精确定价,未配置则 Pricing=nil
// 结果按 (Platform, Name) 稳定排序,并按 (Platform, lowercase(Name)) 去重
// - Pass Amapping遍历 ModelMapping
// - 精确 src → target显示名 = src用户视角定价用 target 在同 platform 定价里查
// mapping 改写后实际计费的是 target这是用户感知的"实际花费"
// target 为空或为通配符时退化为按 src 自查
// - 通配符 src如 "claude-3-*"):用同 platform 定价里前缀匹配的模型作为候选展开,
// 每个候选用自身定价(通配符场景一般是 passthroughtarget 通常也是通配符)。
// - "*" 单独 mapping key 走通配符分支(前缀为空 → 全展开)。
// - Pass Bpricing-only遍历 ModelPricing 中所有非通配符模型,对未在 Pass A 添加过的
// 补齐——显示名 = 定价模型名,定价 = 自身(这是关键修复:定价存在即代表渠道支持该模型,
// 即使没配映射)。
//
// 显示名命中定价时使用**定价的原始大小写**(定价是模型身份的事实来源)。
// 按 (Platform, Name) 稳定排序,按 (Platform, lowercase(Name)) 去重,先到者胜出。
//
// 注意:定价仅在 channel.ModelPricing 内查找——全局 LiteLLM 回落由调用方
// `ChannelService.ListAvailable`)在合成展示数据时叠加。
func (c *Channel) SupportedModels() []SupportedModel {
if c == nil || len(c.ModelMapping) == 0 {
if c == nil {
return nil
}
if len(c.ModelMapping) == 0 && len(c.ModelPricing) == 0 {
return nil
}
@@ -476,21 +486,24 @@ func (c *Channel) SupportedModels() []SupportedModel {
seen := make(map[dedupKey]struct{})
result := make([]SupportedModel, 0)
add := func(platform, name string, pidx *platformPricingIndex) {
key := dedupKey{platform: platform, name: strings.ToLower(name)}
// lookup 在 platform pricing index 中按精确名查定价,命中时返回定价大小写。
lookup := func(pidx *platformPricingIndex, name string) (display string, pricing *ChannelModelPricing) {
if pidx == nil || name == "" {
return name, nil
}
lower := strings.ToLower(name)
if p, ok := pidx.byLower[lower]; ok {
return pidx.originalCase[lower], p
}
return name, nil
}
add := func(platform, displayName string, pricing *ChannelModelPricing) {
key := dedupKey{platform: platform, name: strings.ToLower(displayName)}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
var pricing *ChannelModelPricing
displayName := name
if pidx != nil {
lower := strings.ToLower(name)
if p, ok := pidx.byLower[lower]; ok {
pricing = p
displayName = pidx.originalCase[lower] // 定价大小写胜出
}
}
result = append(result, SupportedModel{
Name: displayName,
Platform: platform,
@@ -498,12 +511,13 @@ func (c *Channel) SupportedModels() []SupportedModel {
})
}
// Pass A从 mapping 展开
for platform, mapping := range c.ModelMapping {
if len(mapping) == 0 {
continue
}
pidx := idx[platform] // 可能为 nil该平台无定价行
for src := range mapping {
pidx := idx[platform]
for src, target := range mapping {
prefix, isWild := splitWildcardSuffix(src)
if isWild {
if pidx == nil {
@@ -512,12 +526,32 @@ func (c *Channel) SupportedModels() []SupportedModel {
prefixLower := strings.ToLower(prefix)
for _, candidate := range pidx.names {
if strings.HasPrefix(strings.ToLower(candidate), prefixLower) {
add(platform, candidate, pidx)
display, pricing := lookup(pidx, candidate)
add(platform, display, pricing)
}
}
continue
}
add(platform, src, pidx)
// 精确 mapping定价按 target 查target 缺失/通配则退化按 src 查
pricingKey := target
if pricingKey == "" {
pricingKey = src
}
if _, targetWild := splitWildcardSuffix(pricingKey); targetWild {
pricingKey = src
}
_, pricing := lookup(pidx, pricingKey)
// 显示名优先用 src 在定价里的原始大小写(若 src 本身是个定价模型名)
displayName, _ := lookup(pidx, src)
add(platform, displayName, pricing)
}
}
// Pass B从 pricing 补齐 mapping 未覆盖的具体模型(修复"定价存在但没配映射 → 不显示"
for platform, pidx := range idx {
for _, name := range pidx.names {
display, pricing := lookup(pidx, name)
add(platform, display, pricing)
}
}

View File

@@ -36,7 +36,10 @@ type AvailableChannel struct {
// ListAvailable 返回所有渠道的可用视图:每个渠道附带关联分组信息与支持模型列表。
//
// 支持模型通过 (*Channel).SupportedModels() 计算得到(见 channel.go)。
// 支持模型通过 (*Channel).SupportedModels() 计算mapping pricing 并联)。
// 对于渠道未配置定价的模型,进一步用 PricingService 的全局 LiteLLM 数据合成
// 一份展示用定价,让用户看到默认价格而非"未配置"。
//
// 关联分组信息通过 groupRepo.ListActive 查询后按 ID 映射;渠道 GroupIDs 中未在活跃列表中
// 的分组(已停用或删除)会被忽略。
//
@@ -78,6 +81,9 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
ch.normalizeBillingModelSource()
supported := ch.SupportedModels()
s.fillGlobalPricingFallback(supported)
out = append(out, AvailableChannel{
ID: ch.ID,
Name: ch.Name,
@@ -86,7 +92,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
BillingModelSource: ch.BillingModelSource,
RestrictModels: ch.RestrictModels,
Groups: groups,
SupportedModels: ch.SupportedModels(),
SupportedModels: supported,
})
}
@@ -95,3 +101,49 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
})
return out, nil
}
// fillGlobalPricingFallback 对未命中渠道定价的支持模型,从全局 LiteLLM 数据合成一份
// 展示用定价(按 token 计费)。仅用于「可用渠道」展示,不影响真实计费链路。
//
// 当 s.pricingService 为 nil测试场景跳过回落。
func (s *ChannelService) fillGlobalPricingFallback(models []SupportedModel) {
if s.pricingService == nil {
return
}
for i := range models {
if models[i].Pricing != nil {
continue
}
lp := s.pricingService.GetModelPricing(models[i].Name)
if lp == nil {
continue
}
models[i].Pricing = synthesizePricingFromLiteLLM(lp)
}
}
// synthesizePricingFromLiteLLM 把 LiteLLM 的定价数据转成 ChannelModelPricing 形态,
// 仅用于展示。BillingMode 固定为 token图片场景的 OutputCostPerImageToken 也归到
// ImageOutputPrice 字段(与渠道侧"图片输出按 token 计价"语义一致)。
//
// LiteLLM 中字段 0 视为未配置,不带入展示。
func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing) *ChannelModelPricing {
if lp == nil {
return nil
}
return &ChannelModelPricing{
BillingMode: BillingModeToken,
InputPrice: nonZeroPtr(lp.InputCostPerToken),
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),
CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost),
ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken),
}
}
func nonZeroPtr(v float64) *float64 {
if v == 0 {
return nil
}
return &v
}

View File

@@ -75,7 +75,7 @@ func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) *
repo := &mockChannelRepository{
listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
}
return NewChannelService(repo, groupRepo, nil)
return NewChannelService(repo, groupRepo, nil, nil)
}
func TestListAvailable_EmptyActiveGroups_NoGroupsAttached(t *testing.T) {
@@ -134,7 +134,7 @@ func TestListAvailable_ListAllErrorPropagates(t *testing.T) {
listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel },
}
groupRepo := &stubGroupRepoForAvailable{}
svc := NewChannelService(repo, groupRepo, nil)
svc := NewChannelService(repo, groupRepo, nil, nil)
out, err := svc.ListAvailable(context.Background())
require.Nil(t, out)
require.ErrorIs(t, err, sentinel)

View File

@@ -143,17 +143,21 @@ type ChannelService struct {
repo ChannelRepository
groupRepo GroupRepository
authCacheInvalidator APIKeyAuthCacheInvalidator
pricingService *PricingService // 用于「可用渠道」展示时回落到全局定价;可为 nil测试场景
cache atomic.Value // *channelCache
cacheSF singleflight.Group
}
// NewChannelService 创建渠道服务实例
func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, authCacheInvalidator APIKeyAuthCacheInvalidator) *ChannelService {
// NewChannelService 创建渠道服务实例
// pricingService 仅供 ListAvailable 在渠道未配置定价时回落到全局 LiteLLM 数据;
// 计费热路径走独立的 ModelPricingResolver与此参数无关。可传 nil。
func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, pricingService *PricingService) *ChannelService {
s := &ChannelService{
repo: repo,
groupRepo: groupRepo,
authCacheInvalidator: authCacheInvalidator,
pricingService: pricingService,
}
return s
}

View File

@@ -189,11 +189,11 @@ func (m *mockChannelAuthCacheInvalidator) InvalidateAuthCacheByGroupID(_ context
// ---------------------------------------------------------------------------
func newTestChannelService(repo *mockChannelRepository) *ChannelService {
return NewChannelService(repo, nil, nil)
return NewChannelService(repo, nil, nil, nil)
}
func newTestChannelServiceWithAuth(repo *mockChannelRepository, auth *mockChannelAuthCacheInvalidator) *ChannelService {
return NewChannelService(repo, nil, auth)
return NewChannelService(repo, nil, auth, nil)
}
// makeStandardRepo returns a repo that serves one active channel with anthropic pricing

View File

@@ -476,29 +476,12 @@ func TestSupportedModels_WildcardExpandedFromPricing(t *testing.T) {
for _, m := range got {
names = append(names, m.Name)
}
require.ElementsMatch(t, []string{"claude-sonnet-4-5", "claude-sonnet-4-6"}, names)
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_PlatformWithoutMappingSkipped(t *testing.T) {
ch := &Channel{
ModelPricing: []ChannelModelPricing{
{ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}},
{ID: 2, Platform: "openai", Models: []string{"gpt-4o"}},
},
ModelMapping: map[string]map[string]string{
"anthropic": {"claude-sonnet-4-6": "claude-sonnet-4-6"},
// openai 没有 mapping 条目
},
}
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)
}
func TestSupportedModels_MissingPricingKeepsNilPricing(t *testing.T) {
ch := &Channel{
@@ -584,7 +567,8 @@ func TestSupportedModels_WildcardOnlyPricingRowsSkipped(t *testing.T) {
}
func TestSupportedModels_WildcardPrefixMatchesNothing(t *testing.T) {
// 通配符模式无任何对应定价模型时,该平台应产出 0 个模型。
// 通配符模式无任何对应定价模型时,该平台 mapping 路不产出;
// 但其他平台的 pricing-only 模型仍会通过 Pass B 出现。
ch := &Channel{
ModelPricing: []ChannelModelPricing{
{ID: 1, Platform: "openai", Models: []string{"gpt-4o"}},
@@ -593,11 +577,15 @@ func TestSupportedModels_WildcardPrefixMatchesNothing(t *testing.T) {
"anthropic": {"gpt-foo-*": "gpt-foo-1"},
},
}
require.Empty(t, ch.SupportedModels())
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 定价行拉到 anthropic 平台下;
// openai 的 pricing-only 模型则正常通过 Pass B 暴露在 openai 平台下。
ch := &Channel{
ModelPricing: []ChannelModelPricing{
{ID: 1, Platform: "openai", Models: []string{"claude-sonnet-4-6"}},
@@ -606,7 +594,10 @@ func TestSupportedModels_CrossPlatformPricingDoesNotBleed(t *testing.T) {
"anthropic": {"claude-sonnet-*": "x"},
},
}
require.Empty(t, ch.SupportedModels())
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) {
@@ -626,7 +617,8 @@ func TestSupportedModels_CaseInsensitiveDedup(t *testing.T) {
}
func TestSupportedModels_EmptyPlatformMapping(t *testing.T) {
// ModelMapping 有一个 platform key 但 value 空 map —— 该 platform 应被跳过。
// ModelMapping 平台 key 存在但 value 空 mapmapping 路跳过该平台,
// 但 pricing 路仍会把该平台的定价模型补齐关键修复azcc 这种"只配定价不配映射"渠道)。
ch := &Channel{
ModelPricing: []ChannelModelPricing{
{ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}},
@@ -635,7 +627,11 @@ func TestSupportedModels_EmptyPlatformMapping(t *testing.T) {
"anthropic": {},
},
}
require.Empty(t, ch.SupportedModels())
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) {
@@ -668,3 +664,65 @@ func TestSupportedModels_AsteriskOnlyMappingExpandsAllPriced(t *testing.T) {
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)
}

View File

@@ -184,7 +184,7 @@ func newResolverWithChannel(t *testing.T, pricing []ChannelModelPricing) *ModelP
return map[int64]string{groupID: "anthropic"}, nil
},
}
cs := NewChannelService(repo, nil, nil)
cs := NewChannelService(repo, nil, nil, nil)
bs := newTestBillingServiceForResolver()
return NewModelPricingResolver(cs, bs)
}
@@ -517,7 +517,7 @@ func TestResolve_WithChannelOverride_CacheError(t *testing.T) {
return nil, errors.New("database unavailable")
},
}
cs := NewChannelService(repo, nil, nil)
cs := NewChannelService(repo, nil, nil, nil)
bs := newTestBillingServiceForResolver()
r := NewModelPricingResolver(cs, bs)