oc-codex-multi-auth 6.0.0 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +56 -18
  3. package/assets/readme-hero.svg +10 -10
  4. package/config/README.md +1 -1
  5. package/config/minimal-opencode.json +1 -1
  6. package/config/opencode-legacy.json +1 -1
  7. package/config/opencode-modern.json +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +112 -2525
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/accounts/persistence.d.ts +34 -0
  13. package/dist/lib/accounts/persistence.d.ts.map +1 -0
  14. package/dist/lib/accounts/persistence.js +153 -0
  15. package/dist/lib/accounts/persistence.js.map +1 -0
  16. package/dist/lib/accounts/recovery.d.ts +59 -0
  17. package/dist/lib/accounts/recovery.d.ts.map +1 -0
  18. package/dist/lib/accounts/recovery.js +229 -0
  19. package/dist/lib/accounts/recovery.js.map +1 -0
  20. package/dist/lib/accounts/rotation.d.ts +40 -0
  21. package/dist/lib/accounts/rotation.d.ts.map +1 -0
  22. package/dist/lib/accounts/rotation.js +204 -0
  23. package/dist/lib/accounts/rotation.js.map +1 -0
  24. package/dist/lib/accounts/state.d.ts +92 -0
  25. package/dist/lib/accounts/state.d.ts.map +1 -0
  26. package/dist/lib/accounts/state.js +366 -0
  27. package/dist/lib/accounts/state.js.map +1 -0
  28. package/dist/lib/accounts.d.ts +48 -91
  29. package/dist/lib/accounts.d.ts.map +1 -1
  30. package/dist/lib/accounts.js +121 -699
  31. package/dist/lib/accounts.js.map +1 -1
  32. package/dist/lib/auth/auth.d.ts +1 -1
  33. package/dist/lib/auth/auth.d.ts.map +1 -1
  34. package/dist/lib/auth/auth.js +5 -2
  35. package/dist/lib/auth/auth.js.map +1 -1
  36. package/dist/lib/auth/login-runner.d.ts +32 -0
  37. package/dist/lib/auth/login-runner.d.ts.map +1 -1
  38. package/dist/lib/auth/login-runner.js +79 -64
  39. package/dist/lib/auth/login-runner.js.map +1 -1
  40. package/dist/lib/auth/server.d.ts.map +1 -1
  41. package/dist/lib/auth/server.js +3 -8
  42. package/dist/lib/auth/server.js.map +1 -1
  43. package/dist/lib/circuit-breaker.d.ts +53 -2
  44. package/dist/lib/circuit-breaker.d.ts.map +1 -1
  45. package/dist/lib/circuit-breaker.js +65 -6
  46. package/dist/lib/circuit-breaker.js.map +1 -1
  47. package/dist/lib/config.d.ts.map +1 -1
  48. package/dist/lib/config.js +77 -32
  49. package/dist/lib/config.js.map +1 -1
  50. package/dist/lib/error-sentinels.d.ts +20 -0
  51. package/dist/lib/error-sentinels.d.ts.map +1 -0
  52. package/dist/lib/error-sentinels.js +28 -0
  53. package/dist/lib/error-sentinels.js.map +1 -0
  54. package/dist/lib/errors.d.ts +100 -1
  55. package/dist/lib/errors.d.ts.map +1 -1
  56. package/dist/lib/errors.js +116 -1
  57. package/dist/lib/errors.js.map +1 -1
  58. package/dist/lib/logger.d.ts +14 -1
  59. package/dist/lib/logger.d.ts.map +1 -1
  60. package/dist/lib/logger.js +65 -5
  61. package/dist/lib/logger.js.map +1 -1
  62. package/dist/lib/oauth-constants.d.ts +18 -0
  63. package/dist/lib/oauth-constants.d.ts.map +1 -0
  64. package/dist/lib/oauth-constants.js +18 -0
  65. package/dist/lib/oauth-constants.js.map +1 -0
  66. package/dist/lib/oauth-success.d.ts +2 -0
  67. package/dist/lib/oauth-success.d.ts.map +1 -0
  68. package/dist/lib/oauth-success.html +711 -711
  69. package/dist/lib/oauth-success.js +714 -0
  70. package/dist/lib/oauth-success.js.map +1 -0
  71. package/dist/lib/prompts/codex.d.ts.map +1 -1
  72. package/dist/lib/prompts/codex.js +12 -3
  73. package/dist/lib/prompts/codex.js.map +1 -1
  74. package/dist/lib/prompts/opencode-codex.d.ts.map +1 -1
  75. package/dist/lib/prompts/opencode-codex.js +3 -2
  76. package/dist/lib/prompts/opencode-codex.js.map +1 -1
  77. package/dist/lib/recovery/hook.d.ts +43 -0
  78. package/dist/lib/recovery/hook.d.ts.map +1 -0
  79. package/dist/lib/recovery/hook.js +325 -0
  80. package/dist/lib/recovery/hook.js.map +1 -0
  81. package/dist/lib/recovery/index.d.ts +1 -0
  82. package/dist/lib/recovery/index.d.ts.map +1 -1
  83. package/dist/lib/recovery/index.js +1 -0
  84. package/dist/lib/recovery/index.js.map +1 -1
  85. package/dist/lib/recovery/storage.d.ts.map +1 -1
  86. package/dist/lib/recovery/storage.js +5 -1
  87. package/dist/lib/recovery/storage.js.map +1 -1
  88. package/dist/lib/recovery.d.ts +15 -30
  89. package/dist/lib/recovery.d.ts.map +1 -1
  90. package/dist/lib/recovery.js +14 -312
  91. package/dist/lib/recovery.js.map +1 -1
  92. package/dist/lib/request/fetch-helpers.d.ts +1 -0
  93. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  94. package/dist/lib/request/fetch-helpers.js +58 -10
  95. package/dist/lib/request/fetch-helpers.js.map +1 -1
  96. package/dist/lib/request/response-handler.d.ts.map +1 -1
  97. package/dist/lib/request/response-handler.js +8 -2
  98. package/dist/lib/request/response-handler.js.map +1 -1
  99. package/dist/lib/runtime.d.ts +101 -0
  100. package/dist/lib/runtime.d.ts.map +1 -0
  101. package/dist/lib/runtime.js +77 -0
  102. package/dist/lib/runtime.js.map +1 -0
  103. package/dist/lib/schemas.d.ts +164 -1
  104. package/dist/lib/schemas.d.ts.map +1 -1
  105. package/dist/lib/schemas.js +251 -0
  106. package/dist/lib/schemas.js.map +1 -1
  107. package/dist/lib/storage/atomic-write.d.ts +31 -0
  108. package/dist/lib/storage/atomic-write.d.ts.map +1 -0
  109. package/dist/lib/storage/atomic-write.js +75 -0
  110. package/dist/lib/storage/atomic-write.js.map +1 -0
  111. package/dist/lib/storage/backup.d.ts +13 -0
  112. package/dist/lib/storage/backup.d.ts.map +1 -0
  113. package/dist/lib/storage/backup.js +59 -0
  114. package/dist/lib/storage/backup.js.map +1 -0
  115. package/dist/lib/storage/errors.d.ts +17 -0
  116. package/dist/lib/storage/errors.d.ts.map +1 -0
  117. package/dist/lib/storage/errors.js +38 -0
  118. package/dist/lib/storage/errors.js.map +1 -0
  119. package/dist/lib/storage/export-import.d.ts +84 -0
  120. package/dist/lib/storage/export-import.d.ts.map +1 -0
  121. package/dist/lib/storage/export-import.js +222 -0
  122. package/dist/lib/storage/export-import.js.map +1 -0
  123. package/dist/lib/storage/flagged.d.ts +29 -0
  124. package/dist/lib/storage/flagged.d.ts.map +1 -0
  125. package/dist/lib/storage/flagged.js +207 -0
  126. package/dist/lib/storage/flagged.js.map +1 -0
  127. package/dist/lib/storage/identity.d.ts +72 -0
  128. package/dist/lib/storage/identity.d.ts.map +1 -0
  129. package/dist/lib/storage/identity.js +230 -0
  130. package/dist/lib/storage/identity.js.map +1 -0
  131. package/dist/lib/storage/keychain.d.ts +146 -0
  132. package/dist/lib/storage/keychain.d.ts.map +1 -0
  133. package/dist/lib/storage/keychain.js +278 -0
  134. package/dist/lib/storage/keychain.js.map +1 -0
  135. package/dist/lib/storage/load-save.d.ts +61 -0
  136. package/dist/lib/storage/load-save.d.ts.map +1 -0
  137. package/dist/lib/storage/load-save.js +606 -0
  138. package/dist/lib/storage/load-save.js.map +1 -0
  139. package/dist/lib/storage/migrations.d.ts +35 -0
  140. package/dist/lib/storage/migrations.d.ts.map +1 -1
  141. package/dist/lib/storage/migrations.js +37 -0
  142. package/dist/lib/storage/migrations.js.map +1 -1
  143. package/dist/lib/storage/normalize.d.ts +22 -0
  144. package/dist/lib/storage/normalize.d.ts.map +1 -0
  145. package/dist/lib/storage/normalize.js +114 -0
  146. package/dist/lib/storage/normalize.js.map +1 -0
  147. package/dist/lib/storage/paths.d.ts.map +1 -1
  148. package/dist/lib/storage/paths.js +2 -1
  149. package/dist/lib/storage/paths.js.map +1 -1
  150. package/dist/lib/storage/state.d.ts +46 -0
  151. package/dist/lib/storage/state.d.ts.map +1 -0
  152. package/dist/lib/storage/state.js +100 -0
  153. package/dist/lib/storage/state.js.map +1 -0
  154. package/dist/lib/storage/worktree-lock.d.ts +79 -0
  155. package/dist/lib/storage/worktree-lock.d.ts.map +1 -0
  156. package/dist/lib/storage/worktree-lock.js +265 -0
  157. package/dist/lib/storage/worktree-lock.js.map +1 -0
  158. package/dist/lib/storage.d.ts +21 -155
  159. package/dist/lib/storage.d.ts.map +1 -1
  160. package/dist/lib/storage.js +24 -1167
  161. package/dist/lib/storage.js.map +1 -1
  162. package/dist/lib/tools/_shared.d.ts +16 -0
  163. package/dist/lib/tools/_shared.d.ts.map +1 -0
  164. package/dist/lib/tools/_shared.js +21 -0
  165. package/dist/lib/tools/_shared.js.map +1 -0
  166. package/dist/lib/tools/codex-dashboard.d.ts +8 -0
  167. package/dist/lib/tools/codex-dashboard.d.ts.map +1 -0
  168. package/dist/lib/tools/codex-dashboard.js +176 -0
  169. package/dist/lib/tools/codex-dashboard.js.map +1 -0
  170. package/dist/lib/tools/codex-diag.d.ts +19 -0
  171. package/dist/lib/tools/codex-diag.d.ts.map +1 -0
  172. package/dist/lib/tools/codex-diag.js +139 -0
  173. package/dist/lib/tools/codex-diag.js.map +1 -0
  174. package/dist/lib/tools/codex-diff.d.ts +44 -0
  175. package/dist/lib/tools/codex-diff.d.ts.map +1 -0
  176. package/dist/lib/tools/codex-diff.js +284 -0
  177. package/dist/lib/tools/codex-diff.js.map +1 -0
  178. package/dist/lib/tools/codex-doctor.d.ts +8 -0
  179. package/dist/lib/tools/codex-doctor.d.ts.map +1 -0
  180. package/dist/lib/tools/codex-doctor.js +284 -0
  181. package/dist/lib/tools/codex-doctor.js.map +1 -0
  182. package/dist/lib/tools/codex-export.d.ts +8 -0
  183. package/dist/lib/tools/codex-export.d.ts.map +1 -0
  184. package/dist/lib/tools/codex-export.js +63 -0
  185. package/dist/lib/tools/codex-export.js.map +1 -0
  186. package/dist/lib/tools/codex-health.d.ts +8 -0
  187. package/dist/lib/tools/codex-health.d.ts.map +1 -0
  188. package/dist/lib/tools/codex-health.js +124 -0
  189. package/dist/lib/tools/codex-health.js.map +1 -0
  190. package/dist/lib/tools/codex-help.d.ts +8 -0
  191. package/dist/lib/tools/codex-help.d.ts.map +1 -0
  192. package/dist/lib/tools/codex-help.js +127 -0
  193. package/dist/lib/tools/codex-help.js.map +1 -0
  194. package/dist/lib/tools/codex-import.d.ts +8 -0
  195. package/dist/lib/tools/codex-import.d.ts.map +1 -0
  196. package/dist/lib/tools/codex-import.js +97 -0
  197. package/dist/lib/tools/codex-import.js.map +1 -0
  198. package/dist/lib/tools/codex-keychain.d.ts +36 -0
  199. package/dist/lib/tools/codex-keychain.d.ts.map +1 -0
  200. package/dist/lib/tools/codex-keychain.js +289 -0
  201. package/dist/lib/tools/codex-keychain.js.map +1 -0
  202. package/dist/lib/tools/codex-label.d.ts +8 -0
  203. package/dist/lib/tools/codex-label.d.ts.map +1 -0
  204. package/dist/lib/tools/codex-label.js +143 -0
  205. package/dist/lib/tools/codex-label.js.map +1 -0
  206. package/dist/lib/tools/codex-limits.d.ts +8 -0
  207. package/dist/lib/tools/codex-limits.d.ts.map +1 -0
  208. package/dist/lib/tools/codex-limits.js +497 -0
  209. package/dist/lib/tools/codex-limits.js.map +1 -0
  210. package/dist/lib/tools/codex-list.d.ts +8 -0
  211. package/dist/lib/tools/codex-list.d.ts.map +1 -0
  212. package/dist/lib/tools/codex-list.js +260 -0
  213. package/dist/lib/tools/codex-list.js.map +1 -0
  214. package/dist/lib/tools/codex-metrics.d.ts +8 -0
  215. package/dist/lib/tools/codex-metrics.d.ts.map +1 -0
  216. package/dist/lib/tools/codex-metrics.js +180 -0
  217. package/dist/lib/tools/codex-metrics.js.map +1 -0
  218. package/dist/lib/tools/codex-next.d.ts +8 -0
  219. package/dist/lib/tools/codex-next.d.ts.map +1 -0
  220. package/dist/lib/tools/codex-next.js +54 -0
  221. package/dist/lib/tools/codex-next.js.map +1 -0
  222. package/dist/lib/tools/codex-note.d.ts +8 -0
  223. package/dist/lib/tools/codex-note.d.ts.map +1 -0
  224. package/dist/lib/tools/codex-note.js +79 -0
  225. package/dist/lib/tools/codex-note.js.map +1 -0
  226. package/dist/lib/tools/codex-refresh.d.ts +8 -0
  227. package/dist/lib/tools/codex-refresh.d.ts.map +1 -0
  228. package/dist/lib/tools/codex-refresh.js +78 -0
  229. package/dist/lib/tools/codex-refresh.js.map +1 -0
  230. package/dist/lib/tools/codex-remove.d.ts +8 -0
  231. package/dist/lib/tools/codex-remove.d.ts.map +1 -0
  232. package/dist/lib/tools/codex-remove.js +184 -0
  233. package/dist/lib/tools/codex-remove.js.map +1 -0
  234. package/dist/lib/tools/codex-setup.d.ts +8 -0
  235. package/dist/lib/tools/codex-setup.d.ts.map +1 -0
  236. package/dist/lib/tools/codex-setup.js +26 -0
  237. package/dist/lib/tools/codex-setup.js.map +1 -0
  238. package/dist/lib/tools/codex-status.d.ts +8 -0
  239. package/dist/lib/tools/codex-status.d.ts.map +1 -0
  240. package/dist/lib/tools/codex-status.js +263 -0
  241. package/dist/lib/tools/codex-status.js.map +1 -0
  242. package/dist/lib/tools/codex-switch.d.ts +8 -0
  243. package/dist/lib/tools/codex-switch.d.ts.map +1 -0
  244. package/dist/lib/tools/codex-switch.js +122 -0
  245. package/dist/lib/tools/codex-switch.js.map +1 -0
  246. package/dist/lib/tools/codex-tag.d.ts +8 -0
  247. package/dist/lib/tools/codex-tag.d.ts.map +1 -0
  248. package/dist/lib/tools/codex-tag.js +101 -0
  249. package/dist/lib/tools/codex-tag.js.map +1 -0
  250. package/dist/lib/tools/index.d.ts +143 -0
  251. package/dist/lib/tools/index.d.ts.map +1 -0
  252. package/dist/lib/tools/index.js +67 -0
  253. package/dist/lib/tools/index.js.map +1 -0
  254. package/dist/lib/ui/format.d.ts.map +1 -1
  255. package/dist/lib/ui/format.js +5 -0
  256. package/dist/lib/ui/format.js.map +1 -1
  257. package/dist/lib/ui/runtime.d.ts +10 -0
  258. package/dist/lib/ui/runtime.d.ts.map +1 -1
  259. package/dist/lib/ui/runtime.js +16 -11
  260. package/dist/lib/ui/runtime.js.map +1 -1
  261. package/dist/lib/ui/select.d.ts.map +1 -1
  262. package/dist/lib/ui/select.js +12 -3
  263. package/dist/lib/ui/select.js.map +1 -1
  264. package/dist/lib/ui/theme.d.ts +19 -0
  265. package/dist/lib/ui/theme.d.ts.map +1 -1
  266. package/dist/lib/ui/theme.js +27 -0
  267. package/dist/lib/ui/theme.js.map +1 -1
  268. package/package.json +24 -16
  269. package/scripts/copy-oauth-success.js +16 -6
  270. package/scripts/install-oc-codex-multi-auth-core.js +462 -381
  271. package/scripts/install-oc-codex-multi-auth.js +15 -15
  272. package/scripts/test-all-models.sh +1 -1
  273. package/dist/lib/audit.d.ts +0 -45
  274. package/dist/lib/audit.d.ts.map +0 -1
  275. package/dist/lib/audit.js +0 -154
  276. package/dist/lib/audit.js.map +0 -1
  277. package/dist/lib/auth-rate-limit.d.ts +0 -20
  278. package/dist/lib/auth-rate-limit.d.ts.map +0 -1
  279. package/dist/lib/auth-rate-limit.js +0 -91
  280. package/dist/lib/auth-rate-limit.js.map +0 -1
  281. package/dist/lib/runtime-contracts.d.ts +0 -16
  282. package/dist/lib/runtime-contracts.d.ts.map +0 -1
  283. package/dist/lib/runtime-contracts.js +0 -24
  284. package/dist/lib/runtime-contracts.js.map +0 -1
