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