From c26d3ae1b5e5337fde63bb79ef3dfae3ed1de84f Mon Sep 17 00:00:00 2001 From: name <136912576+is7Qin@users.noreply.github.com> Date: Fri, 15 May 2026 01:28:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E6=B8=A0=E9=81=93=E6=9C=AA?= =?UTF-8?q?=E5=A1=AB=E4=BB=B7=E6=97=B6=E6=8C=89=20LiteLLM=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E4=BB=B7=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「可用渠道」展示链路有两个未覆盖场景导致用户看到"未配置定价": 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 各分支、 合成器三种模式选择、空条目兜底与既有价格保护。 --- backend/internal/service/channel_available.go | 64 +++++++-- .../service/channel_available_test.go | 134 ++++++++++++++++++ 2 files changed, 190 insertions(+), 8 deletions(-) diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 815730e3..d2d24659 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -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), diff --git a/backend/internal/service/channel_available_test.go b/backend/internal/service/channel_available_test.go index 8be70ceb..d59e587e 100644 --- a/backend/internal/service/channel_available_test.go +++ b/backend/internal/service/channel_available_test.go @@ -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} +}