subscription_tier.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. package xai
  2. import (
  3. "encoding/json"
  4. "strconv"
  5. "strings"
  6. "time"
  7. )
  8. // GrokQuotaSignalMaxAge bounds how long a grok-4.5 Responses window can
  9. // influence SuperGrok vs Heavy inference.
  10. const GrokQuotaSignalMaxAge = 24 * time.Hour
  11. const (
  12. grok45ResponsesModel = "grok-4.5"
  13. grokHeavyQuotaRequestLimit int64 = 8_300
  14. grokHeavyQuotaTokenLimit int64 = 53_000_000
  15. )
  16. // MapJWTSubscriptionTier maps prod_auth.SubscriptionTier numeric JWT claims
  17. // to stable snake_case keys used by Grok Build / Mixpanel.
  18. func MapJWTSubscriptionTier(tier uint64) string {
  19. switch tier {
  20. case 0:
  21. return "free"
  22. case 1:
  23. return "supergrok"
  24. case 2:
  25. return "x_basic"
  26. case 3:
  27. return "x_premium"
  28. case 4:
  29. return "x_premium_plus"
  30. case 5:
  31. return "supergrok_heavy"
  32. case 6:
  33. return "supergrok_lite"
  34. case 7:
  35. return "supergrok_plus"
  36. default:
  37. return strconv.FormatUint(tier, 10)
  38. }
  39. }
  40. // NormalizeSubscriptionTier canonicalizes display names, /user strings, and
  41. // JWT-derived keys onto the same snake_case identifiers.
  42. func NormalizeSubscriptionTier(raw string) string {
  43. t := strings.ToLower(strings.TrimSpace(raw))
  44. t = strings.ReplaceAll(t, "-", "_")
  45. t = strings.Join(strings.Fields(t), "_")
  46. switch t {
  47. case "free", "grok_free", "grokfree", "free_tier", "freetier", "grok_basic", "grokbasic":
  48. return "free"
  49. case "supergrok", "grokpro":
  50. return "supergrok"
  51. case "supergrok_lite", "supergroklite":
  52. return "supergrok_lite"
  53. case "supergrok_heavy", "supergrokheavy":
  54. return "supergrok_heavy"
  55. case "supergrok_pro", "supergrokpro":
  56. return "supergrok_pro"
  57. case "supergrok_plus", "supergrokplus":
  58. return "supergrok_plus"
  59. case "x_basic", "xbasic", "basic":
  60. return "x_basic"
  61. case "x_premium", "xpremium":
  62. return "x_premium"
  63. case "x_premium_plus", "xpremiumplus", "x_premium+":
  64. return "x_premium_plus"
  65. default:
  66. return t
  67. }
  68. }
  69. // SubscriptionTierFromJWT decodes an access token payload (no signature check)
  70. // and maps the numeric or string `tier` claim.
  71. func SubscriptionTierFromJWT(jwt string) string {
  72. claims := DecodeJWTClaims(jwt)
  73. if claims == nil {
  74. return ""
  75. }
  76. raw, ok := claims["tier"]
  77. if !ok || raw == nil {
  78. return ""
  79. }
  80. switch v := raw.(type) {
  81. case float64:
  82. if v < 0 {
  83. return ""
  84. }
  85. return MapJWTSubscriptionTier(uint64(v))
  86. case json.Number:
  87. n, err := v.Int64()
  88. if err != nil || n < 0 {
  89. return NormalizeSubscriptionTier(v.String())
  90. }
  91. return MapJWTSubscriptionTier(uint64(n))
  92. case string:
  93. trimmed := strings.TrimSpace(v)
  94. if trimmed == "" {
  95. return ""
  96. }
  97. if n, err := strconv.ParseUint(trimmed, 10, 64); err == nil {
  98. return MapJWTSubscriptionTier(n)
  99. }
  100. return NormalizeSubscriptionTier(trimmed)
  101. default:
  102. return ""
  103. }
  104. }
  105. // CanonicalGrokPlan resolves SuperGrok vs Heavy when the provider label is
  106. // ambiguous (SuperGrokPro). JWT numeric claims are applied by the caller first.
  107. // Monthly $150/$1500 limits still win when present.
  108. // Rate-limit windows are only used when they came from grok-4.5 Responses.
  109. func CanonicalGrokPlan(monthlyLimitCents *float64, subscriptionTier string, quota *QuotaSnapshot) string {
  110. if plan := resolvePlan(monthlyLimitCents); plan != "" {
  111. return NormalizeSubscriptionTier(plan)
  112. }
  113. normalized := NormalizeSubscriptionTier(subscriptionTier)
  114. switch normalized {
  115. case "free", "x_basic":
  116. return "free"
  117. case "supergrok_heavy":
  118. return "supergrok_heavy"
  119. case "supergrok_lite":
  120. return "supergrok_lite"
  121. case "supergrok_plus":
  122. return "supergrok_plus"
  123. }
  124. if isAmbiguousGrokPaidPlan(normalized) {
  125. if hint := Grok45ResponsesPlanHint(quota, time.Time{}); hint != "" {
  126. return hint
  127. }
  128. return "supergrok"
  129. }
  130. return ""
  131. }
  132. func isAmbiguousGrokPaidPlan(normalized string) bool {
  133. switch normalized {
  134. case "supergrok", "supergrok_pro", "paid", "pro":
  135. return true
  136. default:
  137. return false
  138. }
  139. }
  140. // IsGrok45ResponsesQuotaModel reports whether model is the grok-4.5 Responses
  141. // id (or a dated grok-4.5-* variant). Empty and other families are false.
  142. func IsGrok45ResponsesQuotaModel(model string) bool {
  143. m := strings.ToLower(strings.TrimSpace(StripGrokProviderPrefix(model)))
  144. return m == grok45ResponsesModel || strings.HasPrefix(m, grok45ResponsesModel+"-")
  145. }
  146. // Grok45ResponsesPlanHint returns SuperGrok / Heavy inferred from a grok-4.5
  147. // Responses window. Other models' limits are ignored.
  148. func Grok45ResponsesPlanHint(quota *QuotaSnapshot, now time.Time) string {
  149. if quota == nil {
  150. return ""
  151. }
  152. if plan := NormalizeSubscriptionTier(quota.PlanFrom45Responses); plan == "supergrok" || plan == "supergrok_heavy" {
  153. if isQuotaTimestampFresh(quota.PlanFrom45ResponsesAt, now) {
  154. return plan
  155. }
  156. }
  157. if !IsGrok45ResponsesQuotaModel(quota.Model) || !IsQuotaSnapshotFresh(quota, now) {
  158. return ""
  159. }
  160. if quotaLooksLikeGrokHeavy(quota) {
  161. return "supergrok_heavy"
  162. }
  163. return ""
  164. }
  165. // ApplyGrok45ResponsesPlanSignal records a grok-4.5 Heavy/SuperGrok hint, or
  166. // copies the previous 4.5 hint when this observation is a different model.
  167. func (s *QuotaSnapshot) ApplyGrok45ResponsesPlanSignal(prev *QuotaSnapshot) {
  168. if s == nil {
  169. return
  170. }
  171. observedAt := firstNonEmptyQuotaTime(s.LastHeadersSeenAt, s.UpdatedAt)
  172. if IsGrok45ResponsesQuotaModel(s.Model) && quotaHasLimitWindow(s) {
  173. if quotaLooksLikeGrokHeavy(s) {
  174. s.PlanFrom45Responses = "supergrok_heavy"
  175. s.PlanFrom45ResponsesAt = observedAt
  176. return
  177. }
  178. s.PlanFrom45Responses = "supergrok"
  179. s.PlanFrom45ResponsesAt = observedAt
  180. return
  181. }
  182. if prev != nil && strings.TrimSpace(prev.PlanFrom45Responses) != "" {
  183. s.PlanFrom45Responses = prev.PlanFrom45Responses
  184. s.PlanFrom45ResponsesAt = prev.PlanFrom45ResponsesAt
  185. }
  186. }
  187. // QuotaSnapshotObservedAt prefers LastHeadersSeenAt over UpdatedAt so a later
  188. // snapshot rewrite cannot refresh a stale Heavy window.
  189. func QuotaSnapshotObservedAt(snapshot *QuotaSnapshot) (time.Time, bool) {
  190. if snapshot == nil {
  191. return time.Time{}, false
  192. }
  193. return parseQuotaTimestamp(firstNonEmptyQuotaTime(snapshot.LastHeadersSeenAt, snapshot.UpdatedAt))
  194. }
  195. // IsQuotaSnapshotFresh reports whether a quota signal is recent enough to
  196. // distinguish SuperGrok from Heavy.
  197. func IsQuotaSnapshotFresh(snapshot *QuotaSnapshot, now time.Time) bool {
  198. observedAt, ok := QuotaSnapshotObservedAt(snapshot)
  199. if !ok {
  200. return false
  201. }
  202. return isTimeFresh(observedAt, now)
  203. }
  204. func isQuotaTimestampFresh(raw string, now time.Time) bool {
  205. parsed, ok := parseQuotaTimestamp(raw)
  206. if !ok {
  207. return false
  208. }
  209. return isTimeFresh(parsed, now)
  210. }
  211. func parseQuotaTimestamp(raw string) (time.Time, bool) {
  212. raw = strings.TrimSpace(raw)
  213. if raw == "" {
  214. return time.Time{}, false
  215. }
  216. parsed, err := time.Parse(time.RFC3339, raw)
  217. if err != nil {
  218. return time.Time{}, false
  219. }
  220. return parsed, true
  221. }
  222. func isTimeFresh(observedAt, now time.Time) bool {
  223. if now.IsZero() {
  224. now = time.Now()
  225. }
  226. age := now.Sub(observedAt)
  227. return age <= GrokQuotaSignalMaxAge && age >= -5*time.Minute
  228. }
  229. func quotaHasLimitWindow(quota *QuotaSnapshot) bool {
  230. if quota == nil {
  231. return false
  232. }
  233. if quota.Requests != nil && quota.Requests.Limit != nil {
  234. return true
  235. }
  236. return quota.Tokens != nil && quota.Tokens.Limit != nil
  237. }
  238. func quotaLooksLikeGrokHeavy(quota *QuotaSnapshot) bool {
  239. if quota == nil {
  240. return false
  241. }
  242. var requestLimit, tokenLimit int64
  243. if quota.Requests != nil && quota.Requests.Limit != nil {
  244. requestLimit = *quota.Requests.Limit
  245. }
  246. if quota.Tokens != nil && quota.Tokens.Limit != nil {
  247. tokenLimit = *quota.Tokens.Limit
  248. }
  249. return requestLimit >= grokHeavyQuotaRequestLimit || tokenLimit >= grokHeavyQuotaTokenLimit
  250. }
  251. func firstNonEmptyQuotaTime(values ...string) string {
  252. for _, value := range values {
  253. if strings.TrimSpace(value) != "" {
  254. return strings.TrimSpace(value)
  255. }
  256. }
  257. return ""
  258. }