quota.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. package xai
  2. import (
  3. "net/http"
  4. "strconv"
  5. "strings"
  6. "time"
  7. )
  8. // GrokFreeRolling24hTokenLimit is the operator soft-gate nominal Free allowance
  9. // (rolling 24h). Soft-gate default matches this; upstream header limits may
  10. // still report historical 1M/2M Free snapshots.
  11. const GrokFreeRolling24hTokenLimit int64 = 500_000
  12. var grokFreeRolling24hTokenLimits = map[int64]struct{}{
  13. GrokFreeRolling24hTokenLimit: {},
  14. 1_000_000: {}, // Observed Free limit variants.
  15. 2_000_000: {}, // Legacy Free limit observed before July 2026.
  16. }
  17. func IsGrokFreeRolling24hTokenLimit(limit int64) bool {
  18. _, ok := grokFreeRolling24hTokenLimits[limit]
  19. return ok
  20. }
  21. type QuotaWindow struct {
  22. Limit *int64 `json:"limit,omitempty"`
  23. Remaining *int64 `json:"remaining,omitempty"`
  24. ResetUnix *int64 `json:"reset_unix,omitempty"`
  25. ResetAt string `json:"reset_at,omitempty"`
  26. }
  27. type QuotaSnapshot struct {
  28. Requests *QuotaWindow `json:"requests,omitempty"`
  29. Tokens *QuotaWindow `json:"tokens,omitempty"`
  30. RetryAfterSeconds *int `json:"retry_after_seconds,omitempty"`
  31. SubscriptionTier string `json:"subscription_tier,omitempty"`
  32. EntitlementStatus string `json:"entitlement_status,omitempty"`
  33. StatusCode int `json:"status_code,omitempty"`
  34. Headers map[string]string `json:"headers,omitempty"`
  35. HeadersObserved bool `json:"headers_observed"`
  36. ObservationSource string `json:"observation_source,omitempty"`
  37. LastProbeAt string `json:"last_probe_at,omitempty"`
  38. LastHeadersSeenAt string `json:"last_headers_seen_at,omitempty"`
  39. UpdatedAt string `json:"updated_at"`
  40. // Model is the upstream id that produced these rate-limit headers.
  41. Model string `json:"model,omitempty"`
  42. // PlanFrom45Responses is inferred from a grok-4.5 Responses window
  43. // (8300/53M = Heavy). Carried across later non-4.5 overwrites.
  44. PlanFrom45Responses string `json:"plan_from_45_responses,omitempty"`
  45. PlanFrom45ResponsesAt string `json:"plan_from_45_responses_at,omitempty"`
  46. }
  47. func (s *QuotaSnapshot) HasObservedHeaders() bool {
  48. if s == nil {
  49. return false
  50. }
  51. return s.HeadersObserved ||
  52. s.Requests != nil ||
  53. s.Tokens != nil ||
  54. s.RetryAfterSeconds != nil ||
  55. s.SubscriptionTier != "" ||
  56. s.EntitlementStatus != "" ||
  57. len(s.Headers) > 0
  58. }
  59. var quotaHeaderAllowlist = []string{
  60. "x-ratelimit-limit-requests",
  61. "x-ratelimit-remaining-requests",
  62. "x-ratelimit-reset-requests",
  63. "x-ratelimit-limit-tokens",
  64. "x-ratelimit-remaining-tokens",
  65. "x-ratelimit-reset-tokens",
  66. "x-rate-limit-limit-requests",
  67. "x-rate-limit-remaining-requests",
  68. "x-rate-limit-reset-requests",
  69. "x-rate-limit-limit-tokens",
  70. "x-rate-limit-remaining-tokens",
  71. "x-rate-limit-reset-tokens",
  72. "retry-after",
  73. "x-subscription-tier",
  74. "xai-subscription-tier",
  75. "x-xai-subscription-tier",
  76. "x-xai-user-tier",
  77. "xai-user-tier",
  78. "xai-tier",
  79. "x-user-tier",
  80. "x-plan-tier",
  81. "x-subscription-plan",
  82. "x-entitlement-status",
  83. "xai-entitlement-status",
  84. "x-xai-entitlement-status",
  85. "x-xai-user-entitlement-status",
  86. "x-user-entitlement-status",
  87. }
  88. func ParseQuotaHeaders(headers http.Header, statusCode int) *QuotaSnapshot {
  89. return parseQuotaHeaders(headers, statusCode, "", false)
  90. }
  91. func ObserveQuotaHeaders(headers http.Header, statusCode int, source string) *QuotaSnapshot {
  92. return parseQuotaHeaders(headers, statusCode, source, true)
  93. }
  94. func parseQuotaHeaders(headers http.Header, statusCode int, source string, keepEmpty bool) *QuotaSnapshot {
  95. if headers == nil && !keepEmpty {
  96. return nil
  97. }
  98. now := time.Now().UTC().Format(time.RFC3339)
  99. snapshot := &QuotaSnapshot{
  100. Requests: parseQuotaWindow(headers, "requests"),
  101. Tokens: parseQuotaWindow(headers, "tokens"),
  102. StatusCode: statusCode,
  103. Headers: make(map[string]string),
  104. ObservationSource: strings.TrimSpace(source),
  105. UpdatedAt: now,
  106. }
  107. if snapshot.ObservationSource == "active_probe" {
  108. snapshot.LastProbeAt = now
  109. }
  110. if retryAfter := parseRetryAfter(headers.Get("retry-after")); retryAfter != nil {
  111. snapshot.RetryAfterSeconds = retryAfter
  112. }
  113. snapshot.SubscriptionTier = firstHeader(headers,
  114. "xai-subscription-tier",
  115. "x-subscription-tier",
  116. "x-xai-subscription-tier",
  117. "x-xai-user-tier",
  118. "xai-user-tier",
  119. "xai-tier",
  120. "x-user-tier",
  121. "x-plan-tier",
  122. "x-subscription-plan",
  123. )
  124. snapshot.EntitlementStatus = firstHeader(headers,
  125. "xai-entitlement-status",
  126. "x-entitlement-status",
  127. "x-xai-entitlement-status",
  128. "x-xai-user-entitlement-status",
  129. "x-user-entitlement-status",
  130. )
  131. for _, name := range quotaHeaderAllowlist {
  132. if value := strings.TrimSpace(headers.Get(name)); value != "" {
  133. snapshot.Headers[name] = value
  134. }
  135. }
  136. if snapshot.Requests == nil &&
  137. snapshot.Tokens == nil &&
  138. snapshot.RetryAfterSeconds == nil &&
  139. snapshot.SubscriptionTier == "" &&
  140. snapshot.EntitlementStatus == "" &&
  141. len(snapshot.Headers) == 0 {
  142. if keepEmpty {
  143. return snapshot
  144. }
  145. return nil
  146. }
  147. snapshot.HeadersObserved = true
  148. snapshot.LastHeadersSeenAt = now
  149. return snapshot
  150. }
  151. func parseQuotaWindow(headers http.Header, dimension string) *QuotaWindow {
  152. limitHeader := firstHeader(headers,
  153. "x-ratelimit-limit-"+dimension,
  154. "x-rate-limit-limit-"+dimension,
  155. )
  156. remainingHeader := firstHeader(headers,
  157. "x-ratelimit-remaining-"+dimension,
  158. "x-rate-limit-remaining-"+dimension,
  159. )
  160. resetHeader := firstHeader(headers,
  161. "x-ratelimit-reset-"+dimension,
  162. "x-rate-limit-reset-"+dimension,
  163. )
  164. window := &QuotaWindow{
  165. Limit: parseInt64Ptr(limitHeader),
  166. Remaining: parseInt64Ptr(remainingHeader),
  167. }
  168. if reset := parseResetHeader(resetHeader); reset != nil {
  169. window.ResetUnix = reset
  170. window.ResetAt = time.Unix(*reset, 0).UTC().Format(time.RFC3339)
  171. }
  172. if window.Limit == nil && window.Remaining == nil && window.ResetUnix == nil {
  173. return nil
  174. }
  175. return window
  176. }
  177. func parseResetHeader(raw string) *int64 {
  178. raw = strings.TrimSpace(raw)
  179. if raw == "" {
  180. return nil
  181. }
  182. if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
  183. // xAI (and OpenAI-compatible upstreams) may express the reset as a
  184. // millisecond epoch, a second epoch, or a *relative* number of seconds
  185. // until reset (e.g. "60"). Disambiguate by magnitude, mirroring the
  186. // Kiro reset parser, so a relative "60" is not misread as 1970-01-01.
  187. switch {
  188. case value >= 1_000_000_000_000: // milliseconds epoch → seconds
  189. value = value / 1000
  190. case value >= 1_000_000_000: // already a plausible unix-seconds epoch (>= 2001-09)
  191. // keep as-is
  192. default: // relative seconds from now
  193. value = time.Now().Unix() + value
  194. }
  195. return &value
  196. }
  197. if duration, err := time.ParseDuration(raw); err == nil && duration > 0 {
  198. if duration < time.Second {
  199. duration = time.Second
  200. }
  201. value := time.Now().Add(duration).Unix()
  202. return &value
  203. }
  204. if t, err := time.Parse(time.RFC3339, raw); err == nil {
  205. value := t.Unix()
  206. return &value
  207. }
  208. return nil
  209. }
  210. func parseRetryAfter(raw string) *int {
  211. raw = strings.TrimSpace(raw)
  212. if raw == "" {
  213. return nil
  214. }
  215. if value, err := strconv.Atoi(raw); err == nil {
  216. return &value
  217. }
  218. if t, err := http.ParseTime(raw); err == nil {
  219. seconds := int(time.Until(t).Seconds())
  220. if seconds < 0 {
  221. seconds = 0
  222. }
  223. return &seconds
  224. }
  225. return nil
  226. }
  227. func parseInt64Ptr(raw string) *int64 {
  228. raw = strings.TrimSpace(raw)
  229. if raw == "" {
  230. return nil
  231. }
  232. value, err := strconv.ParseInt(raw, 10, 64)
  233. if err != nil {
  234. return nil
  235. }
  236. return &value
  237. }
  238. func firstHeader(headers http.Header, names ...string) string {
  239. for _, name := range names {
  240. if value := strings.TrimSpace(headers.Get(name)); value != "" {
  241. return value
  242. }
  243. }
  244. return ""
  245. }