model_pricing_resolver.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. package service
  2. import (
  3. "context"
  4. "log/slog"
  5. "strings"
  6. )
  7. // PricingSource 定价来源标识
  8. const (
  9. PricingSourceGlobal = "global"
  10. PricingSourceGroup = "group"
  11. PricingSourceChannel = "channel"
  12. PricingSourceLiteLLM = "litellm"
  13. PricingSourceFallback = "fallback"
  14. )
  15. // ResolvedPricing 统一定价解析结果
  16. type ResolvedPricing struct {
  17. // Mode 计费模式
  18. Mode BillingMode
  19. // Token 模式:基础定价(来自 LiteLLM 或 fallback)
  20. BasePricing *ModelPricing
  21. // Token 模式:区间定价列表(如有,覆盖 BasePricing 中的对应字段)
  22. Intervals []PricingInterval
  23. // 按次/图片模式:分层定价
  24. RequestTiers []PricingInterval
  25. // 按次/图片模式:默认价格(未命中层级时使用)
  26. DefaultPerRequestPrice float64
  27. // 来源标识
  28. Source string // "channel", "litellm", "fallback"
  29. // 是否支持缓存细分
  30. SupportsCacheBreakdown bool
  31. // 渠道定价原始配置(用于区间模式下获取 ImageOutputPrice)
  32. channelPricing *ChannelModelPricing
  33. longContextPricingEnabled bool
  34. }
  35. // ModelPricingResolver 统一模型定价解析器。
  36. // 解析链:Global → Group → Channel → LiteLLM → Fallback。
  37. // Global 为全站手动定价,命中则覆盖全部分组/账号;未命中走原链路。
  38. type ModelPricingResolver struct {
  39. channelService *ChannelService
  40. billingService *BillingService
  41. globalPricing *GlobalModelPricingService
  42. }
  43. // NewModelPricingResolver 创建定价解析器实例(无全站定价,行为与原来一致)
  44. func NewModelPricingResolver(channelService *ChannelService, billingService *BillingService) *ModelPricingResolver {
  45. return &ModelPricingResolver{
  46. channelService: channelService,
  47. billingService: billingService,
  48. }
  49. }
  50. // NewModelPricingResolverWithGlobal 创建带全站定价覆盖的解析器实例。
  51. func NewModelPricingResolverWithGlobal(channelService *ChannelService, billingService *BillingService, globalPricing *GlobalModelPricingService) *ModelPricingResolver {
  52. return &ModelPricingResolver{
  53. channelService: channelService,
  54. billingService: billingService,
  55. globalPricing: globalPricing,
  56. }
  57. }
  58. // PricingInput 定价解析输入
  59. type PricingInput struct {
  60. Model string
  61. GroupID *int64 // nil 表示不检查渠道
  62. Group *Group
  63. }
  64. // Resolve 解析模型定价。
  65. // 0. 全站手动定价(命中则直接返回,覆盖全部分组/账号)
  66. // 1. 获取基础定价(LiteLLM → Fallback)
  67. // 2. 如果指定了 GroupID,查找渠道定价并覆盖
  68. func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) *ResolvedPricing {
  69. longContextPricingEnabled := input.Group == nil || input.Group.LongContextPricingEnabled
  70. if r.globalPricing != nil {
  71. if globalPricing := r.globalPricing.Match(ctx, input.Model); globalPricing != nil {
  72. resolved := r.resolveGlobalPricing(globalPricing, input.Model)
  73. resolved.longContextPricingEnabled = longContextPricingEnabled
  74. return resolved
  75. }
  76. }
  77. if groupPricing := matchGroupModelPricing(input.Group, input.Model); groupPricing != nil {
  78. // Group token cards only override the first-tier / flat rates.
  79. // Long-context ladders come from official presets, gated by the checkbox.
  80. if groupPricing.BillingMode == "" || groupPricing.BillingMode == BillingModeToken {
  81. stripped := groupPricing.Clone()
  82. stripped.Intervals = nil
  83. groupPricing = &stripped
  84. }
  85. resolved := r.resolveConfiguredPricing(groupPricing, input.Model, PricingSourceGroup)
  86. resolved.longContextPricingEnabled = longContextPricingEnabled
  87. return resolved
  88. }
  89. var chPricing *ChannelModelPricing
  90. if input.GroupID != nil && r.channelService != nil {
  91. chPricing = r.lookupChannelPricingNormalized(ctx, *input.GroupID, input.Model)
  92. if chPricing != nil {
  93. mode := chPricing.BillingMode
  94. if mode == "" {
  95. mode = BillingModeToken
  96. }
  97. if mode == BillingModePerRequest || mode == BillingModeImage || mode == BillingModeVideo {
  98. resolved := &ResolvedPricing{
  99. Mode: mode,
  100. Source: PricingSourceChannel,
  101. channelPricing: chPricing,
  102. }
  103. resolved.longContextPricingEnabled = longContextPricingEnabled
  104. r.applyRequestTierOverrides(chPricing, resolved)
  105. return resolved
  106. }
  107. }
  108. }
  109. // 1. 获取基础定价
  110. basePricing, source := r.resolveBasePricing(input.Model)
  111. resolved := &ResolvedPricing{
  112. Mode: BillingModeToken,
  113. BasePricing: basePricing,
  114. Source: source,
  115. SupportsCacheBreakdown: basePricing != nil && basePricing.SupportsCacheBreakdown,
  116. }
  117. resolved.longContextPricingEnabled = longContextPricingEnabled
  118. // 2. 如果有 GroupID,尝试渠道覆盖
  119. if chPricing != nil {
  120. resolved.Source = PricingSourceChannel
  121. resolved.channelPricing = chPricing
  122. r.applyTokenOverrides(chPricing, resolved)
  123. } else if input.GroupID != nil && r.channelService != nil {
  124. r.applyChannelOverrides(ctx, *input.GroupID, input.Model, resolved)
  125. }
  126. return resolved
  127. }
  128. // resolveGlobalPricing 解析全站覆盖定价。与渠道覆盖不同:全站条目未填写的
  129. // 价格项保持原值(不归零),只有管理员显式设置的价格才覆盖。“未手动设置
  130. // 价格的则按原来的”是全站定价的承诺语义。
  131. func (r *ModelPricingResolver) resolveGlobalPricing(config *ChannelModelPricing, model string) *ResolvedPricing {
  132. mode := config.BillingMode
  133. if mode == "" {
  134. mode = BillingModeToken
  135. }
  136. resolved := &ResolvedPricing{Mode: mode, Source: PricingSourceGlobal, channelPricing: config}
  137. if mode == BillingModePerRequest || mode == BillingModeImage || mode == BillingModeVideo {
  138. r.applyRequestTierOverrides(config, resolved)
  139. return resolved
  140. }
  141. base, _ := r.resolveBasePricing(model)
  142. if base == nil {
  143. base = &ModelPricing{}
  144. } else {
  145. cloned := *base
  146. base = &cloned
  147. }
  148. resolved.BasePricing = base
  149. if config.InputPrice != nil {
  150. base.InputPricePerToken = *config.InputPrice
  151. }
  152. if config.OutputPrice != nil {
  153. base.OutputPricePerToken = *config.OutputPrice
  154. }
  155. if config.CacheWritePrice != nil {
  156. base.CacheCreationPricePerToken = *config.CacheWritePrice
  157. base.CacheCreationPriceExplicit = true
  158. base.CacheCreation5mPrice = *config.CacheWritePrice
  159. if config.CacheWrite1hPrice == nil {
  160. base.CacheCreation1hPrice = *config.CacheWritePrice
  161. }
  162. }
  163. if config.CacheWrite1hPrice != nil {
  164. base.CacheCreation1hPrice = *config.CacheWrite1hPrice
  165. base.SupportsCacheBreakdown = true
  166. resolved.SupportsCacheBreakdown = true
  167. } else {
  168. resolved.SupportsCacheBreakdown = base.SupportsCacheBreakdown
  169. }
  170. if config.CacheReadPrice != nil {
  171. base.CacheReadPricePerToken = *config.CacheReadPrice
  172. }
  173. if config.ImageOutputPrice != nil {
  174. base.ImageOutputPricePerToken = *config.ImageOutputPrice
  175. base.ImageOutputPriceExplicit = true
  176. }
  177. if config.ImageInputPrice != nil {
  178. base.ImageInputPricePerToken = *config.ImageInputPrice
  179. }
  180. return resolved
  181. }
  182. func (r *ModelPricingResolver) resolveConfiguredPricing(config *ChannelModelPricing, model, source string) *ResolvedPricing {
  183. mode := config.BillingMode
  184. if mode == "" {
  185. mode = BillingModeToken
  186. }
  187. resolved := &ResolvedPricing{Mode: mode, Source: source, channelPricing: config}
  188. if mode == BillingModePerRequest || mode == BillingModeImage || mode == BillingModeVideo {
  189. r.applyRequestTierOverrides(config, resolved)
  190. return resolved
  191. }
  192. resolved.BasePricing, _ = r.resolveBasePricing(model)
  193. resolved.SupportsCacheBreakdown = resolved.BasePricing != nil && resolved.BasePricing.SupportsCacheBreakdown
  194. r.applyTokenOverrides(config, resolved)
  195. return resolved
  196. }
  197. func matchGroupModelPricing(group *Group, model string) *ChannelModelPricing {
  198. if group == nil {
  199. return nil
  200. }
  201. model = normalizeChannelPricingModelName(model)
  202. var wildcard *ChannelModelPricing
  203. for i := range group.ModelPricing {
  204. entry := &group.ModelPricing[i]
  205. for _, pattern := range entry.Models {
  206. normalized := normalizeChannelPricingModelName(pattern)
  207. if normalized == model {
  208. cp := entry.Clone()
  209. return &cp
  210. }
  211. if strings.HasSuffix(normalized, "*") && strings.HasPrefix(model, strings.TrimSuffix(normalized, "*")) && wildcard == nil {
  212. cp := entry.Clone()
  213. wildcard = &cp
  214. }
  215. }
  216. }
  217. return wildcard
  218. }
  219. // resolveBasePricing 从 LiteLLM 或 Fallback 获取基础定价
  220. func (r *ModelPricingResolver) resolveBasePricing(model string) (*ModelPricing, string) {
  221. pricing, err := r.billingService.GetModelPricing(model)
  222. if err != nil {
  223. slog.Debug("failed to get model pricing from LiteLLM, using fallback",
  224. "model", model, "error", err)
  225. return nil, PricingSourceFallback
  226. }
  227. return pricing, PricingSourceLiteLLM
  228. }
  229. // lookupChannelPricingNormalized 查找渠道定价:先用字面模型名做精确/通配匹配,
  230. // 未命中时用与官方兜底价一致的归一化模型名再查一次。
  231. //
  232. // 官方兜底价对 OpenAI/Codex 族会把 gpt-5.6-luna-high 这类变体名归一化到基名
  233. // (billing_service.go 的 normalizeKnownOpenAICodexModel 分支),而渠道定价此前
  234. // 只认字面名。两者不对称导致:管理员只配基名、请求模型带 effort 后缀时,渠道定价
  235. // 未命中而官方兜底命中,计费候选循环首个成功即返回,渠道定价永远轮不到(issue #5256)。
  236. //
  237. // 字面名优先,保证管理员对具体变体的显式配价不被基名覆盖;非 OpenAI 模型
  238. // normalizeKnownOpenAICodexModel 返回空串,此处天然 no-op。
  239. func (r *ModelPricingResolver) lookupChannelPricingNormalized(ctx context.Context, groupID int64, model string) *ChannelModelPricing {
  240. if r.channelService == nil {
  241. return nil
  242. }
  243. if pricing := r.channelService.GetChannelModelPricing(ctx, groupID, model); pricing != nil {
  244. return pricing
  245. }
  246. normalized := normalizeKnownOpenAICodexModel(model)
  247. if normalized == "" || strings.EqualFold(normalized, strings.TrimSpace(model)) {
  248. return nil
  249. }
  250. return r.channelService.GetChannelModelPricing(ctx, groupID, normalized)
  251. }
  252. // applyChannelOverrides 应用渠道定价覆盖
  253. func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupID int64, model string, resolved *ResolvedPricing) {
  254. chPricing := r.lookupChannelPricingNormalized(ctx, groupID, model)
  255. if chPricing == nil {
  256. return
  257. }
  258. resolved.Source = PricingSourceChannel
  259. resolved.channelPricing = chPricing
  260. resolved.Mode = chPricing.BillingMode
  261. if resolved.Mode == "" {
  262. resolved.Mode = BillingModeToken
  263. }
  264. switch resolved.Mode {
  265. case BillingModeToken:
  266. r.applyTokenOverrides(chPricing, resolved)
  267. case BillingModePerRequest, BillingModeImage, BillingModeVideo:
  268. r.applyRequestTierOverrides(chPricing, resolved)
  269. }
  270. }
  271. // applyTokenOverrides 应用 token 模式的渠道覆盖
  272. func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricing, resolved *ResolvedPricing) {
  273. if resolved.BasePricing == nil {
  274. resolved.BasePricing = &ModelPricing{}
  275. } else {
  276. // 防止修改 fallbackPrices 中的共享指针
  277. cloned := *resolved.BasePricing
  278. resolved.BasePricing = &cloned
  279. }
  280. applyChannelTokenPriceOverrides(resolved.BasePricing, chPricing)
  281. if chPricing.CacheWrite1hPrice != nil {
  282. resolved.SupportsCacheBreakdown = true
  283. resolved.BasePricing.SupportsCacheBreakdown = true
  284. }
  285. for i := range chPricing.Intervals {
  286. if chPricing.Intervals[i].CacheWrite1hPrice != nil {
  287. resolved.SupportsCacheBreakdown = true
  288. resolved.BasePricing.SupportsCacheBreakdown = true
  289. break
  290. }
  291. }
  292. resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier
  293. resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier
  294. if chPricing.MaxReasoningEffortMultiplier != nil {
  295. resolved.BasePricing.MaxReasoningEffortMultiplier = chPricing.MaxReasoningEffortMultiplier
  296. }
  297. // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM)
  298. if chPricing.ImageOutputPrice != nil {
  299. resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
  300. } else {
  301. resolved.BasePricing.ImageOutputPricePerToken = 0
  302. }
  303. resolved.BasePricing.ImageOutputPriceExplicit = true
  304. applyChannelImageInputPrice(chPricing, resolved.BasePricing)
  305. // 区间未命中时回退到上面已经应用渠道覆盖的基础价。
  306. resolved.Intervals = filterValidIntervals(chPricing.Intervals)
  307. }
  308. // applyChannelImageInputPrice 应用渠道图片输入价:显式配置则用配置值;
  309. // 未配置时归零,使 computeTokenBreakdown 回退到文本输入价(向后兼容,
  310. // 避免 commit 引入的 LiteLLM 图片输入价泄漏进渠道自定义定价)。
  311. // 与 image_output 不同,此处不设 Explicit 标志——图片输入未配置应回退文本价,
  312. // 而非硬置 0。
  313. func applyChannelImageInputPrice(chPricing *ChannelModelPricing, pricing *ModelPricing) {
  314. if chPricing != nil && chPricing.ImageInputPrice != nil {
  315. pricing.ImageInputPricePerToken = *chPricing.ImageInputPrice
  316. } else {
  317. pricing.ImageInputPricePerToken = 0
  318. }
  319. }
  320. // applyRequestTierOverrides 应用按次/图片模式的渠道覆盖
  321. func (r *ModelPricingResolver) applyRequestTierOverrides(chPricing *ChannelModelPricing, resolved *ResolvedPricing) {
  322. resolved.RequestTiers = filterValidIntervals(chPricing.Intervals)
  323. if chPricing.PerRequestPrice != nil {
  324. resolved.DefaultPerRequestPrice = *chPricing.PerRequestPrice
  325. }
  326. }
  327. // filterValidIntervals 过滤掉所有价格字段都为空的无效 interval。
  328. // 前端可能创建了只有 min/max 但无价格的空 interval。
  329. func filterValidIntervals(intervals []PricingInterval) []PricingInterval {
  330. var valid []PricingInterval
  331. for _, iv := range intervals {
  332. if iv.InputPrice != nil || iv.OutputPrice != nil ||
  333. iv.CacheWritePrice != nil || iv.CacheWrite1hPrice != nil || iv.CacheReadPrice != nil ||
  334. iv.PerRequestPrice != nil || iv.InputMultiplier != nil ||
  335. iv.OutputMultiplier != nil || iv.CacheWriteMultiplier != nil ||
  336. iv.CacheReadMultiplier != nil {
  337. valid = append(valid, iv)
  338. }
  339. }
  340. return valid
  341. }
  342. // GetIntervalPricing 根据 context token 数获取区间定价。
  343. // 如果有区间列表,找到匹配区间并构造 ModelPricing;否则直接返回 BasePricing。
  344. func (r *ModelPricingResolver) GetIntervalPricing(resolved *ResolvedPricing, totalContextTokens int) *ModelPricing {
  345. if len(resolved.Intervals) == 0 {
  346. return resolved.BasePricing
  347. }
  348. iv := FindMatchingInterval(resolved.Intervals, totalContextTokens)
  349. if iv == nil {
  350. return resolved.BasePricing
  351. }
  352. pricing := intervalToModelPricing(iv, resolved.BasePricing, resolved.channelPricing)
  353. // BasePricing 为 nil(仅配置区间)时拷贝不到该标志,从 resolved 回填,
  354. // 保证 computeCacheCreationCost 的 5m/1h 分档判断不被区间路径吞掉。
  355. pricing.SupportsCacheBreakdown = resolved.SupportsCacheBreakdown
  356. return pricing
  357. }
  358. // intervalToModelPricing 将区间定价转换为 ModelPricing
  359. func intervalToModelPricing(iv *PricingInterval, base *ModelPricing, chPricing *ChannelModelPricing) *ModelPricing {
  360. pricing := &ModelPricing{}
  361. if base != nil {
  362. *pricing = *base
  363. }
  364. applyMultiplier := func(value float64, multiplier *float64) float64 {
  365. if multiplier == nil {
  366. return value
  367. }
  368. return value * *multiplier
  369. }
  370. if iv.InputPrice != nil {
  371. pricing.InputPricePerTokenPriority = channelTierOverridePrice(pricing.InputPricePerToken, pricing.InputPricePerTokenPriority, *iv.InputPrice)
  372. pricing.InputPricePerToken = *iv.InputPrice
  373. } else if iv.InputMultiplier != nil {
  374. pricing.InputPricePerToken = applyMultiplier(pricing.InputPricePerToken, iv.InputMultiplier)
  375. pricing.InputPricePerTokenPriority = applyMultiplier(pricing.InputPricePerTokenPriority, iv.InputMultiplier)
  376. }
  377. if iv.OutputPrice != nil {
  378. pricing.OutputPricePerTokenPriority = channelTierOverridePrice(pricing.OutputPricePerToken, pricing.OutputPricePerTokenPriority, *iv.OutputPrice)
  379. pricing.OutputPricePerToken = *iv.OutputPrice
  380. } else if iv.OutputMultiplier != nil {
  381. pricing.OutputPricePerToken = applyMultiplier(pricing.OutputPricePerToken, iv.OutputMultiplier)
  382. pricing.OutputPricePerTokenPriority = applyMultiplier(pricing.OutputPricePerTokenPriority, iv.OutputMultiplier)
  383. }
  384. if iv.CacheWritePrice != nil {
  385. pricing.CacheCreationPricePerTokenPriority = channelTierOverridePrice(pricing.CacheCreationPricePerToken, pricing.CacheCreationPricePerTokenPriority, *iv.CacheWritePrice)
  386. pricing.CacheCreationPricePerToken = *iv.CacheWritePrice
  387. pricing.CacheCreationPriceExplicit = true
  388. pricing.CacheCreation5mPrice = *iv.CacheWritePrice
  389. if iv.CacheWrite1hPrice == nil {
  390. pricing.CacheCreation1hPrice = *iv.CacheWritePrice
  391. }
  392. } else if iv.CacheWriteMultiplier != nil {
  393. pricing.CacheCreationPricePerToken = applyMultiplier(pricing.CacheCreationPricePerToken, iv.CacheWriteMultiplier)
  394. pricing.CacheCreationPricePerTokenPriority = applyMultiplier(pricing.CacheCreationPricePerTokenPriority, iv.CacheWriteMultiplier)
  395. pricing.CacheCreation5mPrice = applyMultiplier(pricing.CacheCreation5mPrice, iv.CacheWriteMultiplier)
  396. pricing.CacheCreation1hPrice = applyMultiplier(pricing.CacheCreation1hPrice, iv.CacheWriteMultiplier)
  397. }
  398. if iv.CacheWrite1hPrice != nil {
  399. pricing.CacheCreation1hPrice = *iv.CacheWrite1hPrice
  400. pricing.SupportsCacheBreakdown = true
  401. }
  402. if iv.CacheReadPrice != nil {
  403. pricing.CacheReadPricePerTokenPriority = channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *iv.CacheReadPrice)
  404. pricing.CacheReadPricePerToken = *iv.CacheReadPrice
  405. } else if iv.CacheReadMultiplier != nil {
  406. pricing.CacheReadPricePerToken = applyMultiplier(pricing.CacheReadPricePerToken, iv.CacheReadMultiplier)
  407. pricing.CacheReadPricePerTokenPriority = applyMultiplier(pricing.CacheReadPricePerTokenPriority, iv.CacheReadMultiplier)
  408. }
  409. // 渠道定价存在时,ImageOutputPrice 显式覆盖;图片输入价用渠道级配置
  410. // (区间不携带图片输入价,与 image_output 一致)。
  411. if chPricing != nil {
  412. pricing.ImageOutputPriceExplicit = true
  413. if chPricing.ImageOutputPrice != nil {
  414. pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
  415. }
  416. applyChannelImageInputPrice(chPricing, pricing)
  417. }
  418. return pricing
  419. }
  420. // GetRequestTierPrice 根据层级标签获取按次价格
  421. func (r *ModelPricingResolver) GetRequestTierPrice(resolved *ResolvedPricing, tierLabel string) float64 {
  422. for _, tier := range resolved.RequestTiers {
  423. if tier.TierLabel == tierLabel && tier.PerRequestPrice != nil {
  424. return *tier.PerRequestPrice
  425. }
  426. }
  427. return 0
  428. }
  429. // GetRequestTierPriceByContext 根据 context token 数获取按次价格
  430. func (r *ModelPricingResolver) GetRequestTierPriceByContext(resolved *ResolvedPricing, totalContextTokens int) float64 {
  431. iv := FindMatchingInterval(resolved.RequestTiers, totalContextTokens)
  432. if iv != nil && iv.PerRequestPrice != nil {
  433. return *iv.PerRequestPrice
  434. }
  435. return 0
  436. }