Files
sub2api/backend/internal/service/channel_available.go
erio 6cd7c60549 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.
2026-04-23 00:45:10 +08:00

150 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"fmt"
"sort"
"strings"
)
// AvailableGroupRef 渠道视图中关联分组的简要信息。
//
// 用户侧「可用渠道」页面据此展示:专属分组 vs 公开分组IsExclusive
// 订阅 vs 标准SubscriptionType、默认倍率RateMultiplier。用户专属倍率
// 不在这里暴露,前端自己通过 /groups/rates 拉取,和 API 密钥页面保持一致。
type AvailableGroupRef struct {
ID int64
Name string
Platform string
SubscriptionType string
RateMultiplier float64
IsExclusive bool
}
// AvailableChannel 可用渠道视图:用于「可用渠道」页面展示渠道基础信息 +
// 关联的分组 + 推导出的支持模型列表(无通配符)。
type AvailableChannel struct {
ID int64
Name string
Description string
Status string
BillingModelSource string
RestrictModels bool
Groups []AvailableGroupRef
SupportedModels []SupportedModel
}
// ListAvailable 返回所有渠道的可用视图:每个渠道附带关联分组信息与支持模型列表。
//
// 支持模型通过 (*Channel).SupportedModels() 计算mapping pricing 并联)。
// 对于渠道未配置定价的模型,进一步用 PricingService 的全局 LiteLLM 数据合成
// 一份展示用定价,让用户看到默认价格而非"未配置"。
//
// 关联分组信息通过 groupRepo.ListActive 查询后按 ID 映射;渠道 GroupIDs 中未在活跃列表中
// 的分组(已停用或删除)会被忽略。
//
// 前置条件s.groupRepo 必须非 nil由 wire DI 保证)。直接 nil-deref 用于 fail-fast
// 避免静默掩盖注入缺失。
func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, error) {
channels, err := s.repo.ListAll(ctx)
if err != nil {
return nil, fmt.Errorf("list channels: %w", err)
}
groups, err := s.groupRepo.ListActive(ctx)
if err != nil {
return nil, fmt.Errorf("list active groups: %w", err)
}
groupByID := make(map[int64]AvailableGroupRef, len(groups))
for i := range groups {
g := groups[i]
groupByID[g.ID] = AvailableGroupRef{
ID: g.ID,
Name: g.Name,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
IsExclusive: g.IsExclusive,
}
}
out := make([]AvailableChannel, 0, len(channels))
for i := range channels {
ch := &channels[i]
groups := make([]AvailableGroupRef, 0, len(ch.GroupIDs))
for _, gid := range ch.GroupIDs {
if ref, ok := groupByID[gid]; ok {
groups = append(groups, ref)
}
}
sort.SliceStable(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
ch.normalizeBillingModelSource()
supported := ch.SupportedModels()
s.fillGlobalPricingFallback(supported)
out = append(out, AvailableChannel{
ID: ch.ID,
Name: ch.Name,
Description: ch.Description,
Status: ch.Status,
BillingModelSource: ch.BillingModelSource,
RestrictModels: ch.RestrictModels,
Groups: groups,
SupportedModels: supported,
})
}
sort.SliceStable(out, func(i, j int) bool {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
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
}