diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 9011f1ce4c..c85420b5d5 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -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 } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index f6a2155013..e79e68391f 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -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) { diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 12c569fedd..1d21e4cc15 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -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 } diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index a0373a1247..ef47cd39c5 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -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"]