grok_team_rate_limit.go 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. package service
  2. import (
  3. "crypto/sha256"
  4. "encoding/hex"
  5. "strings"
  6. "sync"
  7. "time"
  8. )
  9. // In-memory team+model rate-limit overlay for Grok OAuth. When xAI rate-limits
  10. // one account in a team for a model, sibling accounts sharing team_id skip the
  11. // same model until the cooldown expires (mirrors grok2api teamModelRateLimit).
  12. //
  13. // Process-local only: multi-instance deployments each learn the block from their
  14. // own 429s. Prefer short TTLs so drift self-heals.
  15. type grokTeamModelRateLimit struct {
  16. Until time.Time
  17. }
  18. type grokTeamModelRateLimitStore struct {
  19. mu sync.Mutex
  20. items map[string]grokTeamModelRateLimit
  21. }
  22. var globalGrokTeamModelRateLimits = &grokTeamModelRateLimitStore{
  23. items: make(map[string]grokTeamModelRateLimit),
  24. }
  25. const (
  26. grokTeamRateLimitDefaultTTL = 10 * time.Minute
  27. grokTeamRateLimitMaxTTL = time.Hour
  28. grokTeamRateLimitMinTTL = 30 * time.Second
  29. )
  30. func grokTeamFingerprint(teamID string) string {
  31. teamID = strings.TrimSpace(teamID)
  32. if teamID == "" {
  33. return ""
  34. }
  35. sum := sha256.Sum256([]byte(strings.ToLower(teamID)))
  36. return hex.EncodeToString(sum[:8])
  37. }
  38. func grokTeamModelRateLimitKey(teamFingerprint, model string) string {
  39. return teamFingerprint + "|" + strings.ToLower(strings.TrimSpace(model))
  40. }
  41. func accountGrokTeamID(account *Account) string {
  42. if account == nil {
  43. return ""
  44. }
  45. return strings.TrimSpace(account.GetCredential("team_id"))
  46. }
  47. // markGrokTeamModelRateLimit records that this team+model pair should be skipped
  48. // until until. No-op when team_id or model is empty.
  49. func markGrokTeamModelRateLimit(account *Account, model string, until time.Time) {
  50. if account == nil || !account.IsGrokOAuth() {
  51. return
  52. }
  53. fp := grokTeamFingerprint(accountGrokTeamID(account))
  54. model = strings.TrimSpace(model)
  55. if fp == "" || model == "" || until.IsZero() {
  56. return
  57. }
  58. now := time.Now()
  59. if !until.After(now) {
  60. until = now.Add(grokTeamRateLimitDefaultTTL)
  61. }
  62. maxUntil := now.Add(grokTeamRateLimitMaxTTL)
  63. if until.After(maxUntil) {
  64. until = maxUntil
  65. }
  66. key := grokTeamModelRateLimitKey(fp, model)
  67. globalGrokTeamModelRateLimits.mu.Lock()
  68. defer globalGrokTeamModelRateLimits.mu.Unlock()
  69. if cur, ok := globalGrokTeamModelRateLimits.items[key]; ok && cur.Until.After(until) {
  70. return
  71. }
  72. globalGrokTeamModelRateLimits.items[key] = grokTeamModelRateLimit{Until: until}
  73. // Opportunistic prune of expired entries.
  74. for k, v := range globalGrokTeamModelRateLimits.items {
  75. if !v.Until.After(now) {
  76. delete(globalGrokTeamModelRateLimits.items, k)
  77. }
  78. }
  79. }
  80. // isGrokTeamModelRateLimited reports whether the account's team is currently
  81. // blocked for the requested model.
  82. func isGrokTeamModelRateLimited(account *Account, model string, now time.Time) bool {
  83. if account == nil || !account.IsGrokOAuth() {
  84. return false
  85. }
  86. fp := grokTeamFingerprint(accountGrokTeamID(account))
  87. model = strings.TrimSpace(model)
  88. if fp == "" || model == "" {
  89. return false
  90. }
  91. key := grokTeamModelRateLimitKey(fp, model)
  92. globalGrokTeamModelRateLimits.mu.Lock()
  93. defer globalGrokTeamModelRateLimits.mu.Unlock()
  94. cur, ok := globalGrokTeamModelRateLimits.items[key]
  95. if !ok {
  96. return false
  97. }
  98. if !cur.Until.After(now) {
  99. delete(globalGrokTeamModelRateLimits.items, key)
  100. return false
  101. }
  102. return true
  103. }
  104. // filterGrokTeamModelRateLimitedAccounts drops candidates whose team is under a
  105. // model-scoped rate-limit cool. Accounts without team_id pass through.
  106. func filterGrokTeamModelRateLimitedAccounts(accounts []Account, model string, now time.Time) []Account {
  107. if len(accounts) == 0 || strings.TrimSpace(model) == "" {
  108. return accounts
  109. }
  110. out := accounts[:0]
  111. kept := false
  112. for i := range accounts {
  113. upstreamModel := canonicalOpenAIAccountSchedulingModel(&accounts[i], model)
  114. if isGrokTeamModelRateLimited(&accounts[i], upstreamModel, now) {
  115. continue
  116. }
  117. out = append(out, accounts[i])
  118. kept = true
  119. }
  120. if !kept && len(out) == 0 {
  121. // All filtered — return empty (caller treats as no capacity).
  122. return nil
  123. }
  124. return out
  125. }
  126. // resolveGrokTeamRateLimitUntil derives a team cool window from an observed
  127. // account rate-limit reset, with sane clamps.
  128. func resolveGrokTeamRateLimitUntil(resetAt, now time.Time) time.Time {
  129. if resetAt.After(now.Add(grokTeamRateLimitMinTTL)) {
  130. maxUntil := now.Add(grokTeamRateLimitMaxTTL)
  131. if resetAt.After(maxUntil) {
  132. return maxUntil
  133. }
  134. return resetAt
  135. }
  136. return now.Add(grokTeamRateLimitDefaultTTL)
  137. }