account_scheduling_threshold_eval_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. //go:build unit
  2. package service
  3. import (
  4. "testing"
  5. "time"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func TestEvaluateAccountSchedulingThreshold_OpenAIChoosesLatestResetWindow(t *testing.T) {
  10. t.Parallel()
  11. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  12. wantUntil := now.Add(72 * time.Hour)
  13. account := &Account{
  14. Platform: PlatformOpenAI,
  15. Extra: map[string]any{
  16. "codex_5h_used_percent": 90.0,
  17. "codex_5h_reset_at": now.Add(2 * time.Hour).Format(time.RFC3339),
  18. "codex_7d_used_percent": 85.0,
  19. "codex_7d_reset_at": wantUntil.Format(time.RFC3339),
  20. },
  21. }
  22. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{
  23. PlatformOpenAI: 80,
  24. }, now)
  25. require.True(t, decision.ShouldPause)
  26. require.Equal(t, PlatformOpenAI, decision.Platform)
  27. require.Equal(t, "7d", decision.Window)
  28. require.Empty(t, decision.Scope)
  29. require.Equal(t, 85.0, decision.UsedPercent)
  30. require.NotNil(t, decision.Until)
  31. require.True(t, wantUntil.Equal(*decision.Until))
  32. }
  33. func TestEvaluateAccountSchedulingThreshold_OpenAIIgnoresMismatchedCodexSnapshotIdentity(t *testing.T) {
  34. t.Parallel()
  35. now := time.Date(2026, 6, 13, 8, 50, 0, 0, time.UTC)
  36. account := &Account{
  37. Platform: PlatformOpenAI,
  38. Type: AccountTypeOAuth,
  39. Credentials: map[string]any{
  40. "email": "CageLeen9208@outlook.com",
  41. "chatgpt_account_id": "1f945aa7-d9a9-4369-9542-0c702ff4adb0",
  42. "workspace_id": "org-nU4goUxMmureroyswT5oYPv4",
  43. "chatgpt_workspace_id": "org-nU4goUxMmureroyswT5oYPv4",
  44. },
  45. Extra: map[string]any{
  46. "email": "MasonDobies01@outlook.com",
  47. "name": "Paul Clark",
  48. "workspace_id": "org-avRk1G4qdXg7qph3cRIraNKf",
  49. "codex_7d_used_percent": 100.0,
  50. "codex_7d_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339),
  51. },
  52. }
  53. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{
  54. PlatformOpenAI: 99,
  55. }, now)
  56. require.False(t, decision.ShouldPause)
  57. }
  58. func TestEvaluateAccountSchedulingThreshold_AnthropicIgnoresExpiredFiveHourWindow(t *testing.T) {
  59. t.Parallel()
  60. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  61. expiredEnd := now.Add(-30 * time.Minute)
  62. wantUntil := now.Add(5 * 24 * time.Hour)
  63. account := &Account{
  64. Platform: PlatformAnthropic,
  65. SessionWindowEnd: &expiredEnd,
  66. Extra: map[string]any{
  67. "session_window_utilization": 0.99,
  68. "passive_usage_7d_utilization": 0.82,
  69. "passive_usage_7d_reset": float64(wantUntil.Unix()),
  70. },
  71. }
  72. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{
  73. PlatformAnthropic: 80,
  74. }, now)
  75. require.True(t, decision.ShouldPause)
  76. require.Equal(t, PlatformAnthropic, decision.Platform)
  77. require.Equal(t, "7d", decision.Window)
  78. require.Empty(t, decision.Scope)
  79. require.Equal(t, 82.0, decision.UsedPercent)
  80. require.NotNil(t, decision.Until)
  81. require.True(t, wantUntil.Equal(*decision.Until))
  82. }
  83. func TestEvaluateAnthropicFableSchedulingThreshold_UsesAccountOverrideWithoutPausingAccount(t *testing.T) {
  84. t.Parallel()
  85. now := time.Date(2026, 8, 29, 1, 0, 0, 0, time.UTC)
  86. wantUntil := now.Add(4 * 24 * time.Hour)
  87. account := &Account{
  88. Platform: PlatformAnthropic,
  89. Credentials: map[string]any{
  90. "account_scheduling_threshold": 60,
  91. },
  92. Extra: map[string]any{
  93. "passive_usage_7d_utilization": 0.40,
  94. "passive_usage_7d_reset": float64(now.Add(3 * 24 * time.Hour).Unix()),
  95. "passive_usage_7d_oi_utilization": 0.61,
  96. "passive_usage_7d_oi_reset": float64(wantUntil.Unix()),
  97. },
  98. }
  99. thresholds := map[string]int{
  100. PlatformAnthropic: 100,
  101. }
  102. accountDecision := EvaluateAccountSchedulingThreshold(account, thresholds, now)
  103. require.False(t, accountDecision.ShouldPause)
  104. decision := evaluateAnthropicFableSchedulingThreshold(account, thresholds, now)
  105. require.True(t, decision.ShouldPause)
  106. require.Equal(t, PlatformAnthropic, decision.Platform)
  107. require.Equal(t, "7d_oi", decision.Window)
  108. require.Equal(t, anthropicFableRateLimitKey, decision.Scope)
  109. require.Equal(t, 60, decision.ThresholdPercent)
  110. require.Equal(t, 61.0, decision.UsedPercent)
  111. require.NotNil(t, decision.Until)
  112. require.True(t, wantUntil.Equal(*decision.Until))
  113. }
  114. func TestEvaluateAccountSchedulingThreshold_OpenAIPreservesPercentageSemantics(t *testing.T) {
  115. t.Parallel()
  116. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  117. openAIUntil := now.Add(24 * time.Hour)
  118. openAIAccount := &Account{
  119. Platform: PlatformOpenAI,
  120. Extra: map[string]any{
  121. "codex_5h_used_percent": 1.0,
  122. "codex_5h_reset_at": openAIUntil.Format(time.RFC3339),
  123. },
  124. }
  125. candidate := openAIThresholdCandidate(openAIAccount.Extra, "5h", now)
  126. require.NotNil(t, candidate)
  127. require.Equal(t, 1.0, candidate.usedPercent)
  128. openAIDecision := EvaluateAccountSchedulingThreshold(openAIAccount, map[string]int{
  129. PlatformOpenAI: 90,
  130. }, now)
  131. require.False(t, openAIDecision.ShouldPause)
  132. openAIAccount.Extra["codex_5h_used_percent"] = 91.0
  133. openAIDecision = EvaluateAccountSchedulingThreshold(openAIAccount, map[string]int{
  134. PlatformOpenAI: 90,
  135. }, now)
  136. require.True(t, openAIDecision.ShouldPause)
  137. require.Equal(t, 91.0, openAIDecision.UsedPercent)
  138. }
  139. func TestEvaluateAccountSchedulingThreshold_OpenAISkipsStaleSnapshot(t *testing.T) {
  140. t.Parallel()
  141. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  142. account := &Account{
  143. Platform: PlatformOpenAI,
  144. Extra: map[string]any{
  145. "codex_usage_updated_at": now.Add(-2 * time.Hour).Format(time.RFC3339),
  146. "codex_5h_used_percent": 100.0,
  147. "codex_5h_reset_at": now.Add(3 * time.Hour).Format(time.RFC3339),
  148. },
  149. }
  150. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now)
  151. require.False(t, decision.ShouldPause)
  152. }
  153. func TestEvaluateAccountSchedulingThreshold_OpenAISkipsResetWindow(t *testing.T) {
  154. t.Parallel()
  155. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  156. account := &Account{
  157. Platform: PlatformOpenAI,
  158. Extra: map[string]any{
  159. "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
  160. "codex_5h_used_percent": 100.0,
  161. "codex_5h_reset_at": now.Add(-time.Second).Format(time.RFC3339),
  162. },
  163. }
  164. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now)
  165. require.False(t, decision.ShouldPause)
  166. }
  167. func TestEvaluateAccountSchedulingThreshold_OpenAIPausesFreshExhaustedSnapshot(t *testing.T) {
  168. t.Parallel()
  169. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  170. resetAt := now.Add(3 * time.Hour)
  171. account := &Account{
  172. Platform: PlatformOpenAI,
  173. Extra: map[string]any{
  174. "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
  175. "codex_5h_used_percent": 100.0,
  176. "codex_5h_reset_at": resetAt.Format(time.RFC3339),
  177. },
  178. }
  179. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now)
  180. require.True(t, decision.ShouldPause)
  181. require.Equal(t, "5h", decision.Window)
  182. require.Equal(t, 100.0, decision.UsedPercent)
  183. require.NotNil(t, decision.Until)
  184. require.True(t, resetAt.Equal(*decision.Until))
  185. }
  186. func TestEvaluateAccountSchedulingThreshold_OpenAIPausesFreshExhaustedSevenDayWindow(t *testing.T) {
  187. t.Parallel()
  188. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  189. resetAt := now.Add(5 * 24 * time.Hour)
  190. account := &Account{
  191. Platform: PlatformOpenAI,
  192. Extra: map[string]any{
  193. "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
  194. "codex_7d_used_percent": 95.0,
  195. "codex_7d_reset_at": resetAt.Format(time.RFC3339),
  196. },
  197. }
  198. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now)
  199. require.True(t, decision.ShouldPause)
  200. require.Equal(t, "7d", decision.Window)
  201. require.Equal(t, 95.0, decision.UsedPercent)
  202. require.NotNil(t, decision.Until)
  203. require.True(t, resetAt.Equal(*decision.Until))
  204. }
  205. func TestEvaluateAccountSchedulingThreshold_AnthropicPreservesFractionalUtilizationSemantics(t *testing.T) {
  206. t.Parallel()
  207. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  208. anthropicUntil := now.Add(5 * time.Hour)
  209. anthropicAccount := &Account{
  210. Platform: PlatformAnthropic,
  211. SessionWindowEnd: &anthropicUntil,
  212. Extra: map[string]any{
  213. "session_window_utilization": 0.92,
  214. },
  215. }
  216. anthropicDecision := EvaluateAccountSchedulingThreshold(anthropicAccount, map[string]int{
  217. PlatformAnthropic: 90,
  218. }, now)
  219. require.True(t, anthropicDecision.ShouldPause)
  220. require.Equal(t, 92.0, anthropicDecision.UsedPercent)
  221. }
  222. func TestEvaluateAccountSchedulingThreshold_AccountOverrideCanLowerOpenAIThreshold(t *testing.T) {
  223. t.Parallel()
  224. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  225. wantUntil := now.Add(12 * time.Hour)
  226. account := &Account{
  227. Platform: PlatformOpenAI,
  228. Credentials: map[string]any{
  229. "account_scheduling_threshold": 80,
  230. },
  231. Extra: map[string]any{
  232. "codex_7d_used_percent": 85.0,
  233. "codex_7d_reset_at": wantUntil.Format(time.RFC3339),
  234. },
  235. }
  236. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{
  237. PlatformOpenAI: 90,
  238. }, now)
  239. require.True(t, decision.ShouldPause)
  240. require.Equal(t, PlatformOpenAI, decision.Platform)
  241. require.Equal(t, 80, decision.ThresholdPercent)
  242. require.Equal(t, "7d", decision.Window)
  243. require.Empty(t, decision.Scope)
  244. require.Equal(t, 85.0, decision.UsedPercent)
  245. require.NotNil(t, decision.Until)
  246. require.True(t, wantUntil.Equal(*decision.Until))
  247. }
  248. func TestEvaluateAccountSchedulingThreshold_AccountOverrideHundredDisablesOpenAI(t *testing.T) {
  249. t.Parallel()
  250. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  251. account := &Account{
  252. Platform: PlatformOpenAI,
  253. Credentials: map[string]any{
  254. "account_scheduling_threshold": 100,
  255. },
  256. Extra: map[string]any{
  257. "codex_7d_used_percent": 99.0,
  258. "codex_7d_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
  259. },
  260. }
  261. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{
  262. PlatformOpenAI: 80,
  263. }, now)
  264. require.False(t, decision.ShouldPause)
  265. require.Equal(t, 100, decision.ThresholdPercent)
  266. }
  267. func TestEvaluateAccountSchedulingThreshold_AccountOverrideRoundsDecimalThreshold(t *testing.T) {
  268. t.Parallel()
  269. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  270. wantUntil := now.Add(12 * time.Hour)
  271. account := &Account{
  272. Platform: PlatformOpenAI,
  273. Credentials: map[string]any{
  274. "account_scheduling_threshold": 75.5,
  275. },
  276. Extra: map[string]any{
  277. "codex_7d_used_percent": 80.0,
  278. "codex_7d_reset_at": wantUntil.Format(time.RFC3339),
  279. },
  280. }
  281. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{
  282. PlatformOpenAI: 90,
  283. }, now)
  284. require.True(t, decision.ShouldPause)
  285. require.Equal(t, 76, decision.ThresholdPercent)
  286. require.Equal(t, 80.0, decision.UsedPercent)
  287. require.NotNil(t, decision.Until)
  288. require.True(t, wantUntil.Equal(*decision.Until))
  289. }
  290. func TestEvaluateAccountSchedulingThreshold_UnsupportedPlatformsDoNotPause(t *testing.T) {
  291. t.Parallel()
  292. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  293. cases := []struct {
  294. name string
  295. platform string
  296. threshold int
  297. extra map[string]any
  298. }{
  299. {
  300. name: "gemini",
  301. platform: PlatformGemini,
  302. threshold: 80,
  303. extra: map[string]any{
  304. "gemini_usage_raw": map[string]any{
  305. "buckets": []any{
  306. map[string]any{
  307. "modelId": "gemini-2.5-pro",
  308. "remainingFraction": 0.05,
  309. "resetTime": now.Add(2 * time.Hour).Format(time.RFC3339),
  310. },
  311. },
  312. },
  313. },
  314. },
  315. {
  316. name: "kiro",
  317. platform: PlatformKiro,
  318. threshold: 90,
  319. extra: map[string]any{
  320. "kiro_sched_utilization": 99.0,
  321. "kiro_sched_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
  322. },
  323. },
  324. {
  325. name: "antigravity",
  326. platform: PlatformAntigravity,
  327. threshold: 90,
  328. extra: map[string]any{
  329. "antigravity_sched_utilization": 92.0,
  330. "antigravity_sched_reset_at": now.Add(48 * time.Hour).Format(time.RFC3339),
  331. "antigravity_sched_scope": "gemini",
  332. },
  333. },
  334. }
  335. for _, tc := range cases {
  336. t.Run(tc.name, func(t *testing.T) {
  337. t.Parallel()
  338. account := &Account{
  339. Platform: tc.platform,
  340. Credentials: map[string]any{
  341. "account_scheduling_threshold": 1,
  342. },
  343. Extra: tc.extra,
  344. }
  345. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{
  346. tc.platform: tc.threshold,
  347. }, now)
  348. require.False(t, decision.ShouldPause)
  349. require.Zero(t, decision.ThresholdPercent)
  350. })
  351. }
  352. }
  353. func TestEvaluateAccountSchedulingThreshold_GrokUsesConfiguredThresholds(t *testing.T) {
  354. t.Parallel()
  355. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  356. wantUntil := now.Add(2 * time.Hour)
  357. account := &Account{
  358. Platform: PlatformGrok,
  359. Extra: map[string]any{
  360. "grok_sched_utilization": 92.0,
  361. "grok_sched_reset_at": wantUntil.Format(time.RFC3339),
  362. },
  363. }
  364. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{
  365. PlatformGrok: 90,
  366. }, now)
  367. require.True(t, decision.ShouldPause)
  368. require.Equal(t, PlatformGrok, decision.Platform)
  369. require.Equal(t, 90, decision.ThresholdPercent)
  370. require.Equal(t, "grok", decision.Scope)
  371. require.Equal(t, 92.0, decision.UsedPercent)
  372. require.NotNil(t, decision.Until)
  373. require.True(t, wantUntil.Equal(*decision.Until))
  374. }
  375. func TestEvaluateAccountSchedulingThreshold_GrokUsesOnlyHeaderQuotaWindow(t *testing.T) {
  376. t.Parallel()
  377. // Billing seven_day/thirty_day must not drive pause; only grok_sched_* may.
  378. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
  379. weeklyEnd := now.Add(3 * time.Hour)
  380. weeklyPct := 99.0
  381. headerUntil := now.Add(2 * time.Hour)
  382. account := &Account{
  383. Platform: PlatformGrok,
  384. Extra: map[string]any{
  385. "grok_sched_utilization": 50.0, // below threshold
  386. "grok_sched_reset_at": headerUntil.Format(time.RFC3339),
  387. grokBillingExtraKey: &xai.BillingSummary{
  388. UsagePercent: &weeklyPct,
  389. PeriodEnd: weeklyEnd.Format(time.RFC3339),
  390. },
  391. },
  392. }
  393. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformGrok: 90}, now)
  394. require.False(t, decision.ShouldPause, "high billing % alone must not pause under scheduling windows")
  395. account.Extra["grok_sched_utilization"] = 95.0
  396. decision = EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformGrok: 90}, now)
  397. require.True(t, decision.ShouldPause)
  398. require.Equal(t, "grok", decision.Scope)
  399. require.Equal(t, "quota", decision.Window)
  400. require.NotNil(t, decision.Until)
  401. require.True(t, headerUntil.Equal(*decision.Until))
  402. }