channel_available.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "sort"
  6. "strings"
  7. )
  8. // AvailableGroupRef 渠道视图中关联分组的简要信息。
  9. //
  10. // 用户侧「可用渠道」页面据此展示:专属分组 vs 公开分组(IsExclusive)、
  11. // 订阅 vs 标准(SubscriptionType)、默认倍率(RateMultiplier)与高峰倍率规则。
  12. // 用户专属倍率不在这里暴露,前端自己通过 /groups/rates 拉取,和 API 密钥页面保持一致。
  13. type AvailableGroupRef struct {
  14. ID int64
  15. Name string
  16. Platform string
  17. SubscriptionType string
  18. RateMultiplier float64
  19. PeakRateEnabled bool
  20. PeakStart string
  21. PeakEnd string
  22. PeakRateMultiplier float64
  23. IsExclusive bool
  24. }
  25. // AvailableChannel 可用渠道视图:用于「可用渠道」页面展示渠道基础信息 +
  26. // 关联的分组 + 推导出的支持模型列表(无通配符)。
  27. type AvailableChannel struct {
  28. ID int64
  29. Name string
  30. Description string
  31. Status string
  32. BillingModelSource string
  33. RestrictModels bool
  34. Groups []AvailableGroupRef
  35. SupportedModels []SupportedModel
  36. }
  37. // ListAvailable 返回所有渠道的可用视图:每个渠道附带关联分组信息与支持模型列表。
  38. //
  39. // 支持模型通过 (*Channel).SupportedModels() 计算(mapping ∪ pricing 并联)。
  40. // 对于渠道未配置定价的模型,进一步用 PricingService 的全局 LiteLLM 数据合成
  41. // 一份展示用定价,让用户看到默认价格而非"未配置"。
  42. //
  43. // 关联分组信息通过 groupRepo.ListActive 查询后按 ID 映射;渠道 GroupIDs 中未在活跃列表中
  44. // 的分组(已停用或删除)会被忽略。
  45. //
  46. // 前置条件:s.groupRepo 必须非 nil(由 wire DI 保证)。直接 nil-deref 用于 fail-fast,
  47. // 避免静默掩盖注入缺失。
  48. func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, error) {
  49. channels, err := s.repo.ListAll(ctx)
  50. if err != nil {
  51. return nil, fmt.Errorf("list channels: %w", err)
  52. }
  53. groups, err := s.groupRepo.ListActive(ctx)
  54. if err != nil {
  55. return nil, fmt.Errorf("list active groups: %w", err)
  56. }
  57. groupByID := make(map[int64]AvailableGroupRef, len(groups))
  58. for i := range groups {
  59. g := groups[i]
  60. groupByID[g.ID] = AvailableGroupRef{
  61. ID: g.ID,
  62. Name: g.Name,
  63. Platform: g.Platform,
  64. SubscriptionType: g.SubscriptionType,
  65. RateMultiplier: g.RateMultiplier,
  66. PeakRateEnabled: g.PeakRateEnabled,
  67. PeakStart: g.PeakStart,
  68. PeakEnd: g.PeakEnd,
  69. PeakRateMultiplier: g.PeakRateMultiplier,
  70. IsExclusive: g.IsExclusive,
  71. }
  72. }
  73. out := make([]AvailableChannel, 0, len(channels))
  74. for i := range channels {
  75. ch := &channels[i]
  76. groups := make([]AvailableGroupRef, 0, len(ch.GroupIDs))
  77. for _, gid := range ch.GroupIDs {
  78. if ref, ok := groupByID[gid]; ok {
  79. groups = append(groups, ref)
  80. }
  81. }
  82. sort.SliceStable(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
  83. ch.normalizeBillingModelSource()
  84. supported := ch.SupportedModels()
  85. fillGlobalPricingFallback(s.pricingService, supported)
  86. out = append(out, AvailableChannel{
  87. ID: ch.ID,
  88. Name: ch.Name,
  89. Description: ch.Description,
  90. Status: ch.Status,
  91. BillingModelSource: ch.BillingModelSource,
  92. RestrictModels: ch.RestrictModels,
  93. Groups: groups,
  94. SupportedModels: supported,
  95. })
  96. }
  97. sort.SliceStable(out, func(i, j int) bool {
  98. return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
  99. })
  100. return out, nil
  101. }
  102. // fillGlobalPricingFallback 对未命中渠道定价的支持模型,从全局 LiteLLM 数据合成一份
  103. // 展示用定价。仅用于「可用渠道」展示,不影响真实计费链路。
  104. //
  105. // 触发条件:
  106. // 1. Pricing == nil(渠道完全没声明该模型的定价条目)
  107. // 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格)
  108. //
  109. // 当 pricingService 为 nil(测试场景),跳过价格回落,但仍补充内置模型倍率。
  110. // 可用渠道与模型广场共用。
  111. func fillGlobalPricingFallback(pricingService *PricingService, models []SupportedModel) {
  112. for i := range models {
  113. if pricingService != nil && pricingNeedsFallback(models[i].Pricing) {
  114. if lp := pricingService.GetModelPricing(models[i].Name); lp != nil {
  115. models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing)
  116. }
  117. }
  118. models[i].Pricing = withDefaultMaxReasoningEffortMultiplier(models[i].Pricing, models[i].Name)
  119. }
  120. }
  121. // pricingNeedsFallback 判定一个 ChannelModelPricing 是否需要走全局回落。
  122. // 价格全部缺失(无 flat 字段且无任何带价 interval)即视为未配置。
  123. func pricingNeedsFallback(p *ChannelModelPricing) bool {
  124. if p == nil {
  125. return true
  126. }
  127. if p.InputPrice != nil || p.OutputPrice != nil ||
  128. p.CacheWritePrice != nil || p.CacheWrite1hPrice != nil || p.CacheReadPrice != nil ||
  129. p.ImageOutputPrice != nil || p.PerRequestPrice != nil {
  130. return false
  131. }
  132. for _, iv := range p.Intervals {
  133. if iv.InputPrice != nil || iv.OutputPrice != nil ||
  134. iv.CacheWritePrice != nil || iv.CacheWrite1hPrice != nil || iv.CacheReadPrice != nil ||
  135. iv.PerRequestPrice != nil {
  136. return false
  137. }
  138. }
  139. return true
  140. }
  141. // synthesizePricingFromLiteLLM 把 LiteLLM 的定价数据转成 ChannelModelPricing 形态,
  142. // 仅用于展示。
  143. //
  144. // 计费模式优先级:
  145. // 1. 渠道已选 BillingMode(admin 在 UI 里选了 image / per_request 但没填价的场景,
  146. // 按选定模式合成对应字段)
  147. // 2. LiteLLM mode="image_generation" → image
  148. // 3. 默认 token
  149. //
  150. // LiteLLM 中字段 0 视为未配置,不带入展示。
  151. func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing, existing *ChannelModelPricing) *ChannelModelPricing {
  152. if lp == nil {
  153. return existing
  154. }
  155. mode := BillingModeToken
  156. switch {
  157. case existing != nil && existing.BillingMode != "":
  158. mode = existing.BillingMode
  159. case lp.Mode == "image_generation":
  160. mode = BillingModeImage
  161. }
  162. if mode == BillingModeImage || mode == BillingModePerRequest {
  163. return &ChannelModelPricing{
  164. BillingMode: mode,
  165. PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage),
  166. ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken),
  167. InputPrice: nonZeroPtr(lp.InputCostPerToken),
  168. OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
  169. MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing),
  170. }
  171. }
  172. return &ChannelModelPricing{
  173. BillingMode: mode,
  174. InputPrice: nonZeroPtr(lp.InputCostPerToken),
  175. OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
  176. CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),
  177. CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr),
  178. CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost),
  179. ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken),
  180. MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing),
  181. }
  182. }
  183. func maxReasoningEffortMultiplierFromPricing(pricing *ChannelModelPricing) *float64 {
  184. if pricing == nil {
  185. return nil
  186. }
  187. return pricing.MaxReasoningEffortMultiplier
  188. }
  189. func nonZeroPtr(v float64) *float64 {
  190. if v == 0 {
  191. return nil
  192. }
  193. return &v
  194. }