mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
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:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user