mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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>
396 lines
15 KiB
Go
396 lines
15 KiB
Go
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
|
||
}
|