Files
sub2api/backend/internal/service/model_pricing_resolver.go
Yuan LiuandClaude Opus 5.5 a2007db707 fix(billing): keep catalog image prices when channel pricing leaves them unset
When a channel pricing entry left image_output_price empty,
GetModelPricingWithChannel and the pricing resolver zeroed the image
output price and marked it explicit, so image output tokens were billed
at $0. image_input_price followed the same "unset means zero" rule and
fell back to the text input price instead of the catalog image input
price. A channel that lists an image model such as gpt-image-2 in token
mode without filling in prices therefore generated images without
charging for image output tokens, while the available-channels view
(#2475) displayed the catalog image price for the same entry.

#2930 wanted an explicit 0 on the channel to stop falling back to the
catalog; the change applied the same treatment to unset values as well.

Treat the two image fields like every other token price field:
- Set on the channel (including 0): override the catalog. An explicit
  image_output_price keeps ImageOutputPriceExplicit, so 0 stays free.
- Left empty: keep the catalog price (LiteLLM or built-in fallback). If
  the catalog has none, computeTokenBreakdown still falls back to the
  text input/output price.

The admin UI saves an empty field as null and only an entered 0 as 0,
so the two cases stay distinguishable. The flat, interval and
GetModelPricingWithChannel paths now share applyChannelImagePriceOverrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:46:14 +08:00

396 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"log/slog"
"maps"
"strings"
)
// PricingSource 定价来源标识
const (
PricingSourceGroup = "group"
PricingSourceChannel = "channel"
PricingSourceLiteLLM = "litellm"
PricingSourceFallback = "fallback"
)
// ResolvedPricing 统一定价解析结果
type ResolvedPricing struct {
// Mode 计费模式
Mode BillingMode
// Token 模式:基础定价(来自 LiteLLM 或 fallback)
BasePricing *ModelPricing
// Token 模式:区间定价列表(如有,覆盖 BasePricing 中的对应字段)
Intervals []PricingInterval
// 按次/图片模式:分层定价
RequestTiers []PricingInterval
// 按次/图片模式:默认价格(未命中层级时使用)
DefaultPerRequestPrice float64
// 来源标识
Source string // "channel", "litellm", "fallback"
// 是否支持缓存细分
SupportsCacheBreakdown bool
// 渠道定价原始配置(用于区间模式下获取 ImageOutputPrice)
channelPricing *ChannelModelPricing
longContextPricingEnabled bool
}
// ModelPricingResolver 统一模型定价解析器。
// 解析链:Group → Channel → LiteLLM → Fallback。
type ModelPricingResolver struct {
channelService *ChannelService
billingService *BillingService
}
// NewModelPricingResolver 创建定价解析器实例
func NewModelPricingResolver(channelService *ChannelService, billingService *BillingService) *ModelPricingResolver {
return &ModelPricingResolver{
channelService: channelService,
billingService: billingService,
}
}
// PricingInput 定价解析输入
type PricingInput struct {
Model string
GroupID *int64 // nil 表示不检查渠道
Group *Group
}
// Resolve 解析模型定价。
// 1. 获取基础定价(LiteLLM → Fallback)
// 2. 如果指定了 GroupID,查找渠道定价并覆盖
func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) *ResolvedPricing {
longContextPricingEnabled := input.Group == nil || input.Group.LongContextPricingEnabled
if groupPricing := matchGroupModelPricing(input.Group, input.Model); groupPricing != nil {
// Group token cards only override the first-tier / flat rates.
// Long-context ladders come from official presets, gated by the checkbox.
if groupPricing.BillingMode == "" || groupPricing.BillingMode == BillingModeToken {
stripped := groupPricing.Clone()
stripped.Intervals = nil
groupPricing = &stripped
}
resolved := r.resolveConfiguredPricing(groupPricing, input.Model, PricingSourceGroup)
resolved.longContextPricingEnabled = longContextPricingEnabled
return resolved
}
var chPricing *ChannelModelPricing
if input.GroupID != nil && r.channelService != nil {
chPricing = r.lookupChannelPricingNormalized(ctx, *input.GroupID, input.Model)
if chPricing != nil {
mode := chPricing.BillingMode
if mode == "" {
mode = BillingModeToken
}
if mode == BillingModePerRequest || mode == BillingModeImage || mode == BillingModeVideo {
resolved := &ResolvedPricing{
Mode: mode,
Source: PricingSourceChannel,
channelPricing: chPricing,
}
resolved.longContextPricingEnabled = longContextPricingEnabled
r.applyRequestTierOverrides(chPricing, resolved)
return resolved
}
}
}
// 1. 获取基础定价
basePricing, source := r.resolveBasePricing(input.Model)
resolved := &ResolvedPricing{
Mode: BillingModeToken,
BasePricing: basePricing,
Source: source,
SupportsCacheBreakdown: basePricing != nil && basePricing.SupportsCacheBreakdown,
}
resolved.longContextPricingEnabled = longContextPricingEnabled
// 2. 如果有 GroupID,尝试渠道覆盖
if chPricing != nil {
resolved.Source = PricingSourceChannel
resolved.channelPricing = chPricing
r.applyTokenOverrides(chPricing, resolved)
} else if input.GroupID != nil && r.channelService != nil {
r.applyChannelOverrides(ctx, *input.GroupID, input.Model, resolved)
}
return resolved
}
func (r *ModelPricingResolver) resolveConfiguredPricing(config *ChannelModelPricing, model, source string) *ResolvedPricing {
mode := config.BillingMode
if mode == "" {
mode = BillingModeToken
}
resolved := &ResolvedPricing{Mode: mode, Source: source, channelPricing: config}
if mode == BillingModePerRequest || mode == BillingModeImage || mode == BillingModeVideo {
r.applyRequestTierOverrides(config, resolved)
return resolved
}
resolved.BasePricing, _ = r.resolveBasePricing(model)
resolved.SupportsCacheBreakdown = resolved.BasePricing != nil && resolved.BasePricing.SupportsCacheBreakdown
r.applyTokenOverrides(config, resolved)
return resolved
}
func matchGroupModelPricing(group *Group, model string) *ChannelModelPricing {
if group == nil {
return nil
}
model = normalizeChannelPricingModelName(model)
var wildcard *ChannelModelPricing
for i := range group.ModelPricing {
entry := &group.ModelPricing[i]
for _, pattern := range entry.Models {
normalized := normalizeChannelPricingModelName(pattern)
if normalized == model {
cp := entry.Clone()
return &cp
}
if strings.HasSuffix(normalized, "*") && strings.HasPrefix(model, strings.TrimSuffix(normalized, "*")) && wildcard == nil {
cp := entry.Clone()
wildcard = &cp
}
}
}
return wildcard
}
// resolveBasePricing 从 LiteLLM 或 Fallback 获取基础定价
func (r *ModelPricingResolver) resolveBasePricing(model string) (*ModelPricing, string) {
pricing, err := r.billingService.GetModelPricing(model)
if err != nil {
slog.Debug("failed to get model pricing from LiteLLM, using fallback",
"model", model, "error", err)
return nil, PricingSourceFallback
}
return pricing, PricingSourceLiteLLM
}
// lookupChannelPricingNormalized 查找渠道定价:先用字面模型名做精确/通配匹配,
// 未命中时用与官方兜底价一致的归一化模型名再查一次。
//
// 官方兜底价对 OpenAI/Codex 族会把 gpt-5.6-luna-high 这类变体名归一化到基名
// (billing_service.go 的 normalizeKnownOpenAICodexModel 分支),而渠道定价此前
// 只认字面名。两者不对称导致:管理员只配基名、请求模型带 effort 后缀时,渠道定价
// 未命中而官方兜底命中,计费候选循环首个成功即返回,渠道定价永远轮不到(issue #5256)。
//
// 字面名优先,保证管理员对具体变体的显式配价不被基名覆盖;非 OpenAI 模型
// normalizeKnownOpenAICodexModel 返回空串,此处天然 no-op。
func (r *ModelPricingResolver) lookupChannelPricingNormalized(ctx context.Context, groupID int64, model string) *ChannelModelPricing {
if r.channelService == nil {
return nil
}
if pricing := r.channelService.GetChannelModelPricing(ctx, groupID, model); pricing != nil {
return pricing
}
normalized := normalizeKnownOpenAICodexModel(model)
if normalized == "" || strings.EqualFold(normalized, strings.TrimSpace(model)) {
return nil
}
return r.channelService.GetChannelModelPricing(ctx, groupID, normalized)
}
// applyChannelOverrides 应用渠道定价覆盖
func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupID int64, model string, resolved *ResolvedPricing) {
chPricing := r.lookupChannelPricingNormalized(ctx, groupID, model)
if chPricing == nil {
return
}
resolved.Source = PricingSourceChannel
resolved.channelPricing = chPricing
resolved.Mode = chPricing.BillingMode
if resolved.Mode == "" {
resolved.Mode = BillingModeToken
}
switch resolved.Mode {
case BillingModeToken:
r.applyTokenOverrides(chPricing, resolved)
case BillingModePerRequest, BillingModeImage, BillingModeVideo:
r.applyRequestTierOverrides(chPricing, resolved)
}
}
// applyTokenOverrides 应用 token 模式的渠道覆盖
func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricing, resolved *ResolvedPricing) {
if resolved.BasePricing == nil {
resolved.BasePricing = &ModelPricing{}
} else {
// 防止修改 fallbackPrices 中的共享指针
cloned := *resolved.BasePricing
resolved.BasePricing = &cloned
}
applyChannelTokenPriceOverrides(resolved.BasePricing, chPricing)
if chPricing.CacheWrite1hPrice != nil {
resolved.SupportsCacheBreakdown = true
resolved.BasePricing.SupportsCacheBreakdown = true
}
for i := range chPricing.Intervals {
if chPricing.Intervals[i].CacheWrite1hPrice != nil {
resolved.SupportsCacheBreakdown = true
resolved.BasePricing.SupportsCacheBreakdown = true
break
}
}
resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier
resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier
resolved.BasePricing.ReasoningEffortMultipliers = maps.Clone(chPricing.ReasoningEffortMultipliers)
applyChannelImagePriceOverrides(chPricing, resolved.BasePricing)
// 区间未命中时回退到上面已经应用渠道覆盖的基础价。
resolved.Intervals = filterValidIntervals(chPricing.Intervals)
}
// 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
}
}
// applyRequestTierOverrides 应用按次/图片模式的渠道覆盖
func (r *ModelPricingResolver) applyRequestTierOverrides(chPricing *ChannelModelPricing, resolved *ResolvedPricing) {
resolved.RequestTiers = filterValidIntervals(chPricing.Intervals)
if chPricing.PerRequestPrice != nil {
resolved.DefaultPerRequestPrice = *chPricing.PerRequestPrice
}
}
// filterValidIntervals 过滤掉所有价格字段都为空的无效 interval。
// 前端可能创建了只有 min/max 但无价格的空 interval。
func filterValidIntervals(intervals []PricingInterval) []PricingInterval {
var valid []PricingInterval
for _, iv := range intervals {
if iv.InputPrice != nil || iv.OutputPrice != nil ||
iv.CacheWritePrice != nil || iv.CacheWrite1hPrice != nil || iv.CacheReadPrice != nil ||
iv.PerRequestPrice != nil || iv.InputMultiplier != nil ||
iv.OutputMultiplier != nil || iv.CacheWriteMultiplier != nil ||
iv.CacheReadMultiplier != nil {
valid = append(valid, iv)
}
}
return valid
}
// GetIntervalPricing 根据 context token 数获取区间定价。
// 如果有区间列表,找到匹配区间并构造 ModelPricing;否则直接返回 BasePricing。
func (r *ModelPricingResolver) GetIntervalPricing(resolved *ResolvedPricing, totalContextTokens int) *ModelPricing {
if len(resolved.Intervals) == 0 {
return resolved.BasePricing
}
iv := FindMatchingInterval(resolved.Intervals, totalContextTokens)
if iv == nil {
return resolved.BasePricing
}
pricing := intervalToModelPricing(iv, resolved.BasePricing, resolved.channelPricing)
// BasePricing 为 nil(仅配置区间)时拷贝不到该标志,从 resolved 回填,
// 保证 computeCacheCreationCost 的 5m/1h 分档判断不被区间路径吞掉。
pricing.SupportsCacheBreakdown = resolved.SupportsCacheBreakdown
return pricing
}
// intervalToModelPricing 将区间定价转换为 ModelPricing
func intervalToModelPricing(iv *PricingInterval, base *ModelPricing, chPricing *ChannelModelPricing) *ModelPricing {
pricing := &ModelPricing{}
if base != nil {
*pricing = *base
}
applyMultiplier := func(value float64, multiplier *float64) float64 {
if multiplier == nil {
return value
}
return value * *multiplier
}
if iv.InputPrice != nil {
pricing.InputPricePerTokenPriority = channelTierOverridePrice(pricing.InputPricePerToken, pricing.InputPricePerTokenPriority, *iv.InputPrice)
pricing.InputPricePerToken = *iv.InputPrice
} else if iv.InputMultiplier != nil {
pricing.InputPricePerToken = applyMultiplier(pricing.InputPricePerToken, iv.InputMultiplier)
pricing.InputPricePerTokenPriority = applyMultiplier(pricing.InputPricePerTokenPriority, iv.InputMultiplier)
}
if iv.OutputPrice != nil {
pricing.OutputPricePerTokenPriority = channelTierOverridePrice(pricing.OutputPricePerToken, pricing.OutputPricePerTokenPriority, *iv.OutputPrice)
pricing.OutputPricePerToken = *iv.OutputPrice
} else if iv.OutputMultiplier != nil {
pricing.OutputPricePerToken = applyMultiplier(pricing.OutputPricePerToken, iv.OutputMultiplier)
pricing.OutputPricePerTokenPriority = applyMultiplier(pricing.OutputPricePerTokenPriority, iv.OutputMultiplier)
}
if iv.CacheWritePrice != nil {
pricing.CacheCreationPricePerTokenPriority = channelTierOverridePrice(pricing.CacheCreationPricePerToken, pricing.CacheCreationPricePerTokenPriority, *iv.CacheWritePrice)
pricing.CacheCreationPricePerToken = *iv.CacheWritePrice
pricing.CacheCreationPriceExplicit = true
pricing.CacheCreation5mPrice = *iv.CacheWritePrice
if iv.CacheWrite1hPrice == nil {
pricing.CacheCreation1hPrice = *iv.CacheWritePrice
}
} else if iv.CacheWriteMultiplier != nil {
pricing.CacheCreationPricePerToken = applyMultiplier(pricing.CacheCreationPricePerToken, iv.CacheWriteMultiplier)
pricing.CacheCreationPricePerTokenPriority = applyMultiplier(pricing.CacheCreationPricePerTokenPriority, iv.CacheWriteMultiplier)
pricing.CacheCreation5mPrice = applyMultiplier(pricing.CacheCreation5mPrice, iv.CacheWriteMultiplier)
pricing.CacheCreation1hPrice = applyMultiplier(pricing.CacheCreation1hPrice, iv.CacheWriteMultiplier)
}
if iv.CacheWrite1hPrice != nil {
pricing.CacheCreation1hPrice = *iv.CacheWrite1hPrice
pricing.SupportsCacheBreakdown = true
}
if iv.CacheReadPrice != nil {
pricing.CacheReadPricePerTokenPriority = channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *iv.CacheReadPrice)
pricing.CacheReadPricePerToken = *iv.CacheReadPrice
} else if iv.CacheReadMultiplier != nil {
pricing.CacheReadPricePerToken = applyMultiplier(pricing.CacheReadPricePerToken, iv.CacheReadMultiplier)
pricing.CacheReadPricePerTokenPriority = applyMultiplier(pricing.CacheReadPricePerTokenPriority, iv.CacheReadMultiplier)
}
// 区间不携带图片价,沿用渠道级配置;渠道留空时保留基础价里的目录图片价。
applyChannelImagePriceOverrides(chPricing, pricing)
return pricing
}
// GetRequestTierPrice 根据层级标签获取按次价格
func (r *ModelPricingResolver) GetRequestTierPrice(resolved *ResolvedPricing, tierLabel string) float64 {
for _, tier := range resolved.RequestTiers {
if tier.TierLabel == tierLabel && tier.PerRequestPrice != nil {
return *tier.PerRequestPrice
}
}
return 0
}
// GetRequestTierPriceByContext 根据 context token 数获取按次价格
func (r *ModelPricingResolver) GetRequestTierPriceByContext(resolved *ResolvedPricing, totalContextTokens int) float64 {
iv := FindMatchingInterval(resolved.RequestTiers, totalContextTokens)
if iv != nil && iv.PerRequestPrice != nil {
return *iv.PerRequestPrice
}
return 0
}