Merge pull request #7573 from Yukinoshita-Yukino/fix/channel-image-price-inherit-when-unset

fix(billing): keep catalog image prices when channel pricing leaves them unset
This commit is contained in:
Wesley Liddick
2026-09-28 09:57:02 +08:00
committed by GitHub
4 changed files with 144 additions and 56 deletions
+3 -9
View File
@@ -1350,8 +1350,8 @@ func (s *BillingService) getModelPricingAt(model string, pricingAt time.Time) (*
return nil, fmt.Errorf("%w for model: %s", ErrModelPricingUnavailable, model)
}
// GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值
// 渠道存在时,未配置的图片输出价格归零(不回退到 LiteLLM)
// GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值。
// 与其他 token 字段一致,渠道留空的图片输入/输出价沿用目录价,见 applyChannelImagePriceOverrides。
func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing *ChannelModelPricing) (*ModelPricing, error) {
pricing, err := s.GetModelPricing(model)
if err != nil {
@@ -1367,13 +1367,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing
pricing.FastMultiplier = channelPricing.FastMultiplier
pricing.FlexMultiplier = channelPricing.FlexMultiplier
pricing.ReasoningEffortMultipliers = maps.Clone(channelPricing.ReasoningEffortMultipliers)
if channelPricing.ImageOutputPrice != nil {
pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice
} else {
pricing.ImageOutputPricePerToken = 0
}
pricing.ImageOutputPriceExplicit = true
applyChannelImageInputPrice(channelPricing, pricing)
applyChannelImagePriceOverrides(channelPricing, pricing)
return pricing, nil
}
@@ -1874,19 +1874,49 @@ func TestGetModelPricingWithChannel_UnknownModelReturnsError(t *testing.T) {
require.Contains(t, err.Error(), "pricing not found")
}
func TestGetModelPricingWithChannel_NilImageOutputPriceZerosAndMarksExplicit(t *testing.T) {
svc := newTestBillingService()
func TestGetModelPricingWithChannel_NilImagePricesInheritCatalog(t *testing.T) {
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"gpt-image-2": {
Mode: "image_generation",
InputCostPerToken: 5e-6,
OutputCostPerToken: 10e-6,
InputCostPerImageToken: 8e-6,
OutputCostPerImageToken: 30e-6,
},
}))
chPricing := &ChannelModelPricing{
InputPrice: testPtrFloat64(10e-6),
OutputPrice: testPtrFloat64(20e-6),
// ImageOutputPrice intentionally nil
InputPrice: testPtrFloat64(6e-6),
OutputPrice: testPtrFloat64(12e-6),
// ImageInputPrice / ImageOutputPrice intentionally nil
}
pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing)
pricing, err := svc.GetModelPricingWithChannel("gpt-image-2", chPricing)
require.NoError(t, err)
require.InDelta(t, 6e-6, pricing.InputPricePerToken, 1e-12)
require.InDelta(t, 30e-6, pricing.ImageOutputPricePerToken, 1e-12)
require.False(t, pricing.ImageOutputPriceExplicit)
require.InDelta(t, 8e-6, pricing.ImageInputPricePerToken, 1e-12)
}
func TestGetModelPricingWithChannel_ExplicitImagePricesOverrideCatalog(t *testing.T) {
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"gpt-image-2": {
Mode: "image_generation",
InputCostPerToken: 5e-6,
InputCostPerImageToken: 8e-6,
OutputCostPerImageToken: 30e-6,
},
}))
pricing, err := svc.GetModelPricingWithChannel("gpt-image-2", &ChannelModelPricing{
ImageInputPrice: testPtrFloat64(9e-6),
ImageOutputPrice: testPtrFloat64(0),
})
require.NoError(t, err)
require.Equal(t, 0.0, pricing.ImageOutputPricePerToken)
require.True(t, pricing.ImageOutputPriceExplicit)
require.True(t, pricing.ImageOutputPriceExplicit, "显式 0 仍表示图片输出免费")
require.InDelta(t, 9e-6, pricing.ImageInputPricePerToken, 1e-12)
}
func TestComputeTokenBreakdown_ExplicitZeroImagePrice_NoFallback(t *testing.T) {
@@ -249,29 +249,30 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier
resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier
resolved.BasePricing.ReasoningEffortMultipliers = maps.Clone(chPricing.ReasoningEffortMultipliers)
// 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM)
if chPricing.ImageOutputPrice != nil {
resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
} else {
resolved.BasePricing.ImageOutputPricePerToken = 0
}
resolved.BasePricing.ImageOutputPriceExplicit = true
applyChannelImageInputPrice(chPricing, resolved.BasePricing)
applyChannelImagePriceOverrides(chPricing, resolved.BasePricing)
// 区间未命中时回退到上面已经应用渠道覆盖的基础价。
resolved.Intervals = filterValidIntervals(chPricing.Intervals)
}
// applyChannelImageInputPrice 应用渠道图片输入价:显式配置则用配置值;
// 未配置时归零,使 computeTokenBreakdown 回退到文本输入价(向后兼容,
// 避免 commit 引入的 LiteLLM 图片输入价泄漏进渠道自定义定价)。
// 与 image_output 不同,此处不设 Explicit 标志——图片输入未配置应回退文本价,
// 而非硬置 0。
func applyChannelImageInputPrice(chPricing *ChannelModelPricing, pricing *ModelPricing) {
if chPricing != nil && chPricing.ImageInputPrice != nil {
// applyChannelImagePriceOverrides 应用渠道图片输入/输出价,规则与其他 token 字段一致:
// - 渠道填写了(含 0):覆盖目录价。图片输出价同时标记 ImageOutputPriceExplicit,
// 显式 0 表示图片输出免费,computeTokenBreakdown 不再回退文本输出价。
// - 渠道留空(nil):沿用目录价(LiteLLM 价格表或内置兜底价)。目录也没有图片价时,
// computeTokenBreakdown 分别回退文本输入/输出价。
//
// 此前留空会被归零,图片输出还被标成显式 0:只建了空价卡的图片模型,图片输出
// 一律按 0 计费,图片输入按文本价计费。
func applyChannelImagePriceOverrides(chPricing *ChannelModelPricing, pricing *ModelPricing) {
if chPricing == nil || pricing == nil {
return
}
if chPricing.ImageOutputPrice != nil {
pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
pricing.ImageOutputPriceExplicit = true
}
if chPricing.ImageInputPrice != nil {
pricing.ImageInputPricePerToken = *chPricing.ImageInputPrice
} else {
pricing.ImageInputPricePerToken = 0
}
}
@@ -369,15 +370,8 @@ func intervalToModelPricing(iv *PricingInterval, base *ModelPricing, chPricing *
pricing.CacheReadPricePerToken = applyMultiplier(pricing.CacheReadPricePerToken, iv.CacheReadMultiplier)
pricing.CacheReadPricePerTokenPriority = applyMultiplier(pricing.CacheReadPricePerTokenPriority, iv.CacheReadMultiplier)
}
// 渠道定价存在时,ImageOutputPrice 显式覆盖;图片输入价用渠道级配置
// (区间不携带图片输入价,与 image_output 一致)。
if chPricing != nil {
pricing.ImageOutputPriceExplicit = true
if chPricing.ImageOutputPrice != nil {
pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
}
applyChannelImageInputPrice(chPricing, pricing)
}
// 区间不携带图片价,沿用渠道级配置;渠道留空时保留基础价里的目录图片价。
applyChannelImagePriceOverrides(chPricing, pricing)
return pricing
}
@@ -722,23 +722,50 @@ func TestFilterValidIntervals(t *testing.T) {
// 9. ImageOutputPriceExplicit tests
// ===========================================================================
func TestApplyTokenOverrides_FlatSetsImageOutputPriceExplicit(t *testing.T) {
r := newResolverWithChannel(t, []ChannelModelPricing{{
// withCatalogImagePrices 给测试基础价补上目录图片价($8 输入 / $30 输出 per MTok)。
func withCatalogImagePrices(r *ModelPricingResolver) *ModelPricingResolver {
base := r.billingService.fallbackPrices["claude-sonnet-4"]
base.ImageInputPricePerToken = 8e-6
base.ImageOutputPricePerToken = 30e-6
return r
}
func TestApplyTokenOverrides_FlatUnsetImagePricesInheritCatalog(t *testing.T) {
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(3e-6),
OutputPrice: testPtrFloat64(15e-6),
// ImageOutputPrice intentionally nil
}})
// ImageInputPrice / ImageOutputPrice intentionally nil
}}))
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
require.Equal(t, PricingSourceChannel, resolved.Source)
require.False(t, resolved.BasePricing.ImageOutputPriceExplicit, "留空不应标记显式")
require.InDelta(t, 30e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12)
require.InDelta(t, 8e-6, resolved.BasePricing.ImageInputPricePerToken, 1e-12)
}
func TestApplyTokenOverrides_ExplicitZeroImageOutputPriceStaysZero(t *testing.T) {
// 渠道显式填 0 仍表示图片输出免费,不回退目录价,也不回退文本输出价。
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
ImageOutputPrice: testPtrFloat64(0),
}}))
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
require.Equal(t, 0.0, resolved.BasePricing.ImageOutputPricePerToken)
require.InDelta(t, 8e-6, resolved.BasePricing.ImageInputPricePerToken, 1e-12, "图片输入留空仍沿用目录价")
}
func TestApplyTokenOverrides_FlatWithImageOutputPriceSetsExplicit(t *testing.T) {
@@ -759,29 +786,72 @@ func TestApplyTokenOverrides_FlatWithImageOutputPriceSetsExplicit(t *testing.T)
require.InDelta(t, 50e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12)
}
func TestApplyTokenOverrides_IntervalSetsImageOutputPriceExplicit(t *testing.T) {
r := newResolverWithChannel(t, []ChannelModelPricing{{
func TestApplyTokenOverrides_IntervalUnsetImagePricesInheritCatalog(t *testing.T) {
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
// No ImageOutputPrice
// No ImageInputPrice / ImageOutputPrice
Intervals: []PricingInterval{
{MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(3e-6), OutputPrice: testPtrFloat64(15e-6)},
},
}})
}}))
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
// BasePricing should have explicit mark (for interval fallback)
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
require.Equal(t, 0.0, resolved.BasePricing.ImageOutputPricePerToken)
require.False(t, resolved.BasePricing.ImageOutputPriceExplicit)
require.InDelta(t, 30e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12)
// 区间命中时同样沿用目录图片价
pricing := r.GetIntervalPricing(resolved, 50000)
require.False(t, pricing.ImageOutputPriceExplicit)
require.InDelta(t, 30e-6, pricing.ImageOutputPricePerToken, 1e-12)
require.InDelta(t, 8e-6, pricing.ImageInputPricePerToken, 1e-12)
}
func TestApplyTokenOverrides_IntervalExplicitImageOutputPrice(t *testing.T) {
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
ImageOutputPrice: testPtrFloat64(50e-6),
Intervals: []PricingInterval{
{MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(3e-6), OutputPrice: testPtrFloat64(15e-6)},
},
}}))
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
// intervalToModelPricing should also have explicit mark
pricing := r.GetIntervalPricing(resolved, 50000)
require.True(t, pricing.ImageOutputPriceExplicit)
require.Equal(t, 0.0, pricing.ImageOutputPricePerToken)
require.InDelta(t, 50e-6, pricing.ImageOutputPricePerToken, 1e-12)
}
func TestCalculateCostUnified_EmptyChannelEntryBillsCatalogImagePrices(t *testing.T) {
// 常见配置:图片模型只建了空价卡(token 模式、价格全留空)。图片输出此前按 0 计费。
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
}}))
tokens := UsageTokens{InputTokens: 1000, ImageInputTokens: 600, OutputTokens: 500, ImageOutputTokens: 500}
cost, err := r.billingService.CalculateCostUnified(CostInput{
Ctx: context.Background(),
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
Tokens: tokens,
RateMultiplier: 1,
Resolver: r,
})
require.NoError(t, err)
require.InDelta(t, 500*30e-6, cost.ImageOutputCost, 1e-12)
require.InDelta(t, 600*8e-6, cost.ImageInputCost, 1e-12)
require.InDelta(t, 400*3e-6, cost.InputCost, 1e-12)
require.InDelta(t, 0, cost.OutputCost, 1e-12, "输出全部是图片 token")
}
// ===========================================================================
@@ -835,7 +905,7 @@ func TestApplyTokenOverrides_IntervalDoesNotPolluteFallbackPrices(t *testing.T)
})
require.NotNil(t, resolved)
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
require.False(t, resolved.BasePricing.ImageOutputPriceExplicit, "渠道未填图片价,不应标记显式")
// Global fallbackPrices must NOT be polluted
fp := r.billingService.fallbackPrices["claude-sonnet-4"]