| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477 |
- package service
- import (
- "context"
- "log/slog"
- "strings"
- )
- // PricingSource 定价来源标识
- const (
- PricingSourceGlobal = "global"
- 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 统一模型定价解析器。
- // 解析链:Global → Group → Channel → LiteLLM → Fallback。
- // Global 为全站手动定价,命中则覆盖全部分组/账号;未命中走原链路。
- type ModelPricingResolver struct {
- channelService *ChannelService
- billingService *BillingService
- globalPricing *GlobalModelPricingService
- }
- // NewModelPricingResolver 创建定价解析器实例(无全站定价,行为与原来一致)
- func NewModelPricingResolver(channelService *ChannelService, billingService *BillingService) *ModelPricingResolver {
- return &ModelPricingResolver{
- channelService: channelService,
- billingService: billingService,
- }
- }
- // NewModelPricingResolverWithGlobal 创建带全站定价覆盖的解析器实例。
- func NewModelPricingResolverWithGlobal(channelService *ChannelService, billingService *BillingService, globalPricing *GlobalModelPricingService) *ModelPricingResolver {
- return &ModelPricingResolver{
- channelService: channelService,
- billingService: billingService,
- globalPricing: globalPricing,
- }
- }
- // PricingInput 定价解析输入
- type PricingInput struct {
- Model string
- GroupID *int64 // nil 表示不检查渠道
- Group *Group
- }
- // Resolve 解析模型定价。
- // 0. 全站手动定价(命中则直接返回,覆盖全部分组/账号)
- // 1. 获取基础定价(LiteLLM → Fallback)
- // 2. 如果指定了 GroupID,查找渠道定价并覆盖
- func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) *ResolvedPricing {
- longContextPricingEnabled := input.Group == nil || input.Group.LongContextPricingEnabled
- if r.globalPricing != nil {
- if globalPricing := r.globalPricing.Match(ctx, input.Model); globalPricing != nil {
- resolved := r.resolveGlobalPricing(globalPricing, input.Model)
- resolved.longContextPricingEnabled = longContextPricingEnabled
- return resolved
- }
- }
- 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
- }
- // resolveGlobalPricing 解析全站覆盖定价。与渠道覆盖不同:全站条目未填写的
- // 价格项保持原值(不归零),只有管理员显式设置的价格才覆盖。“未手动设置
- // 价格的则按原来的”是全站定价的承诺语义。
- func (r *ModelPricingResolver) resolveGlobalPricing(config *ChannelModelPricing, model string) *ResolvedPricing {
- mode := config.BillingMode
- if mode == "" {
- mode = BillingModeToken
- }
- resolved := &ResolvedPricing{Mode: mode, Source: PricingSourceGlobal, channelPricing: config}
- if mode == BillingModePerRequest || mode == BillingModeImage || mode == BillingModeVideo {
- r.applyRequestTierOverrides(config, resolved)
- return resolved
- }
- base, _ := r.resolveBasePricing(model)
- if base == nil {
- base = &ModelPricing{}
- } else {
- cloned := *base
- base = &cloned
- }
- resolved.BasePricing = base
- if config.InputPrice != nil {
- base.InputPricePerToken = *config.InputPrice
- }
- if config.OutputPrice != nil {
- base.OutputPricePerToken = *config.OutputPrice
- }
- if config.CacheWritePrice != nil {
- base.CacheCreationPricePerToken = *config.CacheWritePrice
- base.CacheCreationPriceExplicit = true
- base.CacheCreation5mPrice = *config.CacheWritePrice
- if config.CacheWrite1hPrice == nil {
- base.CacheCreation1hPrice = *config.CacheWritePrice
- }
- }
- if config.CacheWrite1hPrice != nil {
- base.CacheCreation1hPrice = *config.CacheWrite1hPrice
- base.SupportsCacheBreakdown = true
- resolved.SupportsCacheBreakdown = true
- } else {
- resolved.SupportsCacheBreakdown = base.SupportsCacheBreakdown
- }
- if config.CacheReadPrice != nil {
- base.CacheReadPricePerToken = *config.CacheReadPrice
- }
- if config.ImageOutputPrice != nil {
- base.ImageOutputPricePerToken = *config.ImageOutputPrice
- base.ImageOutputPriceExplicit = true
- }
- if config.ImageInputPrice != nil {
- base.ImageInputPricePerToken = *config.ImageInputPrice
- }
- 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
- if chPricing.MaxReasoningEffortMultiplier != nil {
- resolved.BasePricing.MaxReasoningEffortMultiplier = chPricing.MaxReasoningEffortMultiplier
- }
- // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 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.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)
- }
- // 渠道定价存在时,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
- }
|