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:
parent
18790386a7
commit
c26d3ae1b5
@ -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. 渠道已选 BillingMode(admin 在 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),
|
||||||
|
|||||||
@ -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}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user