grok_quota_fetcher.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. package service
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/http"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  9. )
  10. const grokQuotaSnapshotExtraKey = "grok_usage_snapshot"
  11. type GrokQuotaFetcher struct{}
  12. func NewGrokQuotaFetcher() *GrokQuotaFetcher {
  13. return &GrokQuotaFetcher{}
  14. }
  15. func (f *GrokQuotaFetcher) BuildUsageInfo(account *Account) *UsageInfo {
  16. now := time.Now()
  17. usage := &UsageInfo{
  18. Source: "passive",
  19. UpdatedAt: &now,
  20. GrokFreeTokenLimit: xai.GrokFreeRolling24hTokenLimit,
  21. }
  22. if account == nil {
  23. usage.ErrorCode = "quota_unknown"
  24. usage.Error = "Grok quota is unknown until billing is probed or an upstream response includes xAI rate-limit headers"
  25. return usage
  26. }
  27. billing, _ := grokBillingSnapshotFromExtra(account.Extra)
  28. snapshot, err := grokQuotaSnapshotFromExtra(account.Extra)
  29. activeProbeClearsForbidden := newerSuccessfulGrokActiveProbeClearsBillingForbidden(billing, snapshot)
  30. if billing != nil {
  31. usage.GrokBilling = billing
  32. applyGrokBillingProgressWindows(usage, billing, now)
  33. if billing.Plan != "" {
  34. usage.SubscriptionTier = billing.Plan
  35. usage.SubscriptionTierRaw = billing.Plan
  36. }
  37. if parsedAt, parseErr := time.Parse(time.RFC3339, billing.UpdatedAt); parseErr == nil {
  38. usage.UpdatedAt = &parsedAt
  39. }
  40. if billing.FetchedAt != "" {
  41. usage.GrokLastQuotaProbeAt = billing.FetchedAt
  42. }
  43. usage.GrokQuotaSnapshotState = "billing_observed"
  44. usage.GrokLastStatusCode = billing.StatusCode
  45. switch billing.StatusCode {
  46. case 401:
  47. usage.NeedsReauth = true
  48. usage.ErrorCode = "unauthenticated"
  49. case 403:
  50. usage.IsForbidden = true
  51. usage.ForbiddenType = "forbidden"
  52. usage.ErrorCode = "forbidden"
  53. case 429:
  54. usage.ErrorCode = "rate_limited"
  55. }
  56. // Official weekly/monthly progress clears the "unknown until headers" state.
  57. if usage.ErrorCode == "quota_unknown" && (usage.SevenDay != nil || usage.ThirtyDay != nil) {
  58. usage.ErrorCode = ""
  59. if strings.Contains(strings.ToLower(usage.Error), "unknown until") ||
  60. strings.Contains(strings.ToLower(usage.Error), "no xai quota headers") {
  61. usage.Error = ""
  62. }
  63. }
  64. }
  65. if err != nil || snapshot == nil {
  66. applyGrokCredentialUsageFallback(usage, account, billing, nil)
  67. if billing == nil {
  68. usage.ErrorCode = "quota_unknown"
  69. usage.Error = "Grok quota is unknown until billing is probed or an upstream response includes xAI rate-limit headers"
  70. }
  71. return usage
  72. }
  73. if parsedAt, parseErr := time.Parse(time.RFC3339, snapshot.UpdatedAt); parseErr == nil {
  74. if billing == nil || usage.UpdatedAt == nil || parsedAt.After(*usage.UpdatedAt) {
  75. usage.UpdatedAt = &parsedAt
  76. }
  77. }
  78. usage.GrokRequestQuota = snapshot.Requests
  79. usage.GrokTokenQuota = snapshot.Tokens
  80. usage.GrokRetryAfterSeconds = snapshot.RetryAfterSeconds
  81. if usage.SubscriptionTier == "" {
  82. usage.SubscriptionTier = snapshot.SubscriptionTier
  83. usage.SubscriptionTierRaw = snapshot.SubscriptionTier
  84. }
  85. if usage.GrokEntitlementStatus == "" {
  86. usage.GrokEntitlementStatus = snapshot.EntitlementStatus
  87. }
  88. if usage.GrokLastQuotaProbeAt == "" {
  89. usage.GrokLastQuotaProbeAt = snapshot.LastProbeAt
  90. }
  91. usage.GrokLastHeadersSeenAt = snapshot.LastHeadersSeenAt
  92. if activeProbeClearsForbidden {
  93. usage.IsForbidden = false
  94. usage.ForbiddenType = ""
  95. usage.ErrorCode = ""
  96. usage.GrokLastQuotaProbeAt = snapshot.LastProbeAt
  97. usage.GrokLastStatusCode = snapshot.StatusCode
  98. } else if snapshot.StatusCode >= http.StatusBadRequest || usage.GrokLastStatusCode == 0 {
  99. usage.GrokLastStatusCode = snapshot.StatusCode
  100. }
  101. if snapshot.HasObservedHeaders() {
  102. if usage.GrokQuotaSnapshotState == "" {
  103. usage.GrokQuotaSnapshotState = "observed"
  104. }
  105. } else if billing == nil {
  106. usage.GrokQuotaSnapshotState = "no_headers"
  107. usage.ErrorCode = "quota_unknown"
  108. usage.Error = "No xAI quota headers observed on the latest Grok probe"
  109. }
  110. if usage.ErrorCode == "" {
  111. switch snapshot.StatusCode {
  112. case 401:
  113. usage.NeedsReauth = true
  114. usage.ErrorCode = "unauthenticated"
  115. case 403:
  116. usage.IsForbidden = true
  117. usage.ForbiddenType = "forbidden"
  118. usage.ErrorCode = "forbidden"
  119. if usage.GrokEntitlementStatus == "" {
  120. usage.GrokEntitlementStatus = "forbidden"
  121. }
  122. case 429:
  123. usage.ErrorCode = "rate_limited"
  124. }
  125. }
  126. if accountGrokNeedsReauth(account) {
  127. usage.NeedsReauth = true
  128. if usage.ErrorCode == "" {
  129. usage.ErrorCode = "spending_limit"
  130. }
  131. }
  132. applyGrokCredentialUsageFallback(usage, account, billing, snapshot)
  133. if activeProbeClearsForbidden && strings.TrimSpace(snapshot.EntitlementStatus) == "" &&
  134. strings.EqualFold(strings.TrimSpace(usage.GrokEntitlementStatus), "forbidden") {
  135. usage.GrokEntitlementStatus = ""
  136. }
  137. return usage
  138. }
  139. func newerSuccessfulGrokActiveProbeClearsBillingForbidden(billing *xai.BillingSummary, snapshot *xai.QuotaSnapshot) bool {
  140. if billing == nil || billing.StatusCode != http.StatusForbidden || snapshot == nil ||
  141. snapshot.StatusCode != http.StatusOK || strings.TrimSpace(snapshot.ObservationSource) != "active_probe" {
  142. return false
  143. }
  144. billingAt, billingOK := firstGrokObservationTime(billing.UpdatedAt, billing.FetchedAt)
  145. probeAt, probeOK := firstGrokObservationTime(snapshot.LastProbeAt, snapshot.UpdatedAt)
  146. // Both snapshots use second precision, so a billing request followed by the
  147. // active probe in the same refresh can legitimately have equal timestamps.
  148. return billingOK && probeOK && !probeAt.Before(billingAt)
  149. }
  150. func firstGrokObservationTime(values ...string) (time.Time, bool) {
  151. for _, value := range values {
  152. parsedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
  153. if err == nil {
  154. return parsedAt, true
  155. }
  156. }
  157. return time.Time{}, false
  158. }
  159. func applyGrokCredentialUsageFallback(usage *UsageInfo, account *Account, billing *xai.BillingSummary, snapshot *xai.QuotaSnapshot) {
  160. if usage == nil || account == nil {
  161. return
  162. }
  163. if usage.GrokEntitlementStatus == "" {
  164. usage.GrokEntitlementStatus = strings.TrimSpace(account.GetCredential("entitlement_status"))
  165. }
  166. applyGrokResolvedSubscriptionTier(usage, account, billing, snapshot)
  167. }
  168. func applyGrokResolvedSubscriptionTier(usage *UsageInfo, account *Account, billing *xai.BillingSummary, snapshot *xai.QuotaSnapshot) {
  169. if usage == nil || account == nil {
  170. return
  171. }
  172. if jwtTier := xai.SubscriptionTierFromJWT(account.GetCredential("access_token")); jwtTier != "" {
  173. usage.SubscriptionTier = jwtTier
  174. usage.SubscriptionTierRaw = jwtTier
  175. return
  176. }
  177. signal := strings.TrimSpace(account.GetCredential("subscription_tier"))
  178. if signal == "" && snapshot != nil {
  179. signal = strings.TrimSpace(snapshot.SubscriptionTier)
  180. }
  181. if signal == "" && billing != nil {
  182. signal = strings.TrimSpace(billing.Plan)
  183. }
  184. var limit *float64
  185. if billing != nil {
  186. limit = billing.MonthlyLimitCents
  187. }
  188. if plan := xai.CanonicalGrokPlan(limit, signal, snapshot); plan != "" {
  189. usage.SubscriptionTier = plan
  190. if usage.SubscriptionTierRaw == "" {
  191. usage.SubscriptionTierRaw = firstNonEmpty(signal, plan)
  192. }
  193. return
  194. }
  195. if usage.SubscriptionTier == "" && signal != "" {
  196. usage.SubscriptionTier = signal
  197. usage.SubscriptionTierRaw = signal
  198. }
  199. }
  200. func grokBillingSnapshotFromExtra(extra map[string]any) (*xai.BillingSummary, error) {
  201. if extra == nil {
  202. return nil, nil
  203. }
  204. raw, ok := extra[grokBillingExtraKey]
  205. if !ok || raw == nil {
  206. return nil, nil
  207. }
  208. switch snapshot := raw.(type) {
  209. case *xai.BillingSummary:
  210. return snapshot, nil
  211. case xai.BillingSummary:
  212. return &snapshot, nil
  213. case map[string]any:
  214. data, err := json.Marshal(snapshot)
  215. if err != nil {
  216. return nil, err
  217. }
  218. var out xai.BillingSummary
  219. if err := json.Unmarshal(data, &out); err != nil {
  220. return nil, err
  221. }
  222. return &out, nil
  223. default:
  224. data, err := json.Marshal(raw)
  225. if err != nil {
  226. return nil, fmt.Errorf("marshal grok billing snapshot: %w", err)
  227. }
  228. var out xai.BillingSummary
  229. if err := json.Unmarshal(data, &out); err != nil {
  230. return nil, err
  231. }
  232. return &out, nil
  233. }
  234. }
  235. func stampGrokQuotaSnapshotForPlan(account *Account, snapshot *xai.QuotaSnapshot, model string) {
  236. if snapshot == nil {
  237. return
  238. }
  239. if strings.TrimSpace(snapshot.Model) == "" {
  240. model = strings.TrimSpace(model)
  241. if model != "" {
  242. snapshot.Model = xai.ResolveGrokTextResponsesModelID(model)
  243. }
  244. }
  245. var prev *xai.QuotaSnapshot
  246. if account != nil {
  247. prev, _ = grokQuotaSnapshotFromExtra(account.Extra)
  248. }
  249. snapshot.ApplyGrok45ResponsesPlanSignal(prev)
  250. }
  251. func grokQuotaSnapshotFromExtra(extra map[string]any) (*xai.QuotaSnapshot, error) {
  252. if extra == nil {
  253. return nil, nil
  254. }
  255. raw, ok := extra[grokQuotaSnapshotExtraKey]
  256. if !ok || raw == nil {
  257. return nil, nil
  258. }
  259. switch snapshot := raw.(type) {
  260. case *xai.QuotaSnapshot:
  261. return snapshot, nil
  262. case xai.QuotaSnapshot:
  263. return &snapshot, nil
  264. case map[string]any:
  265. data, err := json.Marshal(snapshot)
  266. if err != nil {
  267. return nil, err
  268. }
  269. var out xai.QuotaSnapshot
  270. if err := json.Unmarshal(data, &out); err != nil {
  271. return nil, err
  272. }
  273. return &out, nil
  274. default:
  275. data, err := json.Marshal(raw)
  276. if err != nil {
  277. return nil, fmt.Errorf("marshal grok quota snapshot: %w", err)
  278. }
  279. var out xai.QuotaSnapshot
  280. if err := json.Unmarshal(data, &out); err != nil {
  281. return nil, err
  282. }
  283. return &out, nil
  284. }
  285. }
  286. // applyGrokBillingProgressWindows fills official weekly (seven_day) and monthly
  287. // (thirty_day) UsageProgress from a billing probe summary.
  288. func applyGrokBillingProgressWindows(usage *UsageInfo, billing *xai.BillingSummary, now time.Time) {
  289. if usage == nil || billing == nil {
  290. return
  291. }
  292. if billing.UsagePercent != nil {
  293. seven := &UsageProgress{Utilization: *billing.UsagePercent}
  294. if end, err := parseTime(strings.TrimSpace(billing.PeriodEnd)); err == nil {
  295. seven.ResetsAt = &end
  296. if sec := int(end.Sub(now).Seconds()); sec > 0 {
  297. seven.RemainingSeconds = sec
  298. }
  299. }
  300. if usage.SevenDay != nil {
  301. seven.WindowStats = usage.SevenDay.WindowStats
  302. }
  303. usage.SevenDay = seven
  304. }
  305. var monthlyUtil *float64
  306. if billing.UsedPercent != nil {
  307. monthlyUtil = billing.UsedPercent
  308. } else if billing.MonthlyLimitCents != nil && *billing.MonthlyLimitCents > 0 && billing.UsedCents != nil {
  309. v := (*billing.UsedCents / *billing.MonthlyLimitCents) * 100
  310. monthlyUtil = &v
  311. }
  312. if monthlyUtil != nil {
  313. thirty := &UsageProgress{Utilization: *monthlyUtil}
  314. endRaw := strings.TrimSpace(billing.BillingPeriodEnd)
  315. if endRaw == "" && billing.PeriodType == "monthly" {
  316. endRaw = strings.TrimSpace(billing.PeriodEnd)
  317. }
  318. if end, err := parseTime(endRaw); err == nil {
  319. thirty.ResetsAt = &end
  320. if sec := int(end.Sub(now).Seconds()); sec > 0 {
  321. thirty.RemainingSeconds = sec
  322. }
  323. }
  324. if usage.ThirtyDay != nil {
  325. thirty.WindowStats = usage.ThirtyDay.WindowStats
  326. }
  327. usage.ThirtyDay = thirty
  328. }
  329. }