@@ -1,771 +1,193 @@
1
- import { existsSync, promises as fs } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
- import { createLogger } from "./logger.js";
5
- import { loadAccounts, saveAccounts, } from "./storage.js";
6
- import { MODEL_FAMILIES } from "./prompts/codex.js";
7
- import { getHealthTracker, getTokenTracker, selectHybridAccount, } from "./rotation.js";
8
- import { isRecord, nowMs } from "./utils.js";
9
- import { decodeJWT } from "./auth/auth.js";
1
+ /**
2
+ * AccountManager — slim orchestrator that composes four domain services:
3
+ *
4
+ * - {@link AccountState} — in-memory account registry + per-family indices
5
+ * - {@link AccountPersistence} — debounced disk saves, shutdown-flush lifecycle
6
+ * - {@link AccountRotation} — selection, rate-limit and cooldown management
7
+ * - {@link AccountRecovery} — auth-failure tracking, Codex CLI hydration,
8
+ * merge-safe removal
9
+ *
10
+ * This module preserves the public API that existed before the RC-7 split so
11
+ * that `new AccountManager(...)` and every instance method keeps working for
12
+ * external callers (`index.ts`, `lib/tools/*`, `lib/parallel-probe.ts`, etc.).
13
+ */
14
+ import { loadAccounts } from "./storage.js";
15
+ import { AccountPersistence, } from "./accounts/persistence.js";
16
+ import { AccountRecovery } from "./accounts/recovery.js";
17
+ import { AccountRotation } from "./accounts/rotation.js";
18
+ import { AccountState, } from "./accounts/state.js";
19
+ import { formatWaitTime } from "./accounts/rate-limits.js";
20
+ import { nowMs } from "./utils.js";
10
21
  export { extractAccountId, extractAccountEmail, getAccountIdCandidates, selectBestAccountCandidate, shouldUpdateAccountIdFromToken, resolveRequestAccountId, sanitizeEmail, } from "./auth/token-utils.js";
