feat(channels): 渠道未填价时按 LiteLLM 默认价展示

「可用渠道」展示链路有两个未覆盖场景导致用户看到"未配置定价":

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 各分支、
合成器三种模式选择、空条目兜底与既有价格保护。
This commit is contained in:
name 2026-05-15 01:28:13 +08:00
parent 18790386a7
commit c26d3ae1b5
2 changed files with 190 additions and 8 deletions

View File

@ -103,7 +103,11 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
} }
// fillGlobalPricingFallback 对未命中渠道定价的支持模型,从全局 LiteLLM 数据合成一份 // fillGlobalPricingFallback 对未命中渠道定价的支持模型,从全局 LiteLLM 数据合成一份
// 展示用定价(按 token 计费)。仅用于「可用渠道」展示,不影响真实计费链路。 // 展示用定价。仅用于「可用渠道」展示,不影响真实计费链路。
//
// 触发条件:
// 1. Pricing == nil渠道完全没声明该模型的定价条目
// 2. Pricing 非 nil 但所有价格字段为空admin UI 建了条目但没填价格)
// //
// 当 s.pricingService 为 nil测试场景跳过回落。 // 当 s.pricingService 为 nil测试场景跳过回落。
func (s *ChannelService) fillGlobalPricingFallback(models []SupportedModel) { func (s *ChannelService) fillGlobalPricingFallback(models []SupportedModel) {
@ -111,28 +115,72 @@ func (s *ChannelService) fillGlobalPricingFallback(models []SupportedModel) {
return return
} }
for i := range models { for i := range models {
if models[i].Pricing != nil { if !pricingNeedsFallback(models[i].Pricing) {
continue continue
} }
lp := s.pricingService.GetModelPricing(models[i].Name) lp := s.pricingService.GetModelPricing(models[i].Name)
if lp == nil { if lp == nil {
continue continue
} }
models[i].Pricing = synthesizePricingFromLiteLLM(lp) 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 形态, // synthesizePricingFromLiteLLM 把 LiteLLM 的定价数据转成 ChannelModelPricing 形态,
// 仅用于展示。BillingMode 固定为 token图片场景的 OutputCostPerImageToken 也归到 // 仅用于展示。
// ImageOutputPrice 字段(与渠道侧"图片输出按 token 计价"语义一致)。 //
// 计费模式优先级:
// 1. 渠道已选 BillingModeadmin 在 UI 里选了 image / per_request 但没填价的场景,
// 按选定模式合成对应字段)
// 2. LiteLLM mode="image_generation" → image
// 3. 默认 token
// //
// LiteLLM 中字段 0 视为未配置,不带入展示。 // LiteLLM 中字段 0 视为未配置,不带入展示。
func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing) *ChannelModelPricing { func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing, existing *ChannelModelPricing) *ChannelModelPricing {
if lp == nil { if lp == nil {
return 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{ return &ChannelModelPricing{
BillingMode: BillingModeToken, BillingMode: mode,
InputPrice: nonZeroPtr(lp.InputCostPerToken), InputPrice: nonZeroPtr(lp.InputCostPerToken),
OutputPrice: nonZeroPtr(lp.OutputCostPerToken), OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),

View File

@ -175,3 +175,137 @@ func TestListAvailable_DefaultsEmptyBillingModelSource(t *testing.T) {
require.Equal(t, BillingModelSourceChannelMapped, byName["empty"]) require.Equal(t, BillingModelSourceChannelMapped, byName["empty"])
require.Equal(t, BillingModelSourceUpstream, byName["explicit"]) require.Equal(t, BillingModelSourceUpstream, byName["explicit"])
} }
func TestPricingNeedsFallback(t *testing.T) {
tests := []struct {
name string
in *ChannelModelPricing
want bool
}{
{"nil", nil, true},
{"empty struct", &ChannelModelPricing{BillingMode: BillingModeToken}, true},
{"all-empty intervals", &ChannelModelPricing{
BillingMode: BillingModeImage,
Intervals: []PricingInterval{{TierLabel: "1K"}, {TierLabel: "2K"}},
}, true},
{"flat input set", &ChannelModelPricing{InputPrice: testPtrFloat64(3e-6)}, false},
{"flat per_request set", &ChannelModelPricing{PerRequestPrice: testPtrFloat64(0.04)}, false},
{"interval with price", &ChannelModelPricing{
Intervals: []PricingInterval{{TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)}},
}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, pricingNeedsFallback(tt.in))
})
}
}
func TestSynthesizePricingFromLiteLLM_TokenMode(t *testing.T) {
lp := &LiteLLMModelPricing{
Mode: "chat",
InputCostPerToken: 3e-6,
OutputCostPerToken: 1.5e-5,
CacheCreationInputTokenCost: 3.75e-6,
CacheReadInputTokenCost: 3e-7,
}
got := synthesizePricingFromLiteLLM(lp, nil)
require.NotNil(t, got)
require.Equal(t, BillingModeToken, got.BillingMode)
require.NotNil(t, got.InputPrice)
require.InDelta(t, 3e-6, *got.InputPrice, 1e-12)
require.NotNil(t, got.CacheReadPrice)
}
func TestSynthesizePricingFromLiteLLM_ImageGenerationMode(t *testing.T) {
// LiteLLM mode=image_generation 且渠道未声明模式时,按 image 合成。
lp := &LiteLLMModelPricing{
Mode: "image_generation",
OutputCostPerImageToken: 4e-5,
}
got := synthesizePricingFromLiteLLM(lp, nil)
require.NotNil(t, got)
require.Equal(t, BillingModeImage, got.BillingMode)
require.Nil(t, got.PerRequestPrice)
require.NotNil(t, got.ImageOutputPrice)
}
func TestSynthesizePricingFromLiteLLM_RespectsExistingChannelMode(t *testing.T) {
// admin UI 选了 per_request 但没填价LiteLLM 数据按 per_request 合成,
// 即便 LiteLLM 标的是 chat 模式也尊重渠道选择。
lp := &LiteLLMModelPricing{
Mode: "chat",
InputCostPerToken: 5e-6,
OutputCostPerImage: 0.04,
}
existing := &ChannelModelPricing{BillingMode: BillingModePerRequest}
got := synthesizePricingFromLiteLLM(lp, existing)
require.NotNil(t, got)
require.Equal(t, BillingModePerRequest, got.BillingMode)
require.NotNil(t, got.PerRequestPrice)
require.InDelta(t, 0.04, *got.PerRequestPrice, 1e-12)
}
func TestFillGlobalPricingFallback_NilPricing(t *testing.T) {
pricingSvc := newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"claude-opus-4-5": {Mode: "chat", InputCostPerToken: 5e-6},
})
svc := &ChannelService{pricingService: pricingSvc}
models := []SupportedModel{
{Name: "claude-opus-4-5", Platform: "anthropic"},
}
svc.fillGlobalPricingFallback(models)
require.NotNil(t, models[0].Pricing)
require.NotNil(t, models[0].Pricing.InputPrice)
require.InDelta(t, 5e-6, *models[0].Pricing.InputPrice, 1e-12)
}
func TestFillGlobalPricingFallback_EmptyPricingFillsFromLiteLLM(t *testing.T) {
// 核心场景admin UI 建了 pricing 条目image 模式)但没填价,应走 LiteLLM 兜底。
pricingSvc := newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"gpt-image-1": {
Mode: "image_generation",
OutputCostPerImageToken: 4e-5,
},
})
svc := &ChannelService{pricingService: pricingSvc}
models := []SupportedModel{
{
Name: "gpt-image-1",
Platform: "openai",
Pricing: &ChannelModelPricing{
BillingMode: BillingModeImage,
Intervals: []PricingInterval{{TierLabel: "1K"}, {TierLabel: "2K"}},
},
},
}
svc.fillGlobalPricingFallback(models)
require.NotNil(t, models[0].Pricing)
require.Equal(t, BillingModeImage, models[0].Pricing.BillingMode)
require.NotNil(t, models[0].Pricing.ImageOutputPrice)
require.InDelta(t, 4e-5, *models[0].Pricing.ImageOutputPrice, 1e-12)
}
func TestFillGlobalPricingFallback_KeepsExistingPrice(t *testing.T) {
// 渠道已经填了价格的条目不应被回落覆盖。
pricingSvc := newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"served-model": {Mode: "chat", InputCostPerToken: 1e-6},
})
svc := &ChannelService{pricingService: pricingSvc}
existing := &ChannelModelPricing{
BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(9e-9),
}
models := []SupportedModel{
{Name: "served-model", Platform: "anthropic", Pricing: existing},
}
svc.fillGlobalPricingFallback(models)
require.Same(t, existing, models[0].Pricing)
}
func newStubPricingServiceFromMap(data map[string]*LiteLLMModelPricing) *PricingService {
return &PricingService{pricingData: data}
}