Files
sub2api/backend/internal/service/model_pricing_resolver.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

359 lines
14 KiB
Go
Raw 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"
"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.channelService.GetChannelModelPricing(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
}
// applyChannelOverrides 应用渠道定价覆盖
func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupID int64, model string, resolved *ResolvedPricing) {
chPricing := r.channelService.GetChannelModelPricing(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)
resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier
resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier
// 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM)
if chPricing.ImageOutputPrice != nil {
resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
} else {
resolved.BasePricing.ImageOutputPricePerToken = 0
}
resolved.BasePricing.ImageOutputPriceExplicit = true
applyChannelImageInputPrice(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 {
pricing.ImageInputPricePerToken = *chPricing.ImageInputPrice
} else {
pricing.ImageInputPricePerToken = 0
}
}
// 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.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
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.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)
}
// 渠道定价存在时,ImageOutputPrice 显式覆盖;图片输入价用渠道级配置
// (区间不携带图片输入价,与 image_output 一致)。
if chPricing != nil {
pricing.ImageOutputPriceExplicit = true
if chPricing.ImageOutputPrice != nil {
pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
}
applyChannelImageInputPrice(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
}