「可用渠道」展示链路有两个未覆盖场景导致用户看到"未配置定价": 1. admin 在 UI 里建了 ModelPricing 条目但没填任何价格 (常见于 per_request / image 模式只填了 tier_label 没填单价): 原 fallback 只检查 Pricing == nil, 这种空条目会跳过 LiteLLM 兜底。 2. LiteLLM 把图片模型标记 mode=image_generation, 但合成器固定按 token 模式合成, 把 OutputCostPerImage / 图片 token 价丢到错误字段。 改动 (仅 backend/internal/service/channel_available.go): - 新增 pricingNeedsFallback: 价格字段全空 (含 intervals 全空) 视为 未配置, 触发 LiteLLM 兜底。 - synthesizePricingFromLiteLLM 加 existing 参数: 优先尊重渠道已选 BillingMode (per_request / image 也按此模式合成), 没选才看 LiteLLM mode, 仍未命中默认 token。 - image / per_request 分支用 OutputCostPerImage 填 PerRequestPrice, OutputCostPerImageToken 填 ImageOutputPrice, 让 gpt-image / dall-e 系列展示出参考价。 仅影响展示链路, 真实计费走 BillingService / ModelPricingResolver 完全不受影响。新增 8 个单元测试覆盖 pricingNeedsFallback 各分支、 合成器三种模式选择、空条目兜底与既有价格保护。
198 lines
6.3 KiB
Go
198 lines
6.3 KiB
Go
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 数据合成一份
|
||
// 展示用定价。仅用于「可用渠道」展示,不影响真实计费链路。
|
||
//
|
||
// 触发条件:
|
||
// 1. Pricing == nil(渠道完全没声明该模型的定价条目)
|
||
// 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格)
|
||
//
|
||
// 当 s.pricingService 为 nil(测试场景),跳过回落。
|
||
func (s *ChannelService) fillGlobalPricingFallback(models []SupportedModel) {
|
||
if s.pricingService == nil {
|
||
return
|
||
}
|
||
for i := range models {
|
||
if !pricingNeedsFallback(models[i].Pricing) {
|
||
continue
|
||
}
|
||
lp := s.pricingService.GetModelPricing(models[i].Name)
|
||
if lp == nil {
|
||
continue
|
||
}
|
||
models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing)
|
||
}
|
||
}
|
||
|
||
// pricingNeedsFallback 判定一个 ChannelModelPricing 是否需要走全局回落。
|
||
// 价格全部缺失(无 flat 字段且无任何带价 interval)即视为未配置。
|
||
func pricingNeedsFallback(p *ChannelModelPricing) bool {
|
||
if p == nil {
|
||
return true
|
||
}
|
||
if p.InputPrice != nil || p.OutputPrice != nil ||
|
||
p.CacheWritePrice != nil || p.CacheReadPrice != nil ||
|
||
p.ImageOutputPrice != nil || p.PerRequestPrice != nil {
|
||
return false
|
||
}
|
||
for _, iv := range p.Intervals {
|
||
if iv.InputPrice != nil || iv.OutputPrice != nil ||
|
||
iv.CacheWritePrice != nil || iv.CacheReadPrice != nil ||
|
||
iv.PerRequestPrice != nil {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// synthesizePricingFromLiteLLM 把 LiteLLM 的定价数据转成 ChannelModelPricing 形态,
|
||
// 仅用于展示。
|
||
//
|
||
// 计费模式优先级:
|
||
// 1. 渠道已选 BillingMode(admin 在 UI 里选了 image / per_request 但没填价的场景,
|
||
// 按选定模式合成对应字段)
|
||
// 2. LiteLLM mode="image_generation" → image
|
||
// 3. 默认 token
|
||
//
|
||
// LiteLLM 中字段 0 视为未配置,不带入展示。
|
||
func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing, existing *ChannelModelPricing) *ChannelModelPricing {
|
||
if lp == nil {
|
||
return existing
|
||
}
|
||
|
||
mode := BillingModeToken
|
||
switch {
|
||
case existing != nil && existing.BillingMode != "":
|
||
mode = existing.BillingMode
|
||
case lp.Mode == "image_generation":
|
||
mode = BillingModeImage
|
||
}
|
||
|
||
if mode == BillingModeImage || mode == BillingModePerRequest {
|
||
return &ChannelModelPricing{
|
||
BillingMode: mode,
|
||
PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage),
|
||
ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken),
|
||
InputPrice: nonZeroPtr(lp.InputCostPerToken),
|
||
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
|
||
}
|
||
}
|
||
return &ChannelModelPricing{
|
||
BillingMode: mode,
|
||
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
|
||
}
|