video_billing.go 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. package service
  2. import (
  3. "log/slog"
  4. "sort"
  5. "strings"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  7. )
  8. // Canonical video price family keys used in groups.video_model_prices JSONB.
  9. const (
  10. VideoPriceFamilyGrokImagineVideo = "grok-imagine-video"
  11. VideoPriceFamilyGrokImagineVideo15 = "grok-imagine-video-1.5"
  12. )
  13. // CanonicalGrokImagineVideoPriceFamily normalizes model aliases / preview / legacy
  14. // IDs onto the price-family keys stored in video_model_prices.
  15. func CanonicalGrokImagineVideoPriceFamily(model string) string {
  16. if model == "" {
  17. return ""
  18. }
  19. // Prefer shared xAI helper for known aliases. Keep future native Imagine
  20. // models distinct so operators can assign them independent prices.
  21. if c := xai.CanonicalImagineVideoModel(model); c != "" {
  22. switch c {
  23. case xai.DefaultImagineVideo15Model:
  24. return VideoPriceFamilyGrokImagineVideo15
  25. case xai.DefaultImagineVideoModel:
  26. return VideoPriceFamilyGrokImagineVideo
  27. }
  28. if strings.HasPrefix(c, "grok-imagine-video-") {
  29. return c
  30. }
  31. }
  32. m := strings.ToLower(strings.TrimSpace(model))
  33. for _, prefix := range []string{"xai/", "x-ai/", "grok/"} {
  34. if strings.HasPrefix(m, prefix) {
  35. m = strings.TrimPrefix(m, prefix)
  36. break
  37. }
  38. }
  39. switch {
  40. case m == "grok-imagine-video-1.5" || m == "grok-imagine-video-1.5-preview" ||
  41. m == "grok-video-1.5" || strings.Contains(m, "video-1.5"):
  42. return VideoPriceFamilyGrokImagineVideo15
  43. case m == "grok-imagine-video" || m == "grok-imagine-video-preview" ||
  44. m == "grok-video" || m == "grok-video-latest":
  45. return VideoPriceFamilyGrokImagineVideo
  46. default:
  47. return ""
  48. }
  49. }
  50. // NormalizeVideoModelPrices cleans and canonicalizes a per-model resolution map.
  51. // Keys become price families; tiers use 480p/720p/1080p. Negative prices dropped.
  52. //
  53. // Model keys are walked in sorted order rather than in Go map order: several
  54. // aliases can canonicalize onto the same family, and an unordered walk would
  55. // make the winning price for a conflicting tier vary between processes.
  56. // Unrecognized tiers are dropped with a warning instead of silently collapsing
  57. // into the 480p bucket.
  58. func NormalizeVideoModelPrices(in map[string]map[string]float64) map[string]map[string]float64 {
  59. if len(in) == 0 {
  60. return nil
  61. }
  62. modelKeys := make([]string, 0, len(in))
  63. for modelKey := range in {
  64. modelKeys = append(modelKeys, modelKey)
  65. }
  66. sort.Strings(modelKeys)
  67. out := make(map[string]map[string]float64)
  68. for _, modelKey := range modelKeys {
  69. tierPrices := in[modelKey]
  70. if len(tierPrices) == 0 {
  71. continue
  72. }
  73. family := CanonicalGrokImagineVideoPriceFamily(modelKey)
  74. if family == "" {
  75. key := strings.ToLower(strings.TrimSpace(modelKey))
  76. switch key {
  77. case VideoPriceFamilyGrokImagineVideo, VideoPriceFamilyGrokImagineVideo15:
  78. family = key
  79. default:
  80. if key == "" {
  81. continue
  82. }
  83. family = key
  84. }
  85. }
  86. normalizedTiers := out[family]
  87. if normalizedTiers == nil {
  88. normalizedTiers = make(map[string]float64)
  89. }
  90. tierKeys := make([]string, 0, len(tierPrices))
  91. for tierKey := range tierPrices {
  92. tierKeys = append(tierKeys, tierKey)
  93. }
  94. sort.Strings(tierKeys)
  95. for _, tierKey := range tierKeys {
  96. price := tierPrices[tierKey]
  97. if price < 0 {
  98. continue
  99. }
  100. tier, ok := LookupVideoBillingResolution(tierKey)
  101. if !ok {
  102. slog.Warn("video_model_prices_unknown_resolution_dropped",
  103. "model_key", modelKey,
  104. "family", family,
  105. "resolution", tierKey)
  106. continue
  107. }
  108. if existing, exists := normalizedTiers[tier]; exists && existing != price {
  109. slog.Warn("video_model_prices_conflicting_tier_price",
  110. "model_key", modelKey,
  111. "family", family,
  112. "resolution", tier,
  113. "previous_price", existing,
  114. "price", price)
  115. }
  116. normalizedTiers[tier] = price
  117. }
  118. if len(normalizedTiers) > 0 {
  119. out[family] = normalizedTiers
  120. }
  121. }
  122. if len(out) == 0 {
  123. return nil
  124. }
  125. return out
  126. }
  127. // LookupVideoModelPrice returns a per-second price from a model×resolution map, or nil.
  128. func LookupVideoModelPrice(prices map[string]map[string]float64, model, resolution string) *float64 {
  129. if len(prices) == 0 {
  130. return nil
  131. }
  132. family := CanonicalGrokImagineVideoPriceFamily(model)
  133. if family == "" {
  134. family = strings.ToLower(strings.TrimSpace(model))
  135. }
  136. if family == "" {
  137. return nil
  138. }
  139. tierPrices, ok := prices[family]
  140. if !ok || len(tierPrices) == 0 {
  141. return nil
  142. }
  143. tier := NormalizeVideoBillingResolutionOrDefault(resolution)
  144. if price, ok := tierPrices[tier]; ok {
  145. p := price
  146. return &p
  147. }
  148. return nil
  149. }