Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
312 lines
10 KiB
Go
312 lines
10 KiB
Go
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
|
|
}
|