grok_free_quota_gate.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. package service
  2. import (
  3. "context"
  4. "log/slog"
  5. "strings"
  6. "sync"
  7. "sync/atomic"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
  11. )
  12. // Local free-tier soft gate for Grok OAuth scheduling.
  13. //
  14. // Config keys (gateway.grok.*):
  15. // - free_quota_soft_gate_enabled (bool, default true)
  16. // - free_quota_token_limit (int64, default 500_000)
  17. // - free_quota_soft_gate_percent (int, default 95) — stop scheduling before the nominal limit
  18. // - free_quota_window_hours (int, default 24) — local usage rolling window
  19. // - free_quota_stats_cache_seconds (int, default 60) — stats cache TTL; hot path never waits on DB
  20. //
  21. // Soft-gate applies only to *explicit* free OAuth (subscription_tier/plan_type ==
  22. // "free"). Media/cache free detection uses isKnownGrokFreeAccount instead.
  23. // Admin paths (QueryQuota / import probe) never call this filter.
  24. // Defaults live on config.Gateway.Grok (see config load defaults / tests).
  25. type GrokFreeQuotaPolicy struct {
  26. Enabled bool `json:"enabled"`
  27. TokenLimit int64 `json:"token_limit"`
  28. SoftGatePercent int `json:"soft_gate_percent"`
  29. SoftGateTokens int64 `json:"soft_gate_tokens"`
  30. WindowHours int `json:"window_hours"`
  31. }
  32. type grokFreeQuotaGateSettings struct {
  33. limitTokens int64
  34. gateTokens int64
  35. window time.Duration
  36. cacheTTL time.Duration
  37. }
  38. type grokFreeQuotaGateCacheEntry struct {
  39. tokens int64
  40. checkedAt time.Time
  41. known bool
  42. }
  43. var grokFreeQuotaGateQueryFailureTotal atomic.Int64
  44. var grokFreeQuotaGateBlockedTotal atomic.Int64
  45. func resolveGrokFreeQuotaGateSettings(cfg *config.Config) (grokFreeQuotaGateSettings, bool) {
  46. if cfg == nil || !cfg.Gateway.Grok.FreeQuotaSoftGateEnabled {
  47. return grokFreeQuotaGateSettings{}, false
  48. }
  49. limit := cfg.Gateway.Grok.FreeQuotaTokenLimit
  50. percent := cfg.Gateway.Grok.FreeQuotaSoftGatePercent
  51. windowHours := cfg.Gateway.Grok.FreeQuotaWindowHours
  52. cacheSeconds := cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds
  53. if limit <= 0 || percent < 1 || percent > 100 || windowHours <= 0 || cacheSeconds < 0 {
  54. return grokFreeQuotaGateSettings{}, false
  55. }
  56. gate := calculateGrokFreeQuotaSoftGateTokens(limit, percent)
  57. if gate <= 0 {
  58. return grokFreeQuotaGateSettings{}, false
  59. }
  60. return grokFreeQuotaGateSettings{
  61. limitTokens: limit,
  62. gateTokens: gate,
  63. window: time.Duration(windowHours) * time.Hour,
  64. cacheTTL: time.Duration(cacheSeconds) * time.Second,
  65. }, true
  66. }
  67. func calculateGrokFreeQuotaSoftGateTokens(limit int64, percent int) int64 {
  68. if limit <= 0 || percent <= 0 {
  69. return 0
  70. }
  71. return (limit/100)*int64(percent) + (limit%100)*int64(percent)/100
  72. }
  73. // isExplicitGrokFreeOAuthAccount decides whether the free soft-gate applies.
  74. // Contract: only OAuth accounts with credentials/extra
  75. // subscription_tier or plan_type exactly "free" (case-insensitive). Inferred
  76. // free / basic / blank plan do not soft-gate.
  77. func isExplicitGrokFreeOAuthAccount(account *Account) bool {
  78. if account == nil || !account.IsGrokOAuth() {
  79. return false
  80. }
  81. for _, tier := range []string{
  82. account.GetCredential("subscription_tier"),
  83. account.GetCredential("plan_type"),
  84. account.GetExtraString("subscription_tier"),
  85. account.GetExtraString("plan_type"),
  86. } {
  87. if strings.EqualFold(strings.TrimSpace(tier), "free") {
  88. return true
  89. }
  90. }
  91. return false
  92. }
  93. // filterGrokFreeQuotaAccounts applies a local, rolling soft gate only to
  94. // FREE Grok OAuth accounts on the OpenAI scheduling hot path.
  95. // Missing or failed statistics always fail open; upstream quota/rate-limit
  96. // handling remains authoritative. Admin quota/import probes never call this.
  97. func (s *defaultOpenAIAccountScheduler) filterGrokFreeQuotaAccounts(ctx context.Context, accounts []Account) []Account {
  98. if s == nil || s.service == nil {
  99. return accounts
  100. }
  101. return filterGrokFreeQuotaAccountsCore(ctx, s.service.cfg, s.service.usageLogRepo, &s.grokFreeQuotaGateCache, accounts)
  102. }
  103. // filterGrokFreeQuotaAccountsForGateway applies the same soft gate on Gateway
  104. // scheduling (e.g. /v1/web_search) so free accounts near local 95%/1M are not
  105. // still selected for native search while Responses soft-gates them out.
  106. func (s *GatewayService) filterGrokFreeQuotaAccountsForGateway(ctx context.Context, accounts []Account) []Account {
  107. if s == nil {
  108. return accounts
  109. }
  110. return filterGrokFreeQuotaAccountsCore(ctx, s.cfg, s.usageLogRepo, &gatewayGrokFreeQuotaGateCache, accounts)
  111. }
  112. // Shared caches for non-advanced-scheduler selection paths.
  113. // Advanced scheduler keeps per-instance sync.Map on defaultOpenAIAccountScheduler.
  114. var gatewayGrokFreeQuotaGateCache sync.Map
  115. var openaiGrokFreeQuotaGateCache sync.Map
  116. // freeQuotaRefreshInFlight coalesces concurrent background refreshes per cache map.
  117. var freeQuotaRefreshInFlight sync.Map // *sync.Map -> *sync.Map (accountID -> struct{})
  118. func filterGrokFreeQuotaAccountsCore(
  119. ctx context.Context,
  120. cfg *config.Config,
  121. usageLogRepo UsageLogRepository,
  122. cache *sync.Map,
  123. accounts []Account,
  124. ) []Account {
  125. if cache == nil {
  126. return accounts
  127. }
  128. settings, enabled := resolveGrokFreeQuotaGateSettings(cfg)
  129. if !enabled || len(accounts) == 0 || usageLogRepo == nil {
  130. return accounts
  131. }
  132. now := time.Now().UTC()
  133. tokensByID := make(map[int64]int64)
  134. missingIDs := make([]int64, 0, len(accounts))
  135. seenMissing := make(map[int64]struct{})
  136. for i := range accounts {
  137. account := &accounts[i]
  138. if !isExplicitGrokFreeOAuthAccount(account) || account.ID <= 0 {
  139. continue
  140. }
  141. if cached, ok := cache.Load(account.ID); ok {
  142. entry, valid := cached.(grokFreeQuotaGateCacheEntry)
  143. if valid {
  144. age := now.Sub(entry.checkedAt)
  145. // cacheTTL == 0 means "no expiry" for known entries (still fail-open
  146. // on first miss; refresh is only scheduled when missing/stale).
  147. fresh := settings.cacheTTL <= 0 || (age >= 0 && age < settings.cacheTTL)
  148. if fresh {
  149. if entry.known {
  150. tokensByID[account.ID] = entry.tokens
  151. }
  152. continue
  153. }
  154. }
  155. }
  156. // Miss / stale: fail open on this request; refresh asynchronously.
  157. if _, exists := seenMissing[account.ID]; !exists {
  158. seenMissing[account.ID] = struct{}{}
  159. missingIDs = append(missingIDs, account.ID)
  160. }
  161. }
  162. if len(missingIDs) > 0 {
  163. scheduleGrokFreeQuotaStatsRefresh(usageLogRepo, cache, settings, missingIDs)
  164. }
  165. filtered := make([]Account, 0, len(accounts))
  166. for i := range accounts {
  167. account := &accounts[i]
  168. if isExplicitGrokFreeOAuthAccount(account) {
  169. if tokens, known := tokensByID[account.ID]; known && tokens >= settings.gateTokens {
  170. continue
  171. }
  172. }
  173. filtered = append(filtered, *account)
  174. }
  175. return filtered
  176. }
  177. // scheduleGrokFreeQuotaStatsRefresh loads usage stats off the request path.
  178. // Concurrent callers for the same accountID are coalesced via in-flight markers.
  179. func scheduleGrokFreeQuotaStatsRefresh(
  180. usageLogRepo UsageLogRepository,
  181. cache *sync.Map,
  182. settings grokFreeQuotaGateSettings,
  183. accountIDs []int64,
  184. ) {
  185. if usageLogRepo == nil || cache == nil || len(accountIDs) == 0 {
  186. return
  187. }
  188. inFlightRoot, _ := freeQuotaRefreshInFlight.LoadOrStore(cache, &sync.Map{})
  189. inFlight, ok := inFlightRoot.(*sync.Map)
  190. if !ok || inFlight == nil {
  191. return
  192. }
  193. toFetch := make([]int64, 0, len(accountIDs))
  194. for _, id := range accountIDs {
  195. if _, loaded := inFlight.LoadOrStore(id, struct{}{}); !loaded {
  196. toFetch = append(toFetch, id)
  197. }
  198. }
  199. if len(toFetch) == 0 {
  200. return
  201. }
  202. window := settings.window
  203. gateTokens := settings.gateTokens
  204. limitTokens := settings.limitTokens
  205. cacheTTL := settings.cacheTTL
  206. go func() {
  207. defer func() {
  208. for _, id := range toFetch {
  209. inFlight.Delete(id)
  210. }
  211. }()
  212. now := time.Now().UTC()
  213. statsByID, err := queryGrokFreeQuotaWindowStats(context.Background(), usageLogRepo, toFetch, now.Add(-window))
  214. if err != nil {
  215. grokFreeQuotaGateQueryFailureTotal.Add(1)
  216. // Store a negative entry so subsequent hot-path calls do not thrash.
  217. // known=false → still fail open until a successful refresh lands.
  218. for _, accountID := range toFetch {
  219. cache.Store(accountID, grokFreeQuotaGateCacheEntry{checkedAt: now})
  220. }
  221. slog.Warn("grok_free_quota_soft_gate_stats_failed",
  222. "account_count", len(toFetch),
  223. "window_hours", window.Hours(),
  224. "error", err)
  225. sweepGrokFreeQuotaGateCache(cache, now, cacheTTL)
  226. return
  227. }
  228. for _, accountID := range toFetch {
  229. tokens := int64(0)
  230. if stats := statsByID[accountID]; stats != nil && stats.Tokens > 0 {
  231. tokens = stats.Tokens
  232. }
  233. cache.Store(accountID, grokFreeQuotaGateCacheEntry{tokens: tokens, checkedAt: now, known: true})
  234. if tokens >= gateTokens {
  235. grokFreeQuotaGateBlockedTotal.Add(1)
  236. slog.Info("grok_free_quota_soft_gate_blocked",
  237. "account_id", accountID,
  238. "tokens", tokens,
  239. "gate_tokens", gateTokens,
  240. "limit_tokens", limitTokens,
  241. "window_hours", window.Hours())
  242. }
  243. }
  244. sweepGrokFreeQuotaGateCache(cache, now, cacheTTL)
  245. }()
  246. }
  247. // grokFreeQuotaGateCacheMinSweepAge floors the eviction age so a tiny cacheTTL
  248. // does not turn the cache into a per-call re-query.
  249. const grokFreeQuotaGateCacheMinSweepAge = 5 * time.Minute
  250. // sweepGrokFreeQuotaGateCache drops entries far past their TTL.
  251. //
  252. // Entries are keyed by account ID and only ever overwritten, so an account that
  253. // stops being scheduled (deleted, or moved off the free tier) would otherwise
  254. // sit in the map for the process lifetime. A still-live account simply
  255. // re-populates its entry on the next miss.
  256. func sweepGrokFreeQuotaGateCache(cache *sync.Map, now time.Time, cacheTTL time.Duration) {
  257. if cache == nil || cacheTTL <= 0 {
  258. return
  259. }
  260. maxAge := cacheTTL * 20
  261. if maxAge < grokFreeQuotaGateCacheMinSweepAge {
  262. maxAge = grokFreeQuotaGateCacheMinSweepAge
  263. }
  264. cache.Range(func(key, value any) bool {
  265. entry, ok := value.(grokFreeQuotaGateCacheEntry)
  266. if !ok || now.Sub(entry.checkedAt) > maxAge {
  267. cache.Delete(key)
  268. }
  269. return true
  270. })
  271. }
  272. func queryGrokFreeQuotaWindowStats(ctx context.Context, usageLogRepo UsageLogRepository, accountIDs []int64, start time.Time) (map[int64]*usagestats.AccountStats, error) {
  273. if usageLogRepo == nil {
  274. return nil, nil
  275. }
  276. if batch, ok := usageLogRepo.(accountWindowStatsBatchReader); ok {
  277. return batch.GetAccountWindowStatsBatch(ctx, accountIDs, start)
  278. }
  279. statsByID := make(map[int64]*usagestats.AccountStats, len(accountIDs))
  280. for _, accountID := range accountIDs {
  281. stats, err := usageLogRepo.GetAccountWindowStats(ctx, accountID, start)
  282. if err != nil {
  283. return nil, err
  284. }
  285. statsByID[accountID] = stats
  286. }
  287. return statsByID, nil
  288. }