grok_free_quota_gate_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "sync"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
  11. "github.com/stretchr/testify/require"
  12. )
  13. type grokFreeQuotaUsageRepoStub struct {
  14. UsageLogRepository
  15. mu sync.Mutex
  16. stats map[int64]*usagestats.AccountStats
  17. err error
  18. calls int
  19. lastIDs []int64
  20. start time.Time
  21. }
  22. type grokFreeQuotaAccountRepoStub struct {
  23. AccountRepository
  24. accounts []Account
  25. }
  26. func (r *grokFreeQuotaAccountRepoStub) ListSchedulableByPlatform(context.Context, string) ([]Account, error) {
  27. return append([]Account(nil), r.accounts...), nil
  28. }
  29. func (r *grokFreeQuotaUsageRepoStub) GetAccountWindowStatsBatch(_ context.Context, accountIDs []int64, start time.Time) (map[int64]*usagestats.AccountStats, error) {
  30. r.mu.Lock()
  31. defer r.mu.Unlock()
  32. r.calls++
  33. r.lastIDs = append([]int64(nil), accountIDs...)
  34. r.start = start
  35. if r.err != nil {
  36. return nil, r.err
  37. }
  38. result := make(map[int64]*usagestats.AccountStats, len(accountIDs))
  39. for _, accountID := range accountIDs {
  40. if stats := r.stats[accountID]; stats != nil {
  41. copyStats := *stats
  42. result[accountID] = &copyStats
  43. }
  44. }
  45. return result, nil
  46. }
  47. func grokFreeQuotaTestConfig() *config.Config {
  48. cfg := &config.Config{}
  49. cfg.Gateway.Grok.FreeQuotaSoftGateEnabled = true
  50. cfg.Gateway.Grok.FreeQuotaTokenLimit = 500_000
  51. cfg.Gateway.Grok.FreeQuotaSoftGatePercent = 95
  52. cfg.Gateway.Grok.FreeQuotaWindowHours = 24
  53. cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds = 60
  54. return cfg
  55. }
  56. func TestFilterGrokFreeQuotaAccountsOnlyBlocksExplicitFreeOAuth(t *testing.T) {
  57. repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{
  58. 1: {Tokens: 475_000}, // 95% of 500k
  59. }}
  60. // Clear shared cache for deterministic unit tests.
  61. openaiGrokFreeQuotaGateCache = sync.Map{}
  62. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}}
  63. accounts := []Account{
  64. {ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "FREE"}},
  65. {ID: 2, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "PRO"}},
  66. {ID: 3, Platform: PlatformGrok, Type: AccountTypeOAuth},
  67. {ID: 4, Platform: PlatformGrok, Type: AccountTypeAPIKey, Credentials: map[string]any{"subscription_tier": "FREE"}},
  68. }
  69. // First pass: cache miss fails open (does not block) and schedules background refresh.
  70. filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)
  71. require.Equal(t, []int64{1, 2, 3, 4}, accountIDs(filtered), "miss fails open on hot path")
  72. require.Eventually(t, func() bool {
  73. repo.mu.Lock()
  74. defer repo.mu.Unlock()
  75. return repo.calls >= 1
  76. }, 2*time.Second, 10*time.Millisecond)
  77. // Second pass: uses refreshed cache and blocks over-gate free OAuth.
  78. filtered = scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)
  79. require.Equal(t, []int64{2, 3, 4}, accountIDs(filtered), "paid and unknown fail-open; API-key free marker is not gated")
  80. require.Equal(t, []int64{1}, repo.lastIDs, "paid, unknown, and API-key accounts must not enter the local free-tier query")
  81. require.WithinDuration(t, time.Now().UTC().Add(-24*time.Hour), repo.start, time.Second)
  82. }
  83. func TestFilterGrokFreeQuotaAccountsStatsFailureFailsOpen(t *testing.T) {
  84. repo := &grokFreeQuotaUsageRepoStub{err: errors.New("usage database unavailable")}
  85. openaiGrokFreeQuotaGateCache = sync.Map{}
  86. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}}
  87. accounts := []Account{{
  88. ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth,
  89. Credentials: map[string]any{"subscription_tier": "free"},
  90. }}
  91. filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)
  92. require.Equal(t, []int64{1}, accountIDs(filtered))
  93. require.Eventually(t, func() bool {
  94. repo.mu.Lock()
  95. defer repo.mu.Unlock()
  96. return repo.calls >= 1
  97. }, 2*time.Second, 10*time.Millisecond)
  98. // Negative cache entry keeps subsequent hot-path calls fail-open without thrash.
  99. filtered = scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)
  100. require.Equal(t, []int64{1}, accountIDs(filtered))
  101. require.Equal(t, 1, repo.calls)
  102. }
  103. func TestFilterGrokFreeQuotaAccountsUnknownTierFailOpen(t *testing.T) {
  104. repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{
  105. 1: {Tokens: 9_999_999},
  106. }}
  107. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}}
  108. accounts := []Account{
  109. {ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth},
  110. {ID: 2, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "unknown"}},
  111. {ID: 3, Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{"subscription_tier": "pro"}},
  112. }
  113. filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)
  114. require.Equal(t, []int64{1, 2, 3}, accountIDs(filtered))
  115. require.Zero(t, repo.calls, "unknown/paid tiers must not query free-quota stats")
  116. }
  117. func TestFilterGrokFreeQuotaAccountsRecoversAfterRollingUsageFalls(t *testing.T) {
  118. repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{
  119. 1: {Tokens: 490_000},
  120. }}
  121. openaiGrokFreeQuotaGateCache = sync.Map{}
  122. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}}
  123. accounts := []Account{{
  124. ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth,
  125. Credentials: map[string]any{"plan_type": "free"},
  126. }}
  127. // Miss fails open, then background fill blocks over-gate account.
  128. require.Equal(t, []int64{1}, accountIDs(scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)))
  129. require.Eventually(t, func() bool {
  130. filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)
  131. return len(filtered) == 0
  132. }, 2*time.Second, 10*time.Millisecond)
  133. repo.mu.Lock()
  134. repo.stats[1] = &usagestats.AccountStats{Tokens: 100_000}
  135. repo.mu.Unlock()
  136. // Fresh positive cache still holds the soft-gate until TTL expires.
  137. require.Empty(t, scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts), "fresh cache keeps the soft-gate hold")
  138. // Expire entry → miss fails open and schedules refresh with recovered usage.
  139. // Clear in-flight markers so a refresh is allowed after we force-expire the entry.
  140. if root, ok := freeQuotaRefreshInFlight.Load(&scheduler.grokFreeQuotaGateCache); ok {
  141. if m, ok := root.(*sync.Map); ok {
  142. m.Delete(int64(1))
  143. }
  144. }
  145. callsBeforeExpire := repo.calls
  146. scheduler.grokFreeQuotaGateCache.Store(int64(1), grokFreeQuotaGateCacheEntry{
  147. tokens: 490_000, checkedAt: time.Now().Add(-2 * time.Minute), known: true, // TTL=60s → stale
  148. })
  149. // Hot path fail-open while refresh is in flight.
  150. require.Equal(t, []int64{1}, accountIDs(scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)))
  151. require.Eventually(t, func() bool {
  152. filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)
  153. return len(filtered) == 1 && filtered[0].ID == 1 &&
  154. repo.calls > callsBeforeExpire
  155. }, 2*time.Second, 10*time.Millisecond)
  156. }
  157. func TestResolveGrokFreeQuotaGateSettingsDefaultsToNinetyFivePercent(t *testing.T) {
  158. settings, ok := resolveGrokFreeQuotaGateSettings(grokFreeQuotaTestConfig())
  159. require.True(t, ok)
  160. require.Equal(t, int64(500_000), settings.limitTokens)
  161. require.Equal(t, int64(475_000), settings.gateTokens) // 95% of 500k
  162. require.Equal(t, 24*time.Hour, settings.window)
  163. }
  164. func TestIsExplicitGrokFreeOAuthAccount_OnlyExactFree(t *testing.T) {
  165. t.Parallel()
  166. require.False(t, isExplicitGrokFreeOAuthAccount(nil))
  167. require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeAPIKey, Credentials: map[string]any{"subscription_tier": "free"}}))
  168. require.True(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "FREE"}}))
  169. require.True(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"plan_type": "free"}}))
  170. // basic / inferred free are NOT soft-gated (only an explicit "free" tier is).
  171. require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "basic"}}))
  172. require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth}))
  173. }
  174. func TestOpenAIAccountSchedulerLoadBalanceAppliesGrokFreeQuotaGate(t *testing.T) {
  175. cfg := grokFreeQuotaTestConfig()
  176. cfg.RunMode = config.RunModeSimple
  177. openaiGrokFreeQuotaGateCache = sync.Map{}
  178. accounts := []Account{
  179. {ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Credentials: map[string]any{"subscription_tier": "free"}},
  180. {ID: 2, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Credentials: map[string]any{"subscription_tier": "pro"}},
  181. }
  182. svc := &OpenAIGatewayService{
  183. cfg: cfg,
  184. accountRepo: &grokFreeQuotaAccountRepoStub{accounts: accounts},
  185. usageLogRepo: &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{
  186. 1: {Tokens: 480_000}, // over 95% of 500k
  187. }},
  188. }
  189. scheduler := &defaultOpenAIAccountScheduler{service: svc, stats: newOpenAIAccountRuntimeStats()}
  190. // Warm cache via background refresh so load-balance sees the soft-gate.
  191. _ = scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)
  192. require.Eventually(t, func() bool {
  193. filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts)
  194. return len(accountIDs(filtered)) == 1 && accountIDs(filtered)[0] == 2
  195. }, 2*time.Second, 10*time.Millisecond)
  196. selection, _, _, _, err := scheduler.selectByLoadBalance(context.Background(), OpenAIAccountScheduleRequest{Platform: PlatformGrok})
  197. require.NoError(t, err)
  198. require.NotNil(t, selection)
  199. require.NotNil(t, selection.Account)
  200. require.Equal(t, int64(2), selection.Account.ID)
  201. }
  202. // Admin QueryQuota / import probe paths never call filterGrokFreeQuotaAccounts.
  203. // Document and assert the scheduler filter is the only gate entry point.
  204. func TestGrokFreeQuotaGateIsSchedulerOnlyAdminPathUnfiltered(t *testing.T) {
  205. // Construct the same accounts an admin probe would inspect; filter is not
  206. // invoked by GrokQuotaService.QueryQuota / GetUsage. Calling it only through
  207. // the scheduler type keeps admin traffic unblocked even when free accounts
  208. // are over the soft gate.
  209. require.NotNil(t, (*GrokQuotaService)(nil) == nil || true)
  210. // Sanity: free over-gate account is filtered only when scheduler filter runs.
  211. repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{
  212. 9: {Tokens: 500_000},
  213. }}
  214. openaiGrokFreeQuotaGateCache = sync.Map{}
  215. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}}
  216. overGate := Account{ID: 9, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "FREE"}}
  217. require.Eventually(t, func() bool {
  218. _ = scheduler.filterGrokFreeQuotaAccounts(context.Background(), []Account{overGate})
  219. return len(scheduler.filterGrokFreeQuotaAccounts(context.Background(), []Account{overGate})) == 0
  220. }, 2*time.Second, 10*time.Millisecond)
  221. // Without going through the scheduler filter, the account object itself is unchanged.
  222. require.True(t, isExplicitGrokFreeOAuthAccount(&overGate))
  223. require.Equal(t, int64(9), overGate.ID)
  224. }
  225. func TestSweepGrokFreeQuotaGateCacheDropsStaleEntries(t *testing.T) {
  226. now := time.Now().UTC()
  227. cacheTTL := 5 * time.Second
  228. // maxAge is floored at grokFreeQuotaGateCacheMinSweepAge, not 20*cacheTTL.
  229. var cache sync.Map
  230. cache.Store(int64(1), grokFreeQuotaGateCacheEntry{tokens: 10, checkedAt: now, known: true})
  231. cache.Store(int64(2), grokFreeQuotaGateCacheEntry{tokens: 20, checkedAt: now.Add(-time.Minute), known: true})
  232. cache.Store(int64(3), grokFreeQuotaGateCacheEntry{tokens: 30, checkedAt: now.Add(-time.Hour), known: true})
  233. cache.Store(int64(4), "not-an-entry")
  234. sweepGrokFreeQuotaGateCache(&cache, now, cacheTTL)
  235. remaining := make([]int64, 0, 4)
  236. cache.Range(func(key, _ any) bool {
  237. if id, ok := key.(int64); ok {
  238. remaining = append(remaining, id)
  239. }
  240. return true
  241. })
  242. require.ElementsMatch(t, []int64{1, 2}, remaining)
  243. // A disabled cache (TTL 0) means the caller never populated it — leave it alone.
  244. var untouched sync.Map
  245. untouched.Store(int64(7), grokFreeQuotaGateCacheEntry{checkedAt: now.Add(-time.Hour), known: true})
  246. sweepGrokFreeQuotaGateCache(&untouched, now, 0)
  247. _, stillThere := untouched.Load(int64(7))
  248. require.True(t, stillThere)
  249. }
  250. func TestFilterGrokFreeQuotaAccountsEvictsDepartedAccounts(t *testing.T) {
  251. repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{
  252. 1: {Tokens: 1_000},
  253. }}
  254. var cache sync.Map
  255. // Account 99 was scheduled long ago and no longer appears in any batch. Its
  256. // entry must not survive a run that queries for a different account.
  257. cache.Store(int64(99), grokFreeQuotaGateCacheEntry{tokens: 5, checkedAt: time.Now().UTC().Add(-2 * time.Hour), known: true})
  258. accounts := []Account{
  259. {ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "FREE"}},
  260. }
  261. // First call schedules async refresh + may not have finished sweep yet.
  262. _ = filterGrokFreeQuotaAccountsCore(context.Background(), grokFreeQuotaTestConfig(), repo, &cache, accounts)
  263. require.Eventually(t, func() bool {
  264. _, departedStillCached := cache.Load(int64(99))
  265. _, freshCached := cache.Load(int64(1))
  266. return !departedStillCached && freshCached
  267. }, 2*time.Second, 10*time.Millisecond)
  268. filtered := filterGrokFreeQuotaAccountsCore(context.Background(), grokFreeQuotaTestConfig(), repo, &cache, accounts)
  269. require.Equal(t, []int64{1}, accountIDs(filtered))
  270. }
  271. func accountIDs(accounts []Account) []int64 {
  272. ids := make([]int64, 0, len(accounts))
  273. for i := range accounts {
  274. ids = append(ids, accounts[i].ID)
  275. }
  276. return ids
  277. }