model_plaza_service.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "sort"
  6. "strings"
  7. )
  8. // PlazaOfficialPricing 模型广场展示用的官方参考价(USD per token),与计费同源:
  9. // LiteLLM → 内置兜底价卡 → 模型策略。字段为 nil 表示该项缺失(0 视为未配置)。
  10. type PlazaOfficialPricing struct {
  11. InputPrice *float64
  12. OutputPrice *float64
  13. CacheWritePrice *float64 // 5m 缓存写入(= LiteLLM cache_creation)
  14. CacheWrite1hPrice *float64 // 1h 缓存写入,仅计费会区分 5m/1h 时给出
  15. CacheReadPrice *float64
  16. // Intervals 官方长上下文阶梯(多档时给出),不受分组开关影响。
  17. Intervals []PricingInterval
  18. }
  19. // PlazaModel 模型广场中单个模型条目:按实收口径合成的展示定价 + 官方参考价。
  20. type PlazaModel struct {
  21. Name string
  22. Platform string
  23. Pricing *ChannelModelPricing
  24. OfficialPricing *PlazaOfficialPricing
  25. // LongContextBasis 多档时的计价基准(整单 / 仅超出部分),单档为空。
  26. LongContextBasis ContextPricingBasis
  27. // TimePricing 计费会生效的分时倍率时段;无分时为 nil。
  28. TimePricing *TimePricingSchedule
  29. }
  30. // PlazaGroup 模型广场中以分组为顶层的条目。
  31. //
  32. // 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的
  33. // 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的
  34. // 具体平台),与「可用渠道」页口径一致。
  35. type PlazaGroup struct {
  36. ID int64
  37. Name string
  38. Description string
  39. Platform string
  40. SubscriptionType string
  41. RateMultiplier float64
  42. PeakRateEnabled bool
  43. PeakStart string
  44. PeakEnd string
  45. PeakRateMultiplier float64
  46. IsExclusive bool
  47. // 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付
  48. // = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。
  49. ImageRateIndependent bool
  50. ImageRateMultiplier float64
  51. // LongContextPricingEnabled 分组是否按上下文长度应用阶梯价;关闭时模型展示的是最低档。
  52. LongContextPricingEnabled bool
  53. Models []PlazaModel
  54. }
  55. // ModelPlazaService 聚合模型广场数据。
  56. //
  57. // 模型枚举来自渠道配置;token 模型的展示单价与阶梯由 BillingService 的阶梯表
  58. // 查询给出(与扣费走同一条解析链与计费函数),图片/按次模型沿用渠道/分组档位价。
  59. type ModelPlazaService struct {
  60. channelRepo ChannelRepository
  61. groupRepo GroupRepository
  62. pricingService *PricingService
  63. billingService *BillingService
  64. resolver *ModelPricingResolver
  65. }
  66. // NewModelPlazaService 创建模型广场服务。
  67. func NewModelPlazaService(
  68. channelRepo ChannelRepository,
  69. groupRepo GroupRepository,
  70. pricingService *PricingService,
  71. billingService *BillingService,
  72. resolver *ModelPricingResolver,
  73. ) *ModelPlazaService {
  74. return &ModelPlazaService{
  75. channelRepo: channelRepo,
  76. groupRepo: groupRepo,
  77. pricingService: pricingService,
  78. billingService: billingService,
  79. resolver: resolver,
  80. }
  81. }
  82. // ListGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。
  83. //
  84. // 模型枚举口径与 ListAvailable 一致(Active 渠道、SupportedModels ∪ 全局定价回落、
  85. // 平台隔离),仅把顶层从渠道换成分组:
  86. // - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定;
  87. // - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换;
  88. // - token 模型的单价与阶梯按实收口径合成(见 ResolveContextPricingSchedule),
  89. // 图片计费模型的档位价按实收口径合成(见 plazaImageDisplayPricing);
  90. // - 每个模型附带官方参考价(查不到为 nil);
  91. // - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称),
  92. // 组内模型按名称排序。
  93. //
  94. // 可见性过滤(专属分组)不在此层做,由 handler 按登录态裁剪。
  95. func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error) {
  96. channels, err := s.channelRepo.ListAll(ctx)
  97. if err != nil {
  98. return nil, fmt.Errorf("list channels: %w", err)
  99. }
  100. groups, err := s.groupRepo.ListActive(ctx)
  101. if err != nil {
  102. return nil, fmt.Errorf("list active groups: %w", err)
  103. }
  104. sort.SliceStable(channels, func(i, j int) bool {
  105. return strings.ToLower(channels[i].Name) < strings.ToLower(channels[j].Name)
  106. })
  107. byGroup := make(map[int64]*PlazaGroup, len(groups))
  108. groupEnt := make(map[int64]*Group, len(groups))
  109. order := make([]int64, 0, len(groups))
  110. for i := range groups {
  111. g := &groups[i]
  112. byGroup[g.ID] = &PlazaGroup{
  113. ID: g.ID,
  114. Name: g.Name,
  115. Description: g.Description,
  116. Platform: g.Platform,
  117. SubscriptionType: g.SubscriptionType,
  118. RateMultiplier: g.RateMultiplier,
  119. PeakRateEnabled: g.PeakRateEnabled,
  120. PeakStart: g.PeakStart,
  121. PeakEnd: g.PeakEnd,
  122. PeakRateMultiplier: g.PeakRateMultiplier,
  123. IsExclusive: g.IsExclusive,
  124. ImageRateIndependent: g.ImageRateIndependent,
  125. ImageRateMultiplier: g.ImageRateMultiplier,
  126. LongContextPricingEnabled: g.LongContextPricingEnabled,
  127. }
  128. groupEnt[g.ID] = g
  129. order = append(order, g.ID)
  130. }
  131. type modelKey struct {
  132. platform string
  133. name string
  134. }
  135. // modelIdx[groupID][platform+modelName] = index into byGroup[groupID].Models
  136. modelIdx := make(map[int64]map[modelKey]int, len(groups))
  137. for i := range channels {
  138. ch := &channels[i]
  139. if ch.Status != StatusActive {
  140. continue
  141. }
  142. ch.normalizeBillingModelSource()
  143. supported := ch.SupportedModels()
  144. fillGlobalPricingFallback(s.pricingService, supported)
  145. for _, gid := range ch.GroupIDs {
  146. pg, ok := byGroup[gid]
  147. if !ok {
  148. continue
  149. }
  150. idx := modelIdx[gid]
  151. if idx == nil {
  152. idx = make(map[modelKey]int, len(supported))
  153. modelIdx[gid] = idx
  154. }
  155. for j := range supported {
  156. m := supported[j]
  157. if pg.Platform == PlatformComposite {
  158. if !isConcreteRequestPlatform(m.Platform) {
  159. continue
  160. }
  161. } else if m.Platform != pg.Platform {
  162. continue
  163. }
  164. key := modelKey{platform: m.Platform, name: m.Name}
  165. if at, seen := idx[key]; seen {
  166. // 先见者胜;仅当已存条目无定价而新条目有定价时升级。
  167. if pg.Models[at].Pricing == nil && m.Pricing != nil {
  168. pg.Models[at].Pricing = m.Pricing
  169. }
  170. continue
  171. }
  172. idx[key] = len(pg.Models)
  173. pg.Models = append(pg.Models, PlazaModel{
  174. Name: m.Name,
  175. Platform: m.Platform,
  176. Pricing: m.Pricing,
  177. })
  178. }
  179. }
  180. }
  181. officialMemo := make(map[string]*PlazaOfficialPricing)
  182. out := make([]PlazaGroup, 0, len(order))
  183. for _, gid := range order {
  184. pg := byGroup[gid]
  185. if len(pg.Models) == 0 {
  186. continue
  187. }
  188. sort.SliceStable(pg.Models, func(i, j int) bool {
  189. if pg.Models[i].Name != pg.Models[j].Name {
  190. return pg.Models[i].Name < pg.Models[j].Name
  191. }
  192. return pg.Models[i].Platform < pg.Models[j].Platform
  193. })
  194. g := groupEnt[gid]
  195. for j := range pg.Models {
  196. s.fillDisplayPricing(ctx, &pg.Models[j], g)
  197. pg.Models[j].OfficialPricing = s.lookupOfficialPricing(ctx, pg.Models[j].Name, officialMemo)
  198. }
  199. out = append(out, *pg)
  200. }
  201. sort.SliceStable(out, func(i, j int) bool {
  202. if out[i].RateMultiplier != out[j].RateMultiplier {
  203. return out[i].RateMultiplier < out[j].RateMultiplier
  204. }
  205. return out[i].Name < out[j].Name
  206. })
  207. return out, nil
  208. }
  209. // fillDisplayPricing 把模型的展示定价换成实收口径:
  210. // token 模型取计费阶梯表(单价与档位均由真实计费函数得出),
  211. // 图片/按次模型(或阶梯表不可用时)沿用渠道定价与分组图片档位价。
  212. func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaModel, g *Group) {
  213. if s.billingService != nil && s.resolver != nil {
  214. sched, err := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{
  215. Model: m.Name,
  216. Group: g,
  217. Platform: m.Platform,
  218. })
  219. if err == nil && sched != nil && len(sched.Tiers) > 0 {
  220. m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaPricingFromSchedule(m.Pricing, sched), m.Name)
  221. if len(sched.Tiers) > 1 {
  222. m.LongContextBasis = sched.Basis
  223. }
  224. m.TimePricing = sched.TimePricing
  225. return
  226. }
  227. }
  228. m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaImageDisplayPricing(m.Pricing, g), m.Name)
  229. }
  230. func withDefaultMaxReasoningEffortMultiplier(pricing *ChannelModelPricing, model string) *ChannelModelPricing {
  231. if pricing == nil || pricing.MaxReasoningEffortMultiplier != nil {
  232. return pricing
  233. }
  234. multiplier := defaultMaxReasoningEffortMultiplier(model)
  235. if multiplier == nil {
  236. return pricing
  237. }
  238. cloned := pricing.Clone()
  239. cloned.MaxReasoningEffortMultiplier = multiplier
  240. return &cloned
  241. }
  242. // plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing:
  243. // 平价取首档单价,多档时 Intervals 逐档给出绝对单价;图片/按次字段沿用原始定价。
  244. func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSchedule) *ChannelModelPricing {
  245. out := &ChannelModelPricing{BillingMode: BillingModeToken}
  246. if raw != nil {
  247. out.ImageInputPrice = raw.ImageInputPrice
  248. out.ImageOutputPrice = raw.ImageOutputPrice
  249. out.PerRequestPrice = raw.PerRequestPrice
  250. out.MaxReasoningEffortMultiplier = raw.MaxReasoningEffortMultiplier
  251. }
  252. first := sched.Tiers[0]
  253. out.InputPrice = first.Input
  254. out.OutputPrice = first.Output
  255. out.CacheWritePrice = first.CacheWrite
  256. out.CacheWrite1hPrice = first.CacheWrite1h
  257. out.CacheReadPrice = first.CacheRead
  258. if len(sched.Tiers) > 1 {
  259. out.Intervals = plazaIntervalsFromTiers(sched.Tiers)
  260. }
  261. return out
  262. }
  263. func plazaIntervalsFromTiers(tiers []ContextPricingTier) []PricingInterval {
  264. intervals := make([]PricingInterval, 0, len(tiers))
  265. for i, t := range tiers {
  266. intervals = append(intervals, PricingInterval{
  267. MinTokens: t.MinTokens,
  268. MaxTokens: t.MaxTokens,
  269. TierLabel: t.Label,
  270. InputPrice: t.Input,
  271. OutputPrice: t.Output,
  272. CacheWritePrice: t.CacheWrite,
  273. CacheWrite1hPrice: t.CacheWrite1h,
  274. CacheReadPrice: t.CacheRead,
  275. SortOrder: i,
  276. })
  277. }
  278. return intervals
  279. }
  280. // plazaImageDisplayPricing 为图片计费模型合成展示定价,使档位价与实收口径一致:
  281. // 每档(1K/2K/4K)单价 = 分组图片价 > 渠道同档位价 > 渠道默认按次价,无价的档不展示。
  282. // 分组未配任何图片价、或定价非图片模式时原样返回。返回克隆,不修改入参
  283. // (渠道定价指针指向缓存共享数据)。
  284. func plazaImageDisplayPricing(p *ChannelModelPricing, g *Group) *ChannelModelPricing {
  285. if p == nil || g == nil || p.BillingMode != BillingModeImage {
  286. return p
  287. }
  288. if g.ImagePrice1K == nil && g.ImagePrice2K == nil && g.ImagePrice4K == nil {
  289. return p
  290. }
  291. channelTierPrice := func(label string) *float64 {
  292. for i := range p.Intervals {
  293. if p.Intervals[i].TierLabel == label && p.Intervals[i].PerRequestPrice != nil {
  294. return p.Intervals[i].PerRequestPrice
  295. }
  296. }
  297. return p.PerRequestPrice
  298. }
  299. tiers := []struct {
  300. label string
  301. groupPrice *float64
  302. }{
  303. {"1K", g.ImagePrice1K},
  304. {"2K", g.ImagePrice2K},
  305. {"4K", g.ImagePrice4K},
  306. }
  307. clone := *p
  308. clone.Intervals = make([]PricingInterval, 0, len(tiers))
  309. for i, t := range tiers {
  310. price := t.groupPrice
  311. if price == nil {
  312. price = channelTierPrice(t.label)
  313. }
  314. if price == nil {
  315. continue
  316. }
  317. v := *price
  318. clone.Intervals = append(clone.Intervals, PricingInterval{
  319. TierLabel: t.label,
  320. PerRequestPrice: &v,
  321. SortOrder: i,
  322. })
  323. }
  324. return &clone
  325. }
  326. // lookupOfficialPricing 查询模型的官方参考价(与计费同源:LiteLLM → 内置兜底 → 模型策略),
  327. // 带 memo 避免同名模型重复解析。官方阶梯按无分组、无渠道的口径查阶梯表。
  328. // billingService 为 nil(测试场景)或查不到时返回 nil。
  329. func (s *ModelPlazaService) lookupOfficialPricing(ctx context.Context, modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing {
  330. if s.billingService == nil {
  331. return nil
  332. }
  333. if cached, ok := memo[modelName]; ok {
  334. return cached
  335. }
  336. var result *PlazaOfficialPricing
  337. if mp, err := s.billingService.GetModelPricing(modelName); err == nil && mp != nil {
  338. result = &PlazaOfficialPricing{
  339. InputPrice: nonZeroPtr(mp.InputPricePerToken),
  340. OutputPrice: nonZeroPtr(mp.OutputPricePerToken),
  341. CacheWritePrice: nonZeroPtr(mp.CacheCreationPricePerToken),
  342. CacheReadPrice: nonZeroPtr(mp.CacheReadPricePerToken),
  343. }
  344. // 计费只在支持 5m/1h 分档时使用 1h 价,其余情况 1h 价对用户无意义。
  345. if mp.SupportsCacheBreakdown {
  346. result.CacheWrite1hPrice = nonZeroPtr(mp.CacheCreation1hPrice)
  347. }
  348. if s.resolver != nil {
  349. sched, schedErr := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{Model: modelName})
  350. if schedErr == nil && sched != nil && len(sched.Tiers) > 1 {
  351. result.Intervals = plazaIntervalsFromTiers(sched.Tiers)
  352. }
  353. }
  354. if result.InputPrice == nil && result.OutputPrice == nil && result.CacheWritePrice == nil &&
  355. result.CacheWrite1hPrice == nil && result.CacheReadPrice == nil && len(result.Intervals) == 0 {
  356. result = nil
  357. }
  358. }
  359. memo[modelName] = result
  360. return result
  361. }