| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- package service
- import (
- "context"
- "log/slog"
- "strings"
- "sync"
- "sync/atomic"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
- )
- // Local free-tier soft gate for Grok OAuth scheduling.
- //
- // Config keys (gateway.grok.*):
- // - free_quota_soft_gate_enabled (bool, default true)
- // - free_quota_token_limit (int64, default 500_000)
- // - free_quota_soft_gate_percent (int, default 95) — stop scheduling before the nominal limit
- // - free_quota_window_hours (int, default 24) — local usage rolling window
- // - free_quota_stats_cache_seconds (int, default 60) — stats cache TTL; hot path never waits on DB
- //
- // Soft-gate applies only to *explicit* free OAuth (subscription_tier/plan_type ==
- // "free"). Media/cache free detection uses isKnownGrokFreeAccount instead.
- // Admin paths (QueryQuota / import probe) never call this filter.
- // Defaults live on config.Gateway.Grok (see config load defaults / tests).
- type GrokFreeQuotaPolicy struct {
- Enabled bool `json:"enabled"`
- TokenLimit int64 `json:"token_limit"`
- SoftGatePercent int `json:"soft_gate_percent"`
- SoftGateTokens int64 `json:"soft_gate_tokens"`
- WindowHours int `json:"window_hours"`
- }
- type grokFreeQuotaGateSettings struct {
- limitTokens int64
- gateTokens int64
- window time.Duration
- cacheTTL time.Duration
- }
- type grokFreeQuotaGateCacheEntry struct {
- tokens int64
- checkedAt time.Time
- known bool
- }
- var grokFreeQuotaGateQueryFailureTotal atomic.Int64
- var grokFreeQuotaGateBlockedTotal atomic.Int64
- func resolveGrokFreeQuotaGateSettings(cfg *config.Config) (grokFreeQuotaGateSettings, bool) {
- if cfg == nil || !cfg.Gateway.Grok.FreeQuotaSoftGateEnabled {
- return grokFreeQuotaGateSettings{}, false
- }
- limit := cfg.Gateway.Grok.FreeQuotaTokenLimit
- percent := cfg.Gateway.Grok.FreeQuotaSoftGatePercent
- windowHours := cfg.Gateway.Grok.FreeQuotaWindowHours
- cacheSeconds := cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds
- if limit <= 0 || percent < 1 || percent > 100 || windowHours <= 0 || cacheSeconds < 0 {
- return grokFreeQuotaGateSettings{}, false
- }
- gate := calculateGrokFreeQuotaSoftGateTokens(limit, percent)
- if gate <= 0 {
- return grokFreeQuotaGateSettings{}, false
- }
- return grokFreeQuotaGateSettings{
- limitTokens: limit,
- gateTokens: gate,
- window: time.Duration(windowHours) * time.Hour,
- cacheTTL: time.Duration(cacheSeconds) * time.Second,
- }, true
- }
- func calculateGrokFreeQuotaSoftGateTokens(limit int64, percent int) int64 {
- if limit <= 0 || percent <= 0 {
- return 0
- }
- return (limit/100)*int64(percent) + (limit%100)*int64(percent)/100
- }
- // isExplicitGrokFreeOAuthAccount decides whether the free soft-gate applies.
- // Contract: only OAuth accounts with credentials/extra
- // subscription_tier or plan_type exactly "free" (case-insensitive). Inferred
- // free / basic / blank plan do not soft-gate.
- func isExplicitGrokFreeOAuthAccount(account *Account) bool {
- if account == nil || !account.IsGrokOAuth() {
- return false
- }
- for _, tier := range []string{
- account.GetCredential("subscription_tier"),
- account.GetCredential("plan_type"),
- account.GetExtraString("subscription_tier"),
- account.GetExtraString("plan_type"),
- } {
- if strings.EqualFold(strings.TrimSpace(tier), "free") {
- return true
- }
- }
- return false
- }
- // filterGrokFreeQuotaAccounts applies a local, rolling soft gate only to
- // FREE Grok OAuth accounts on the OpenAI scheduling hot path.
- // Missing or failed statistics always fail open; upstream quota/rate-limit
- // handling remains authoritative. Admin quota/import probes never call this.
- func (s *defaultOpenAIAccountScheduler) filterGrokFreeQuotaAccounts(ctx context.Context, accounts []Account) []Account {
- if s == nil || s.service == nil {
- return accounts
- }
- return filterGrokFreeQuotaAccountsCore(ctx, s.service.cfg, s.service.usageLogRepo, &s.grokFreeQuotaGateCache, accounts)
- }
- // filterGrokFreeQuotaAccountsForGateway applies the same soft gate on Gateway
- // scheduling (e.g. /v1/web_search) so free accounts near local 95%/1M are not
- // still selected for native search while Responses soft-gates them out.
- func (s *GatewayService) filterGrokFreeQuotaAccountsForGateway(ctx context.Context, accounts []Account) []Account {
- if s == nil {
- return accounts
- }
- return filterGrokFreeQuotaAccountsCore(ctx, s.cfg, s.usageLogRepo, &gatewayGrokFreeQuotaGateCache, accounts)
- }
- // Shared caches for non-advanced-scheduler selection paths.
- // Advanced scheduler keeps per-instance sync.Map on defaultOpenAIAccountScheduler.
- var gatewayGrokFreeQuotaGateCache sync.Map
- var openaiGrokFreeQuotaGateCache sync.Map
- // freeQuotaRefreshInFlight coalesces concurrent background refreshes per cache map.
- var freeQuotaRefreshInFlight sync.Map // *sync.Map -> *sync.Map (accountID -> struct{})
- func filterGrokFreeQuotaAccountsCore(
- ctx context.Context,
- cfg *config.Config,
- usageLogRepo UsageLogRepository,
- cache *sync.Map,
- accounts []Account,
- ) []Account {
- if cache == nil {
- return accounts
- }
- settings, enabled := resolveGrokFreeQuotaGateSettings(cfg)
- if !enabled || len(accounts) == 0 || usageLogRepo == nil {
- return accounts
- }
- now := time.Now().UTC()
- tokensByID := make(map[int64]int64)
- missingIDs := make([]int64, 0, len(accounts))
- seenMissing := make(map[int64]struct{})
- for i := range accounts {
- account := &accounts[i]
- if !isExplicitGrokFreeOAuthAccount(account) || account.ID <= 0 {
- continue
- }
- if cached, ok := cache.Load(account.ID); ok {
- entry, valid := cached.(grokFreeQuotaGateCacheEntry)
- if valid {
- age := now.Sub(entry.checkedAt)
- // cacheTTL == 0 means "no expiry" for known entries (still fail-open
- // on first miss; refresh is only scheduled when missing/stale).
- fresh := settings.cacheTTL <= 0 || (age >= 0 && age < settings.cacheTTL)
- if fresh {
- if entry.known {
- tokensByID[account.ID] = entry.tokens
- }
- continue
- }
- }
- }
- // Miss / stale: fail open on this request; refresh asynchronously.
- if _, exists := seenMissing[account.ID]; !exists {
- seenMissing[account.ID] = struct{}{}
- missingIDs = append(missingIDs, account.ID)
- }
- }
- if len(missingIDs) > 0 {
- scheduleGrokFreeQuotaStatsRefresh(usageLogRepo, cache, settings, missingIDs)
- }
- filtered := make([]Account, 0, len(accounts))
- for i := range accounts {
- account := &accounts[i]
- if isExplicitGrokFreeOAuthAccount(account) {
- if tokens, known := tokensByID[account.ID]; known && tokens >= settings.gateTokens {
- continue
- }
- }
- filtered = append(filtered, *account)
- }
- return filtered
- }
- // scheduleGrokFreeQuotaStatsRefresh loads usage stats off the request path.
- // Concurrent callers for the same accountID are coalesced via in-flight markers.
- func scheduleGrokFreeQuotaStatsRefresh(
- usageLogRepo UsageLogRepository,
- cache *sync.Map,
- settings grokFreeQuotaGateSettings,
- accountIDs []int64,
- ) {
- if usageLogRepo == nil || cache == nil || len(accountIDs) == 0 {
- return
- }
- inFlightRoot, _ := freeQuotaRefreshInFlight.LoadOrStore(cache, &sync.Map{})
- inFlight, ok := inFlightRoot.(*sync.Map)
- if !ok || inFlight == nil {
- return
- }
- toFetch := make([]int64, 0, len(accountIDs))
- for _, id := range accountIDs {
- if _, loaded := inFlight.LoadOrStore(id, struct{}{}); !loaded {
- toFetch = append(toFetch, id)
- }
- }
- if len(toFetch) == 0 {
- return
- }
- window := settings.window
- gateTokens := settings.gateTokens
- limitTokens := settings.limitTokens
- cacheTTL := settings.cacheTTL
- go func() {
- defer func() {
- for _, id := range toFetch {
- inFlight.Delete(id)
- }
- }()
- now := time.Now().UTC()
- statsByID, err := queryGrokFreeQuotaWindowStats(context.Background(), usageLogRepo, toFetch, now.Add(-window))
- if err != nil {
- grokFreeQuotaGateQueryFailureTotal.Add(1)
- // Store a negative entry so subsequent hot-path calls do not thrash.
- // known=false → still fail open until a successful refresh lands.
- for _, accountID := range toFetch {
- cache.Store(accountID, grokFreeQuotaGateCacheEntry{checkedAt: now})
- }
- slog.Warn("grok_free_quota_soft_gate_stats_failed",
- "account_count", len(toFetch),
- "window_hours", window.Hours(),
- "error", err)
- sweepGrokFreeQuotaGateCache(cache, now, cacheTTL)
- return
- }
- for _, accountID := range toFetch {
- tokens := int64(0)
- if stats := statsByID[accountID]; stats != nil && stats.Tokens > 0 {
- tokens = stats.Tokens
- }
- cache.Store(accountID, grokFreeQuotaGateCacheEntry{tokens: tokens, checkedAt: now, known: true})
- if tokens >= gateTokens {
- grokFreeQuotaGateBlockedTotal.Add(1)
- slog.Info("grok_free_quota_soft_gate_blocked",
- "account_id", accountID,
- "tokens", tokens,
- "gate_tokens", gateTokens,
- "limit_tokens", limitTokens,
- "window_hours", window.Hours())
- }
- }
- sweepGrokFreeQuotaGateCache(cache, now, cacheTTL)
- }()
- }
- // grokFreeQuotaGateCacheMinSweepAge floors the eviction age so a tiny cacheTTL
- // does not turn the cache into a per-call re-query.
- const grokFreeQuotaGateCacheMinSweepAge = 5 * time.Minute
- // sweepGrokFreeQuotaGateCache drops entries far past their TTL.
- //
- // Entries are keyed by account ID and only ever overwritten, so an account that
- // stops being scheduled (deleted, or moved off the free tier) would otherwise
- // sit in the map for the process lifetime. A still-live account simply
- // re-populates its entry on the next miss.
- func sweepGrokFreeQuotaGateCache(cache *sync.Map, now time.Time, cacheTTL time.Duration) {
- if cache == nil || cacheTTL <= 0 {
- return
- }
- maxAge := cacheTTL * 20
- if maxAge < grokFreeQuotaGateCacheMinSweepAge {
- maxAge = grokFreeQuotaGateCacheMinSweepAge
- }
- cache.Range(func(key, value any) bool {
- entry, ok := value.(grokFreeQuotaGateCacheEntry)
- if !ok || now.Sub(entry.checkedAt) > maxAge {
- cache.Delete(key)
- }
- return true
- })
- }
- func queryGrokFreeQuotaWindowStats(ctx context.Context, usageLogRepo UsageLogRepository, accountIDs []int64, start time.Time) (map[int64]*usagestats.AccountStats, error) {
- if usageLogRepo == nil {
- return nil, nil
- }
- if batch, ok := usageLogRepo.(accountWindowStatsBatchReader); ok {
- return batch.GetAccountWindowStatsBatch(ctx, accountIDs, start)
- }
- statsByID := make(map[int64]*usagestats.AccountStats, len(accountIDs))
- for _, accountID := range accountIDs {
- stats, err := usageLogRepo.GetAccountWindowStats(ctx, accountID, start)
- if err != nil {
- return nil, err
- }
- statsByID[accountID] = stats
- }
- return statsByID, nil
- }
|