11
22
  export { parseRateLimitReason, getQuotaKey, clampNonNegativeInt, clearExpiredRateLimits, isRateLimitedForQuotaKey, isRateLimitedForFamily, formatWaitTime, } from "./accounts/rate-limits.js";
12
- import { extractAccountId, extractAccountEmail, shouldUpdateAccountIdFromToken, sanitizeEmail, } from "./auth/token-utils.js";
13
- import { clampNonNegativeInt, getQuotaKey, clearExpiredRateLimits, isRateLimitedForFamily, formatWaitTime, } from "./accounts/rate-limits.js";
14
- const log = createLogger("accounts");
15
- const CODEX_CLI_ACCOUNTS_PATH = join(homedir(), ".codex", "accounts.json");
16
- const CODEX_CLI_CACHE_TTL_MS = 5_000;
17
- let codexCliTokenCache = null;
18
- let codexCliTokenCacheLoadedAt = 0;
19
- function extractExpiresAtFromAccessToken(accessToken) {
20
- const decoded = decodeJWT(accessToken);
21
- const exp = decoded?.exp;
22
- if (typeof exp === "number" && Number.isFinite(exp)) {
23
- // JWT exp is in seconds since epoch.
24
- return exp * 1000;
25
- }
26
- return undefined;
27
- }
28
- async function getCodexCliTokenCache() {
29
- const syncEnabled = process.env.CODEX_AUTH_SYNC_CODEX_CLI !== "0";
30
- const skip = !syncEnabled ||
31
- process.env.VITEST_WORKER_ID !== undefined ||
32
- process.env.NODE_ENV === "test";
33
- if (skip)
34
- return null;
35
- const now = nowMs();
36
- if (codexCliTokenCache && now - codexCliTokenCacheLoadedAt < CODEX_CLI_CACHE_TTL_MS) {
37
- return codexCliTokenCache;
38
- }
39
- codexCliTokenCacheLoadedAt = now;
40
- if (!existsSync(CODEX_CLI_ACCOUNTS_PATH)) {
41
- codexCliTokenCache = null;
42
- return null;
23
+ export { lookupCodexCliTokensByEmail, } from "./accounts/recovery.js";
24
+ export class AccountManager {
25
+ state;
26
+ persistence;
27
+ rotation;
28
+ recovery;
29
+ /**
30
+ * Alias to `state.authFailuresByRefreshToken`. Preserved as a property on
31
+ * the orchestrator so that pre-split tests and diagnostics that reach in
32
+ * via `Reflect.get(manager, "authFailuresByRefreshToken")` continue to see
33
+ * the live map without edits. Internal use only.
34
+ */
35
+ get authFailuresByRefreshToken() {
36
+ return this.state.authFailuresByRefreshToken;
43
37
  }
44
- try {
45
- const raw = await fs.readFile(CODEX_CLI_ACCOUNTS_PATH, "utf-8");
46
- const parsed = JSON.parse(raw);
47
- if (!isRecord(parsed) || !Array.isArray(parsed.accounts)) {
48
- codexCliTokenCache = null;
49
- return null;
50
- }
51
- const next = new Map();
52
- for (const entry of parsed.accounts) {
53
- if (!isRecord(entry))
54
- continue;
55
- const email = sanitizeEmail(typeof entry.email === "string" ? entry.email : undefined);
56
- if (!email)
57
- continue;
58
- const accountId = typeof entry.accountId === "string" && entry.accountId.trim() ? entry.accountId.trim() : undefined;
59
- const auth = entry.auth;
60
- const tokens = isRecord(auth) ? auth.tokens : undefined;
61
- const accessToken = isRecord(tokens) && typeof tokens.access_token === "string" && tokens.access_token.trim()
62
- ? tokens.access_token.trim()
63
- : undefined;
64
- const refreshToken = isRecord(tokens) && typeof tokens.refresh_token === "string" && tokens.refresh_token.trim()
65
- ? tokens.refresh_token.trim()
66
- : undefined;
67
- if (!accessToken)
68
- continue;
69
- next.set(email, {
70
- accessToken,
71
- expiresAt: extractExpiresAtFromAccessToken(accessToken),
72
- refreshToken,
73
- accountId,
74
- });
75
- }
76
- codexCliTokenCache = next;
77
- return codexCliTokenCache;
78
- }
79
- catch (error) {
80
- log.debug("Failed to read Codex CLI accounts cache", { error: String(error) });
81
- codexCliTokenCache = null;
82
- return null;
38
+ constructor(authFallback, stored) {
39
+ this.state = new AccountState();
40
+ this.persistence = new AccountPersistence(this.state);
41
+ this.rotation = new AccountRotation(this.state);
42
+ this.recovery = new AccountRecovery(this.state, this.persistence);
43
+ this.state.initializeFromStorage(authFallback, stored);
83
44
  }
84
- }
85
- export async function lookupCodexCliTokensByEmail(email) {
86
- const normalized = sanitizeEmail(email);
87
- if (!normalized)
88
- return null;
89
- const cache = await getCodexCliTokenCache();
90
- const cached = cache?.get(normalized);
91
- return cached ? { ...cached } : null;
92
- }
93
- function initFamilyState(defaultValue) {
94
- return Object.fromEntries(MODEL_FAMILIES.map((family) => [family, defaultValue]));
95
- }
96
- export class AccountManager {
97
- accounts = [];
98
- cursorByFamily = initFamilyState(0);
99
- currentAccountIndexByFamily = initFamilyState(-1);
100
- lastToastAccountIndex = -1;
101
- lastToastTime = 0;
102
- saveDebounceTimer = null;
103
- pendingSave = null;
104
- authFailuresByRefreshToken = new Map();
105
45
  static async loadFromDisk(authFallback) {
106
46
  const stored = await loadAccounts();
107
47
  const manager = new AccountManager(authFallback, stored);
108
- await manager.hydrateFromCodexCli();
48
+ await manager.recovery.hydrateFromCodexCli();
109
49
  return manager;
110
50
  }
51
+ // ----- state delegations -----
111
52
  hasRefreshToken(refreshToken) {
112
- return this.accounts.some((account) => account.refreshToken === refreshToken);
113
- }
114
- async hydrateFromCodexCli() {
115
- const cache = await getCodexCliTokenCache();
116
- if (!cache || cache.size === 0)
117
- return;
118
- const now = nowMs();
119
- let changed = false;
120
- for (const account of this.accounts) {
121
- const email = sanitizeEmail(account.email);
122
- if (!email)
123
- continue;
124
- const cached = cache.get(email);
125
- if (!cached)
126
- continue;
127
- if (typeof cached.expiresAt === "number" && cached.expiresAt <= now) {
128
- continue;
129
- }
130
- const missingOrExpired = !account.access || account.expires === undefined || account.expires <= now;
131
- if (missingOrExpired) {
132
- account.access = cached.accessToken;
133
- if (typeof cached.expiresAt === "number") {
134
- account.expires = cached.expiresAt;
135
- }
136
- changed = true;
137
- }
138
- if (!account.accountId &&
139
- cached.accountId &&
140
- shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId)) {
141
- account.accountId = cached.accountId;
142
- account.accountIdSource = account.accountIdSource ?? "token";
143
- changed = true;
144
- }
145
- }
146
- if (!changed)
147
- return;
148
- try {
149
- await this.saveToDisk();
150
- }
151
- catch (error) {
152
- log.debug("Failed to persist Codex CLI cache hydration", { error: String(error) });
153
- }
154
- }
155
- constructor(authFallback, stored) {
156
- const fallbackAccountId = extractAccountId(authFallback?.access);
157
- const fallbackAccountEmail = sanitizeEmail(extractAccountEmail(authFallback?.access));
158
- if (stored && stored.accounts.length > 0) {
159
- const baseNow = nowMs();
160
- this.accounts = stored.accounts
161
- .map((account, index) => {
162
- if (!account.refreshToken || typeof account.refreshToken !== "string") {
163
- return null;
164
- }
165
- const matchesFallback = !!authFallback &&
166
- ((fallbackAccountId && account.accountId === fallbackAccountId) ||
167
- account.refreshToken === authFallback.refresh ||
168
- (!!fallbackAccountEmail &&
169
- sanitizeEmail(account.email) === fallbackAccountEmail));
170
- const refreshToken = matchesFallback && authFallback ? authFallback.refresh : account.refreshToken;
171
- return {
172
- index,
173
- accountId: matchesFallback ? fallbackAccountId ?? account.accountId : account.accountId,
174
- organizationId: account.organizationId,
175
- accountIdSource: account.accountIdSource,
176
- accountLabel: account.accountLabel,
177
- accountTags: account.accountTags,
178
- accountNote: account.accountNote,
179
- email: matchesFallback
180
- ? fallbackAccountEmail ?? sanitizeEmail(account.email)
181
- : sanitizeEmail(account.email),
182
- refreshToken,
183
- enabled: account.enabled !== false,
184
- access: matchesFallback && authFallback ? authFallback.access : account.accessToken,
185
- expires: matchesFallback && authFallback ? authFallback.expires : account.expiresAt,
186
- addedAt: clampNonNegativeInt(account.addedAt, baseNow),
187
- lastUsed: clampNonNegativeInt(account.lastUsed, 0),
188
- lastSwitchReason: account.lastSwitchReason,
189
- rateLimitResetTimes: account.rateLimitResetTimes ?? {},
190
- coolingDownUntil: account.coolingDownUntil,
191
- cooldownReason: account.cooldownReason,
192
- };
193
- })
194
- .filter((account) => account !== null);
195
- const hasMatchingFallback = !!authFallback &&
196
- this.accounts.some((account) => account.refreshToken === authFallback.refresh ||
197
- (fallbackAccountId && account.accountId === fallbackAccountId) ||
198
- (!!fallbackAccountEmail && account.email === fallbackAccountEmail));
199
- if (authFallback && !hasMatchingFallback) {
200
- const now = nowMs();
201
- this.accounts.push({
202
- index: this.accounts.length,
203
- accountId: fallbackAccountId,
204
- organizationId: undefined,
205
- accountIdSource: fallbackAccountId ? "token" : undefined,
206
- email: fallbackAccountEmail,
207
- refreshToken: authFallback.refresh,
208
- enabled: true,
209
- access: authFallback.access,
210
- expires: authFallback.expires,
211
- addedAt: now,
212
- lastUsed: now,
213
- lastSwitchReason: "initial",
214
- rateLimitResetTimes: {},
215
- });
216
- }
217
- if (this.accounts.length > 0) {
218
- const defaultIndex = clampNonNegativeInt(stored.activeIndex, 0) % this.accounts.length;
219
- for (const family of MODEL_FAMILIES) {
220
- const rawIndex = stored.activeIndexByFamily?.[family];
221
- const nextIndex = clampNonNegativeInt(rawIndex, defaultIndex) % this.accounts.length;
222
- this.currentAccountIndexByFamily[family] = nextIndex;
223
- this.cursorByFamily[family] = nextIndex;
224
- }
225
- }
226
- return;
227
- }
228
- if (authFallback) {
229
- const now = nowMs();
230
- this.accounts = [
231
- {
232
- index: 0,
233
- accountId: fallbackAccountId,
234
- organizationId: undefined,
235
- accountIdSource: fallbackAccountId ? "token" : undefined,
236
- email: fallbackAccountEmail,
237
- refreshToken: authFallback.refresh,
238
- enabled: true,
239
- access: authFallback.access,
240
- expires: authFallback.expires,
241
- addedAt: now,
242
- lastUsed: 0,
243
- lastSwitchReason: "initial",
244
- rateLimitResetTimes: {},
245
- },
246
- ];
247
- for (const family of MODEL_FAMILIES) {
248
- this.currentAccountIndexByFamily[family] = 0;
249
- this.cursorByFamily[family] = 0;
250
- }
251
- }
53
+ return this.state.hasRefreshToken(refreshToken);
252
54
  }
253
55
  getAccountCount() {
254
- return this.accounts.length;
56
+ return this.state.getAccountCount();
255
57
  }
256
58
  getActiveIndex() {
257
- return this.getActiveIndexForFamily("codex");
59
+ return this.state.getActiveIndexForFamily("codex");
258
60
  }
259
61
  getActiveIndexForFamily(family) {
260
- const index = this.currentAccountIndexByFamily[family];
261
- if (index < 0 || index >= this.accounts.length) {
262
- return this.accounts.length > 0 ? 0 : -1;
263
- }
264
- return index;
62
+ return this.state.getActiveIndexForFamily(family);
265
63
  }
266
64
  getAccountsSnapshot() {
267
- return this.accounts.map((account) => ({
268
- ...account,
269
- rateLimitResetTimes: { ...account.rateLimitResetTimes },
270
- }));
65
+ return this.state.getAccountsSnapshot();
271
66
  }
272
67
  getSelectionExplainability(family, model, now = nowMs()) {
273
- const quotaKey = model ? `${family}:${model}` : family;
274
- const baseQuotaKey = getQuotaKey(family);
275
- const modelQuotaKey = model ? getQuotaKey(family, model) : null;
276
- const currentIndex = this.currentAccountIndexByFamily[family];
277
- const healthTracker = getHealthTracker();
278
- const tokenTracker = getTokenTracker();
279
- return this.accounts.map((account) => {
280
- clearExpiredRateLimits(account);
281
- const enabled = account.enabled !== false;
282
- const reasons = [];
283
- let rateLimitedUntil;
284
- const baseRateLimit = account.rateLimitResetTimes[baseQuotaKey];
285
- const modelRateLimit = modelQuotaKey ? account.rateLimitResetTimes[modelQuotaKey] : undefined;
286
- if (typeof baseRateLimit === "number" && baseRateLimit > now) {
287
- rateLimitedUntil = baseRateLimit;
288
- }
289
- if (typeof modelRateLimit === "number" &&
290
- modelRateLimit > now &&
291
- (rateLimitedUntil === undefined || modelRateLimit > rateLimitedUntil)) {
292
- rateLimitedUntil = modelRateLimit;
293
- }
294
- const coolingDownUntil = typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now
295
- ? account.coolingDownUntil
296
- : undefined;
297
- if (!enabled)
298
- reasons.push("disabled");
299
- if (rateLimitedUntil !== undefined)
300
- reasons.push("rate-limited");
301
- if (coolingDownUntil !== undefined) {
302
- reasons.push(account.cooldownReason ? `cooldown:${account.cooldownReason}` : "cooldown");
303
- }
304
- const tokensAvailable = tokenTracker.getTokens(account.index, quotaKey);
305
- if (tokensAvailable < 1)
306
- reasons.push("token-bucket-empty");
307
- const eligible = enabled &&
308
- rateLimitedUntil === undefined &&
309
- coolingDownUntil === undefined &&
310
- tokensAvailable >= 1;
311
- if (reasons.length === 0)
312
- reasons.push("eligible");
313
- return {
314
- index: account.index,
315
- enabled,
316
- isCurrentForFamily: currentIndex === account.index,
317
- eligible,
318
- reasons,
319
- healthScore: healthTracker.getScore(account.index, quotaKey),
320
- tokensAvailable,
321
- rateLimitedUntil,
322
- coolingDownUntil,
323
- cooldownReason: coolingDownUntil !== undefined ? account.cooldownReason : undefined,
324
- lastUsed: account.lastUsed,
325
- };
326
- });
68
+ return this.state.getSelectionExplainability(family, model, now);
327
69
  }
328
70
  setActiveIndex(index) {
329
- if (!Number.isFinite(index))
330
- return null;
331
- if (index < 0 || index >= this.accounts.length)
332
- return null;
333
- const account = this.accounts[index];
334
- if (!account)
335
- return null;
336
- if (account.enabled === false)
337
- return null;
338
- for (const family of MODEL_FAMILIES) {
339
- this.currentAccountIndexByFamily[family] = index;
340
- this.cursorByFamily[family] = index;
341
- }
342
- account.lastUsed = nowMs();
343
- account.lastSwitchReason = "rotation";
344
- return account;
71
+ return this.state.setActiveIndex(index);
345
72
  }
346
73
  getCurrentAccount() {
347
- return this.getCurrentAccountForFamily("codex");
74
+ return this.state.getCurrentAccountForFamily("codex");
348
75
  }
349
76
  getCurrentAccountForFamily(family) {
350
- const index = this.currentAccountIndexByFamily[family];
351
- if (index < 0 || index >= this.accounts.length) {
352
- return null;
353
- }
354
- const account = this.accounts[index];
355
- if (!account || account.enabled === false) {
356
- return null;
357
- }
358
- return account;
77
+ return this.state.getCurrentAccountForFamily(family);
78
+ }
79
+ shouldShowAccountToast(accountIndex, debounceMs = 30000) {
80
+ return this.state.shouldShowAccountToast(accountIndex, debounceMs);
81
+ }
82
+ markToastShown(accountIndex) {
83
+ this.state.markToastShown(accountIndex);
84
+ }
85
+ updateFromAuth(account, auth) {
86
+ this.state.updateFromAuth(account, auth);
87
+ }
88
+ toAuthDetails(account) {
89
+ return this.state.toAuthDetails(account);
359
90
  }
91
+ markSwitched(account, reason, family) {
92
+ this.state.markSwitched(account, reason, family);
93
+ }
94
+ removeAccount(account) {
95
+ return this.state.removeAccount(account);
96
+ }
97
+ removeAccountByIndex(index) {
98
+ return this.state.removeAccountByIndex(index);
99
+ }
100
+ setAccountEnabled(index, enabled) {
101
+ return this.state.setAccountEnabled(index, enabled);
102
+ }
103
+ isAccountCoolingDown(account) {
104
+ return this.state.isAccountCoolingDown(account);
105
+ }
106
+ clearAccountCooldown(account) {
107
+ this.state.clearAccountCooldown(account);
108
+ }
109
+ // ----- rotation delegations -----
360
110
  getCurrentOrNext() {
361
- return this.getCurrentOrNextForFamily("codex");
111
+ return this.rotation.getCurrentOrNextForFamily("codex");
362
112
  }
363
113
  getCurrentOrNextForFamily(family, model) {
364
- const count = this.accounts.length;
365
- if (count === 0)
366
- return null;
367
- const cursor = this.cursorByFamily[family];
368
- for (let i = 0; i < count; i++) {
369
- const idx = (cursor + i) % count;
370
- const account = this.accounts[idx];
371
- if (!account)
372
- continue;
373
- if (account.enabled === false)
374
- continue;
375
- clearExpiredRateLimits(account);
376
- if (isRateLimitedForFamily(account, family, model) || this.isAccountCoolingDown(account)) {
377
- continue;
378
- }
379
- this.cursorByFamily[family] = (idx + 1) % count;
380
- this.currentAccountIndexByFamily[family] = idx;
381
- account.lastUsed = nowMs();
382
- return account;
383
- }
384
- return null;
114
+ return this.rotation.getCurrentOrNextForFamily(family, model);
385
115
  }
386
116
  getNextForFamily(family, model) {
387
- const count = this.accounts.length;
388
- if (count === 0)
389
- return null;
390
- const cursor = this.cursorByFamily[family];
391
- for (let i = 0; i < count; i++) {
392
- const idx = (cursor + i) % count;
393
- const account = this.accounts[idx];
394
- if (!account)
395
- continue;
396
- if (account.enabled === false)
397
- continue;
398
- clearExpiredRateLimits(account);
399
- if (isRateLimitedForFamily(account, family, model) || this.isAccountCoolingDown(account)) {
400
- continue;
401
- }
402
- this.cursorByFamily[family] = (idx + 1) % count;
403
- account.lastUsed = nowMs();
404
- return account;
405
- }
406
- return null;
117
+ return this.rotation.getNextForFamily(family, model);
407
118
  }
408
119
  getCurrentOrNextForFamilyHybrid(family, model, options) {
409
- const count = this.accounts.length;
410
- if (count === 0)
411
- return null;
412
- const currentIndex = this.currentAccountIndexByFamily[family];
413
- if (currentIndex >= 0 && currentIndex < count) {
414
- const currentAccount = this.accounts[currentIndex];
415
- if (currentAccount) {
416
- if (currentAccount.enabled === false) {
417
- // Fall through to hybrid selection.
418
- }
419
- else {
420
- clearExpiredRateLimits(currentAccount);
421
- if (!isRateLimitedForFamily(currentAccount, family, model) &&
422
- !this.isAccountCoolingDown(currentAccount)) {
423
- currentAccount.lastUsed = nowMs();
424
- return currentAccount;
425
- }
426
- }
427
- }
428
- }
429
- const quotaKey = model ? `${family}:${model}` : family;
430
- const healthTracker = getHealthTracker();
431
- const tokenTracker = getTokenTracker();
432
- const accountsWithMetrics = this.accounts
433
- .map((account) => {
434
- if (!account)
435
- return null;
436
- if (account.enabled === false)
437
- return null;
438
- clearExpiredRateLimits(account);
439
- const isAvailable = !isRateLimitedForFamily(account, family, model) && !this.isAccountCoolingDown(account);
440
- return {
441
- index: account.index,
442
- isAvailable,
443
- lastUsed: account.lastUsed,
444
- };
445
- })
446
- .filter((a) => a !== null);
447
- const selected = selectHybridAccount(accountsWithMetrics, healthTracker, tokenTracker, quotaKey, {}, options);
448
- if (!selected)
449
- return null;
450
- const account = this.accounts[selected.index];
451
- if (!account)
452
- return null;
453
- this.currentAccountIndexByFamily[family] = account.index;
454
- this.cursorByFamily[family] = (account.index + 1) % count;
455
- account.lastUsed = nowMs();
456
- return account;
120
+ return this.rotation.getCurrentOrNextForFamilyHybrid(family, model, options);
457
121
  }
458
122
  recordSuccess(account, family, model) {
459
- const quotaKey = model ? `${family}:${model}` : family;
460
- const healthTracker = getHealthTracker();
461
- healthTracker.recordSuccess(account.index, quotaKey);
123
+ this.rotation.recordSuccess(account, family, model);
462
124
  }
463
125
  recordRateLimit(account, family, model) {
464
- const quotaKey = model ? `${family}:${model}` : family;
465
- const healthTracker = getHealthTracker();
466
- const tokenTracker = getTokenTracker();
467
- healthTracker.recordRateLimit(account.index, quotaKey);
468
- tokenTracker.drain(account.index, quotaKey);
126
+ this.rotation.recordRateLimit(account, family, model);
469
127
  }
470
128
  recordFailure(account, family, model) {
471
- const quotaKey = model ? `${family}:${model}` : family;
472
- const healthTracker = getHealthTracker();
473
- healthTracker.recordFailure(account.index, quotaKey);
129
+ this.rotation.recordFailure(account, family, model);
474
130
  }
475
131
  consumeToken(account, family, model) {
476
- const quotaKey = model ? `${family}:${model}` : family;
477
- const tokenTracker = getTokenTracker();
478
- return tokenTracker.tryConsume(account.index, quotaKey);
132
+ return this.rotation.consumeToken(account, family, model);
479
133
  }
480
- /**
481
- * Refund a token consumed within the refund window (30 seconds).
482
- * Use this when a request fails due to network errors (not rate limits).
483
- * @returns true if refund was successful, false if no valid consumption found
484
- */
485
134
  refundToken(account, family, model) {
486
- const quotaKey = model ? `${family}:${model}` : family;
487
- const tokenTracker = getTokenTracker();
488
- return tokenTracker.refundToken(account.index, quotaKey);
489
- }
490
- markSwitched(account, reason, family) {
491
- account.lastSwitchReason = reason;
492
- this.currentAccountIndexByFamily[family] = account.index;
135
+ return this.rotation.refundToken(account, family, model);
493
136
  }
494
137
  markRateLimited(account, retryAfterMs, family, model) {
495
- this.markRateLimitedWithReason(account, retryAfterMs, family, "unknown", model);
138
+ this.rotation.markRateLimited(account, retryAfterMs, family, model);
496
139
  }
497
140
  markRateLimitedWithReason(account, retryAfterMs, family, reason, model) {
498
- const retryMs = Math.max(0, Math.floor(retryAfterMs));
499
- const resetAt = nowMs() + retryMs;
500
- const baseKey = getQuotaKey(family);
501
- account.rateLimitResetTimes[baseKey] = resetAt;
502
- if (model) {
503
- const modelKey = getQuotaKey(family, model);
504
- account.rateLimitResetTimes[modelKey] = resetAt;
505
- }
506
- account.lastRateLimitReason = reason;
141
+ this.rotation.markRateLimitedWithReason(account, retryAfterMs, family, reason, model);
507
142
  }
508
143
  markAccountCoolingDown(account, cooldownMs, reason) {
509
- const ms = Math.max(0, Math.floor(cooldownMs));
510
- account.coolingDownUntil = nowMs() + ms;
511
- account.cooldownReason = reason;
144
+ this.rotation.markAccountCoolingDown(account, cooldownMs, reason);
512
145
  }
513
- /**
514
- * Mark every in-memory account sharing a refresh token as cooling down.
515
- * @returns Number of live accounts updated.
516
- */
517
146
  markAccountsWithRefreshTokenCoolingDown(refreshToken, cooldownMs, reason) {
518
- const matches = this.accounts.filter((account) => account.refreshToken === refreshToken);
519
- for (const account of matches) {
520
- this.markAccountCoolingDown(account, cooldownMs, reason);
521
- }
522
- return matches.length;
523
- }
524
- isAccountCoolingDown(account) {
525
- if (account.coolingDownUntil === undefined)
526
- return false;
527
- if (nowMs() >= account.coolingDownUntil) {
528
- this.clearAccountCooldown(account);
529
- return false;
530
- }
531
- return true;
532
- }
533
- clearAccountCooldown(account) {
534
- delete account.coolingDownUntil;
535
- delete account.cooldownReason;
536
- }
537
- incrementAuthFailures(account) {
538
- const currentFailures = this.authFailuresByRefreshToken.get(account.refreshToken) ?? 0;
539
- const newFailures = currentFailures + 1;
540
- this.authFailuresByRefreshToken.set(account.refreshToken, newFailures);
541
- return newFailures;
542
- }
543
- /**
544
- * Clear the authentication failure counter for the given account's refresh token.
545
- *
546
- * Notes:
547
- * - Failure counts are tracked per refresh token (not per account), so this clears
548
- * shared failure state for all org variants that reuse the same token.
549
- * - Failure counts are in-memory only for the current AccountManager instance.
550
- */
551
- clearAuthFailures(account) {
552
- this.authFailuresByRefreshToken.delete(account.refreshToken);
553
- }
554
- shouldShowAccountToast(accountIndex, debounceMs = 30000) {
555
- const now = nowMs();
556
- if (accountIndex === this.lastToastAccountIndex && now - this.lastToastTime < debounceMs) {
557
- return false;
558
- }
559
- return true;
560
- }
561
- markToastShown(accountIndex) {
562
- this.lastToastAccountIndex = accountIndex;
563
- this.lastToastTime = nowMs();
564
- }
565
- updateFromAuth(account, auth) {
566
- const previousRefreshToken = account.refreshToken;
567
- account.refreshToken = auth.refresh;
568
- account.access = auth.access;
569
- account.expires = auth.expires;
570
- if (previousRefreshToken !== account.refreshToken) {
571
- this.authFailuresByRefreshToken.delete(previousRefreshToken);
572
- }
573
- const tokenAccountId = extractAccountId(auth.access);
574
- if (tokenAccountId &&
575
- (shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId))) {
576
- account.accountId = tokenAccountId;
577
- account.accountIdSource = "token";
578
- }
579
- account.email = sanitizeEmail(extractAccountEmail(auth.access)) ?? account.email;
580
- }
581
- toAuthDetails(account) {
582
- return {
583
- type: "oauth",
584
- access: account.access ?? "",
585
- refresh: account.refreshToken,
586
- expires: account.expires ?? 0,
587
- };
147
+ return this.rotation.markAccountsWithRefreshTokenCoolingDown(refreshToken, cooldownMs, reason);
588
148
  }
589
149
  getMinWaitTime() {
590
- return this.getMinWaitTimeForFamily("codex");
150
+ return this.rotation.getMinWaitTimeForFamily("codex");
591
151
  }
592
152
  getMinWaitTimeForFamily(family, model) {
593
- const now = nowMs();
594
- const enabledAccounts = this.accounts.filter((account) => account.enabled !== false);
595
- const available = enabledAccounts.filter((account) => {
596
- clearExpiredRateLimits(account);
597
- return !isRateLimitedForFamily(account, family, model) && !this.isAccountCoolingDown(account);
598
- });
599
- if (available.length > 0)
600
- return 0;
601
- if (enabledAccounts.length === 0)
602
- return 0;
603
- const waitTimes = [];
604
- const baseKey = getQuotaKey(family);
605
- const modelKey = model ? getQuotaKey(family, model) : null;
606
- for (const account of enabledAccounts) {
607
- const baseResetAt = account.rateLimitResetTimes[baseKey];
608
- if (typeof baseResetAt === "number") {
609
- waitTimes.push(Math.max(0, baseResetAt - now));
610
- }
611
- if (modelKey) {
612
- const modelResetAt = account.rateLimitResetTimes[modelKey];
613
- if (typeof modelResetAt === "number") {
614
- waitTimes.push(Math.max(0, modelResetAt - now));
615
- }
616
- }
617
- if (typeof account.coolingDownUntil === "number") {
618
- waitTimes.push(Math.max(0, account.coolingDownUntil - now));
619
- }
620
- }
621
- return waitTimes.length > 0 ? Math.min(...waitTimes) : 0;
153
+ return this.rotation.getMinWaitTimeForFamily(family, model);
622
154
  }
623
- removeAccount(account) {
624
- const idx = this.accounts.indexOf(account);
625
- if (idx < 0) {
626
- return false;
627
- }
628
- this.accounts.splice(idx, 1);
629
- this.accounts.forEach((acc, index) => {
630
- acc.index = index;
631
- });
632
- if (this.accounts.length === 0) {
633
- for (const family of MODEL_FAMILIES) {
634
- this.cursorByFamily[family] = 0;
635
- this.currentAccountIndexByFamily[family] = -1;
636
- }
637
- return true;
638
- }
639
- for (const family of MODEL_FAMILIES) {
640
- if (this.cursorByFamily[family] > idx) {
641
- this.cursorByFamily[family] = Math.max(0, this.cursorByFamily[family] - 1);
642
- }
643
- }
644
- for (const family of MODEL_FAMILIES) {
645
- this.cursorByFamily[family] = this.cursorByFamily[family] % this.accounts.length;
646
- }
647
- for (const family of MODEL_FAMILIES) {
648
- if (this.currentAccountIndexByFamily[family] > idx) {
649
- this.currentAccountIndexByFamily[family] -= 1;
650
- }
651
- if (this.currentAccountIndexByFamily[family] >= this.accounts.length) {
652
- this.currentAccountIndexByFamily[family] = -1;
653
- }
654
- }
655
- return true;
155
+ // ----- persistence delegations -----
156
+ async saveToDisk() {
157
+ await this.persistence.saveToDisk();
656
158
  }
657
- removeAccountByIndex(index) {
658
- if (!Number.isFinite(index))
659
- return false;
660
- if (index < 0 || index >= this.accounts.length)
661
- return false;
662
- const account = this.accounts[index];
663
- if (!account)
664
- return false;
665
- return this.removeAccount(account);
159
+ saveToDiskDebounced(delayMs = 500) {
160
+ this.persistence.saveToDiskDebounced(delayMs);
666
161
  }
667
- /**
668
- * Remove all accounts that share the same refreshToken as the given account.
669
- * This is used when auth refresh fails to remove all org variants together.
670
- * @returns Number of accounts removed
671
- */
672
- removeAccountsWithSameRefreshToken(account) {
673
- const refreshToken = account.refreshToken;
674
- // Snapshot first because removeAccount mutates this.accounts.
675
- const accountsToRemove = this.accounts.filter((acc) => acc.refreshToken === refreshToken);
676
- let removedCount = 0;
677
- for (const accountToRemove of accountsToRemove) {
678
- if (this.removeAccount(accountToRemove)) {
679
- removedCount++;
680
- }
681
- }
682
- // Clear stale auth failure state for this refresh token
683
- this.authFailuresByRefreshToken.delete(refreshToken);
684
- return removedCount;
162
+ async flushPendingSave() {
163
+ await this.persistence.flushPendingSave();
685
164
  }
686
- setAccountEnabled(index, enabled) {
687
- if (!Number.isFinite(index))
688
- return null;
689
- if (index < 0 || index >= this.accounts.length)
690
- return null;
691
- const account = this.accounts[index];
692
- if (!account)
693
- return null;
694
- account.enabled = enabled;
695
- return account;
165
+ disposeShutdownHandler() {
166
+ this.persistence.disposeShutdownHandler();
696
167
  }
697
- async saveToDisk() {
698
- const activeIndexByFamily = {};
699
- for (const family of MODEL_FAMILIES) {
700
- const raw = this.currentAccountIndexByFamily[family];
701
- activeIndexByFamily[family] = clampNonNegativeInt(raw, 0);
702
- }
703
- const activeIndex = clampNonNegativeInt(activeIndexByFamily.codex, 0);
704
- const storage = {
705
- version: 3,
706
- accounts: this.accounts.map((account) => ({
707
- accountId: account.accountId,
708
- organizationId: account.organizationId,
709
- accountIdSource: account.accountIdSource,
710
- accountLabel: account.accountLabel,
711
- accountTags: account.accountTags,
712
- accountNote: account.accountNote,
713
- email: account.email,
714
- refreshToken: account.refreshToken,
715
- accessToken: account.access,
716
- expiresAt: account.expires,
717
- enabled: account.enabled === false ? false : undefined,
718
- addedAt: account.addedAt,
719
- lastUsed: account.lastUsed,
720
- lastSwitchReason: account.lastSwitchReason,
721
- rateLimitResetTimes: Object.keys(account.rateLimitResetTimes).length > 0 ? account.rateLimitResetTimes : undefined,
722
- coolingDownUntil: account.coolingDownUntil,
723
- cooldownReason: account.cooldownReason,
724
- })),
725
- activeIndex,
726
- activeIndexByFamily,
727
- };
728
- await saveAccounts(storage);
168
+ // ----- recovery delegations -----
169
+ async incrementAuthFailures(account) {
170
+ return this.recovery.incrementAuthFailures(account);
729
171
  }
730
- saveToDiskDebounced(delayMs = 500) {
731
- if (this.saveDebounceTimer) {
732
- clearTimeout(this.saveDebounceTimer);
733
- }
734
- this.saveDebounceTimer = setTimeout(() => {
735
- this.saveDebounceTimer = null;
736
- const doSave = async () => {
737
- try {
738
- if (this.pendingSave) {
739
- await this.pendingSave;
740
- }
741
- this.pendingSave = this.saveToDisk().finally(() => {
742
- this.pendingSave = null;
743
- });
744
- await this.pendingSave;
745
- }
746
- catch (error) {
747
- log.warn("Debounced save failed", { error: error instanceof Error ? error.message : String(error) });
748
- }
749
- };
750
- void doSave();
751
- }, delayMs);
172
+ getAuthFailures(account) {
173
+ return this.recovery.getAuthFailures(account);
752
174
  }
753
- async flushPendingSave() {
754
- if (this.saveDebounceTimer) {
755
- clearTimeout(this.saveDebounceTimer);
756
- this.saveDebounceTimer = null;
757
- await this.saveToDisk();
758
- }
759
- if (this.pendingSave) {
760
- await this.pendingSave;
761
- }
175
+ clearAuthFailures(account) {
176
+ this.recovery.clearAuthFailures(account);
177
+ }
178
+ removeAccountsWithSameRefreshToken(account) {
179
+ return this.recovery.removeAccountsWithSameRefreshToken(account);
762
180
  }
763
181
  }
764
182
  export function formatAccountLabel(account, index) {
765
183
  const accountLabel = account?.accountLabel?.trim();
766
184
  const email = account?.email?.trim();
767
185
  const accountId = account?.accountId?.trim();
768
- const idSuffix = accountId ? (accountId.length > 6 ? accountId.slice(-6) : accountId) : null;
186
+ const idSuffix = accountId
187
+ ? accountId.length > 6
188
+ ? accountId.slice(-6)
189
+ : accountId
190
+ : null;
769
191
  if (accountLabel && email && idSuffix) {
770
192
  return `Account ${index + 1} (${accountLabel}, ${email}, id:${idSuffix})`;
771
193
  }