@cortexkit/antigravity-auth-core 1.1.0 → 2.0.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 (105) hide show
  1. package/dist/account-manager.d.ts +325 -0
  2. package/dist/account-manager.d.ts.map +1 -0
  3. package/dist/account-manager.js +1480 -0
  4. package/dist/account-manager.js.map +1 -0
  5. package/dist/account-storage.d.ts +162 -0
  6. package/dist/account-storage.d.ts.map +1 -0
  7. package/dist/account-storage.js +635 -0
  8. package/dist/account-storage.js.map +1 -0
  9. package/dist/account-types.d.ts +155 -0
  10. package/dist/account-types.d.ts.map +1 -0
  11. package/dist/account-types.js +9 -0
  12. package/dist/account-types.js.map +1 -0
  13. package/dist/agy-request-metadata.d.ts +12 -5
  14. package/dist/agy-request-metadata.d.ts.map +1 -1
  15. package/dist/agy-request-metadata.js +64 -38
  16. package/dist/agy-request-metadata.js.map +1 -1
  17. package/dist/agy-transport.d.ts +2 -2
  18. package/dist/agy-transport.d.ts.map +1 -1
  19. package/dist/agy-transport.js +87 -74
  20. package/dist/agy-transport.js.map +1 -1
  21. package/dist/antigravity/oauth.d.ts +3 -2
  22. package/dist/antigravity/oauth.d.ts.map +1 -1
  23. package/dist/antigravity/oauth.js +65 -55
  24. package/dist/antigravity/oauth.js.map +1 -1
  25. package/dist/atomic-write.d.ts +23 -0
  26. package/dist/atomic-write.d.ts.map +1 -0
  27. package/dist/atomic-write.js +47 -0
  28. package/dist/atomic-write.js.map +1 -0
  29. package/dist/auth-types.d.ts +12 -2
  30. package/dist/auth-types.d.ts.map +1 -1
  31. package/dist/auth.d.ts +1 -1
  32. package/dist/auth.d.ts.map +1 -1
  33. package/dist/auth.js +9 -6
  34. package/dist/auth.js.map +1 -1
  35. package/dist/constants.d.ts +19 -12
  36. package/dist/constants.d.ts.map +1 -1
  37. package/dist/constants.js +51 -39
  38. package/dist/constants.js.map +1 -1
  39. package/dist/fetch-timeout.d.ts +26 -0
  40. package/dist/fetch-timeout.d.ts.map +1 -0
  41. package/dist/fetch-timeout.js +43 -0
  42. package/dist/fetch-timeout.js.map +1 -0
  43. package/dist/file-lock.d.ts +79 -0
  44. package/dist/file-lock.d.ts.map +1 -0
  45. package/dist/file-lock.js +438 -0
  46. package/dist/file-lock.js.map +1 -0
  47. package/dist/fingerprint.d.ts +9 -3
  48. package/dist/fingerprint.d.ts.map +1 -1
  49. package/dist/fingerprint.js +28 -20
  50. package/dist/fingerprint.js.map +1 -1
  51. package/dist/index.d.ts +23 -13
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +18 -9
  54. package/dist/index.js.map +1 -1
  55. package/dist/logger.d.ts +1 -1
  56. package/dist/logger.d.ts.map +1 -1
  57. package/dist/logger.js +10 -10
  58. package/dist/logger.js.map +1 -1
  59. package/dist/model-registry.d.ts +5 -5
  60. package/dist/model-registry.d.ts.map +1 -1
  61. package/dist/model-registry.js +119 -107
  62. package/dist/model-registry.js.map +1 -1
  63. package/dist/project.d.ts +4 -1
  64. package/dist/project.d.ts.map +1 -1
  65. package/dist/project.js +84 -31
  66. package/dist/project.js.map +1 -1
  67. package/dist/quota-manager.d.ts +203 -0
  68. package/dist/quota-manager.d.ts.map +1 -0
  69. package/dist/quota-manager.js +718 -0
  70. package/dist/quota-manager.js.map +1 -0
  71. package/dist/quota-types.d.ts +68 -0
  72. package/dist/quota-types.d.ts.map +1 -0
  73. package/dist/quota-types.js +50 -0
  74. package/dist/quota-types.js.map +1 -0
  75. package/dist/rotation.d.ts +175 -0
  76. package/dist/rotation.d.ts.map +1 -0
  77. package/dist/rotation.js +391 -0
  78. package/dist/rotation.js.map +1 -0
  79. package/dist/transform/claude.d.ts +1 -1
  80. package/dist/transform/claude.d.ts.map +1 -1
  81. package/dist/transform/claude.js +63 -41
  82. package/dist/transform/claude.js.map +1 -1
  83. package/dist/transform/cross-model-sanitizer.d.ts +1 -1
  84. package/dist/transform/cross-model-sanitizer.d.ts.map +1 -1
  85. package/dist/transform/cross-model-sanitizer.js +19 -16
  86. package/dist/transform/cross-model-sanitizer.js.map +1 -1
  87. package/dist/transform/gemini.d.ts +1 -1
  88. package/dist/transform/gemini.d.ts.map +1 -1
  89. package/dist/transform/gemini.js +111 -85
  90. package/dist/transform/gemini.js.map +1 -1
  91. package/dist/transform/index.d.ts +9 -9
  92. package/dist/transform/index.d.ts.map +1 -1
  93. package/dist/transform/index.js +6 -6
  94. package/dist/transform/index.js.map +1 -1
  95. package/dist/transform/model-resolver.d.ts +5 -5
  96. package/dist/transform/model-resolver.d.ts.map +1 -1
  97. package/dist/transform/model-resolver.js +97 -75
  98. package/dist/transform/model-resolver.js.map +1 -1
  99. package/dist/transform/types.d.ts +5 -5
  100. package/dist/transform/types.d.ts.map +1 -1
  101. package/dist/version.d.ts +1 -1
  102. package/dist/version.d.ts.map +1 -1
  103. package/dist/version.js +14 -16
  104. package/dist/version.js.map +1 -1
  105. package/package.json +16 -10
@@ -0,0 +1,1480 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { AccountStorageLockContentionError } from "./account-storage.js";
3
+ import { formatRefreshParts, parseRefreshParts } from "./auth.js";
4
+ import { generateFingerprint, MAX_FINGERPRINT_HISTORY, updateFingerprintVersion, } from "./fingerprint.js";
5
+ import { getQuotaGroupForModel } from "./model-registry.js";
6
+ import { normalizeLegacyCachedQuota, } from "./quota-types.js";
7
+ import { getHealthTracker, getTokenTracker, selectHybridAccount, } from "./rotation.js";
8
+ function isStorageLockContention(error) {
9
+ if (error instanceof AccountStorageLockContentionError)
10
+ return true;
11
+ const message = String(error);
12
+ return (message.includes('Lock file is already being held') ||
13
+ message.includes('ELOCKED'));
14
+ }
15
+ export { calculateBackoffMs, computeSoftQuotaCacheTtlMs, parseRateLimitReason, } from "./rotation.js";
16
+ import { calculateBackoffMs } from "./rotation.js";
17
+ function clampNonNegativeInt(value, fallback) {
18
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
19
+ return fallback;
20
+ }
21
+ return value < 0 ? 0 : Math.floor(value);
22
+ }
23
+ /**
24
+ * Opaque identity for a refresh token.
25
+ *
26
+ * Antigravity refresh tokens are stable (they do not rotate), so hashing
27
+ * the token produces a durable, prunable identity to detect stale cached
28
+ * quota after an account-index shift.
29
+ */
30
+ function quotaAccountIdentity(refreshToken) {
31
+ return createHash('sha256').update(refreshToken).digest('hex').slice(0, 16);
32
+ }
33
+ function getQuotaKey(family, headerStyle, model) {
34
+ if (family === 'claude') {
35
+ return 'claude';
36
+ }
37
+ const base = headerStyle === 'gemini-cli' ? 'gemini-cli' : 'gemini-antigravity';
38
+ if (model) {
39
+ return `${base}:${model}`;
40
+ }
41
+ return base;
42
+ }
43
+ function isRateLimitedForQuotaKey(account, key, now) {
44
+ const resetTime = account.rateLimitResetTimes[key];
45
+ return resetTime !== undefined && now() < resetTime;
46
+ }
47
+ function isRateLimitedForFamily(account, family, now, model) {
48
+ if (family === 'claude') {
49
+ return isRateLimitedForQuotaKey(account, 'claude', now);
50
+ }
51
+ const antigravityIsLimited = isRateLimitedForHeaderStyle(account, family, 'antigravity', now, model);
52
+ const cliIsLimited = isRateLimitedForHeaderStyle(account, family, 'gemini-cli', now, model);
53
+ return antigravityIsLimited && cliIsLimited;
54
+ }
55
+ function isRateLimitedForHeaderStyle(account, family, headerStyle, now, model) {
56
+ clearExpiredRateLimits(account, now);
57
+ if (family === 'claude') {
58
+ return isRateLimitedForQuotaKey(account, 'claude', now);
59
+ }
60
+ // Check model-specific quota first if provided
61
+ if (model) {
62
+ const modelKey = getQuotaKey(family, headerStyle, model);
63
+ if (isRateLimitedForQuotaKey(account, modelKey, now)) {
64
+ return true;
65
+ }
66
+ }
67
+ // Then check base family quota
68
+ const baseKey = getQuotaKey(family, headerStyle);
69
+ return isRateLimitedForQuotaKey(account, baseKey, now);
70
+ }
71
+ function clearExpiredRateLimits(account, clock) {
72
+ const now = clock();
73
+ const keys = Object.keys(account.rateLimitResetTimes);
74
+ for (const key of keys) {
75
+ const resetTime = account.rateLimitResetTimes[key];
76
+ if (resetTime !== undefined && now >= resetTime) {
77
+ delete account.rateLimitResetTimes[key];
78
+ }
79
+ }
80
+ }
81
+ /**
82
+ * Resolve the quota group for soft quota checks.
83
+ *
84
+ * When a model string is available we use the model-registry lookup first,
85
+ * then fall back to substring matching. When model is null/undefined we
86
+ * fall back based on family:
87
+ * - Claude → "non-gemini" quota group
88
+ * - Gemini → "gemini" quota group
89
+ *
90
+ * @param family - The model family ("claude" | "gemini")
91
+ * @param model - Optional model string for precise resolution
92
+ * @returns The QuotaGroup to use for soft quota checks
93
+ */
94
+ export function resolveQuotaGroup(family, model) {
95
+ if (model) {
96
+ const registryGroup = getQuotaGroupForModel(model);
97
+ if (registryGroup)
98
+ return registryGroup;
99
+ const lower = model.toLowerCase();
100
+ // Check Claude / GPT-OSS substrings BEFORE the `gemini` substring so
101
+ // a `gemini-claude-*` alias (Claude route exposed under a `gemini-`
102
+ // namespace) attributes to the non-gemini pool. The model-registry
103
+ // check above already handles registered aliases; this substring
104
+ // fallback mirrors the same precedence rule for unregistered models.
105
+ if (lower.includes('claude') || lower.includes('gpt-oss')) {
106
+ return 'non-gemini';
107
+ }
108
+ if (lower.includes('gemini'))
109
+ return 'gemini';
110
+ }
111
+ return family === 'claude' ? 'non-gemini' : 'gemini';
112
+ }
113
+ function isOverSoftQuotaThreshold(account, family, thresholdPercent, cacheTtlMs, now, model) {
114
+ if (thresholdPercent >= 100)
115
+ return false;
116
+ if (!account.cachedQuota)
117
+ return false;
118
+ if (account.cachedQuotaUpdatedAt == null)
119
+ return false;
120
+ const age = now() - account.cachedQuotaUpdatedAt;
121
+ if (age > cacheTtlMs)
122
+ return false;
123
+ const quotaGroup = resolveQuotaGroup(family, model);
124
+ const groupData = account.cachedQuota[quotaGroup];
125
+ if (groupData?.remainingFraction == null)
126
+ return false;
127
+ const remainingFraction = Math.max(0, Math.min(1, groupData.remainingFraction));
128
+ const usedPercent = (1 - remainingFraction) * 100;
129
+ const isOverThreshold = usedPercent >= thresholdPercent;
130
+ return isOverThreshold;
131
+ }
132
+ const ACCOUNT_SESSION_STATE_TTL_MS = 24 * 60 * 60 * 1000;
133
+ const MAX_ACCOUNT_SESSION_STATES = 256;
134
+ /**
135
+ * In-memory multi-account manager with sticky account selection.
136
+ *
137
+ * Uses the same account until it hits a rate limit (429), then switches.
138
+ * Rate limits are tracked per-model-family (claude/gemini) so an account
139
+ * rate-limited for Claude can still be used for Gemini.
140
+ *
141
+ * Source of truth for the pool is `antigravity-accounts.json`.
142
+ */
143
+ export class AccountManager {
144
+ accounts = [];
145
+ cursorByFamily = { claude: 0, gemini: 0 };
146
+ currentAccountIndexByFamily = {
147
+ claude: -1,
148
+ gemini: -1,
149
+ };
150
+ sessionOffsetApplied = {
151
+ claude: false,
152
+ gemini: false,
153
+ };
154
+ lastToastAccountIndex = -1;
155
+ lastToastTime = 0;
156
+ savePending = false;
157
+ saveTimeout = null;
158
+ saveInFlight = null;
159
+ disposed = false;
160
+ savePromiseResolvers = [];
161
+ sessionStartTime;
162
+ sessionRequestCounts = new Map();
163
+ sessionUsedAccounts = new Set();
164
+ requestSessionStates = new Map();
165
+ store;
166
+ storagePath;
167
+ onDiagnostic;
168
+ now;
169
+ random;
170
+ pid;
171
+ constructor(authFallback, stored, options) {
172
+ this.store = options.store;
173
+ this.storagePath = options.storagePath ?? '';
174
+ this.onDiagnostic = options.onDiagnostic;
175
+ this.now = options.now ?? (() => Date.now());
176
+ this.random = options.random ?? (() => Math.random());
177
+ this.pid = options.pid ?? process.pid;
178
+ this.sessionStartTime = this.now();
179
+ const authParts = authFallback
180
+ ? parseRefreshParts(authFallback.refresh)
181
+ : null;
182
+ if (stored && stored.accounts.length === 0) {
183
+ this.accounts = [];
184
+ this.cursorByFamily = { claude: 0, gemini: 0 };
185
+ return;
186
+ }
187
+ if (stored && stored.accounts.length > 0) {
188
+ const baseNow = this.now();
189
+ this.accounts = stored.accounts
190
+ .map((acc, index) => {
191
+ if (!acc.refreshToken || typeof acc.refreshToken !== 'string') {
192
+ return null;
193
+ }
194
+ const matchesFallback = !!(authFallback &&
195
+ authParts?.refreshToken &&
196
+ acc.refreshToken === authParts.refreshToken);
197
+ return {
198
+ index,
199
+ email: acc.email,
200
+ label: acc.label,
201
+ addedAt: clampNonNegativeInt(acc.addedAt, baseNow),
202
+ lastUsed: clampNonNegativeInt(acc.lastUsed, 0),
203
+ parts: {
204
+ refreshToken: acc.refreshToken,
205
+ projectId: acc.projectId,
206
+ managedProjectId: acc.managedProjectId,
207
+ },
208
+ // Authoritative record-level fields that survive bare-refresh-token
209
+ // rotations where `parts.*` may be overwritten with undefined.
210
+ projectId: acc.projectId,
211
+ managedProjectId: acc.managedProjectId,
212
+ access: matchesFallback ? authFallback?.access : undefined,
213
+ expires: matchesFallback ? authFallback?.expires : undefined,
214
+ enabled: acc.enabled !== false,
215
+ rateLimitResetTimes: acc.rateLimitResetTimes ?? {},
216
+ lastSwitchReason: acc.lastSwitchReason,
217
+ coolingDownUntil: acc.coolingDownUntil,
218
+ cooldownReason: acc.cooldownReason,
219
+ touchedForQuota: {},
220
+ fingerprint: acc.fingerprint ?? generateFingerprint(),
221
+ fingerprintHistory: acc.fingerprintHistory ?? [],
222
+ cachedQuota: normalizeLegacyCachedQuota(acc.cachedQuota),
223
+ // Restore the opaque identity stamp alongside the quota so the
224
+ // post-load projection can detect a stale snapshot captured
225
+ // for a different account after an index shift.
226
+ cachedQuotaAccountId: acc.cachedQuotaAccountId,
227
+ cachedQuotaUpdatedAt: acc.cachedQuotaUpdatedAt,
228
+ capturedTierId: acc.capturedTierId,
229
+ capturedPaidTierId: acc.capturedPaidTierId,
230
+ capturedTierAt: acc.capturedTierAt,
231
+ capturedTierSchemaVersion: acc.capturedTierSchemaVersion,
232
+ dailyRequestCounts: acc.dailyRequestCounts,
233
+ verificationRequired: acc.verificationRequired,
234
+ verificationRequiredAt: acc.verificationRequiredAt,
235
+ verificationRequiredReason: acc.verificationRequiredReason,
236
+ verificationUrl: acc.verificationUrl,
237
+ accountIneligible: acc.accountIneligible,
238
+ accountIneligibleAt: acc.accountIneligibleAt,
239
+ accountIneligibleReason: acc.accountIneligibleReason,
240
+ eligibilityStateUpdatedAt: acc.eligibilityStateUpdatedAt,
241
+ };
242
+ })
243
+ .filter((a) => a !== null);
244
+ // Update fingerprint versions to match the current runtime version.
245
+ // Saved fingerprints may carry an older version string; this ensures
246
+ // they always reflect the latest fetched (or fallback) version.
247
+ let fingerprintVersionChanged = false;
248
+ for (const acc of this.accounts) {
249
+ if (acc.fingerprint && updateFingerprintVersion(acc.fingerprint)) {
250
+ fingerprintVersionChanged = true;
251
+ }
252
+ }
253
+ const legacyCursor = clampNonNegativeInt(stored.activeIndex, 0);
254
+ if (this.accounts.length > 0) {
255
+ const defaultIndex = legacyCursor % this.accounts.length;
256
+ this.currentAccountIndexByFamily.claude =
257
+ clampNonNegativeInt(stored.activeIndexByFamily?.claude, defaultIndex) % this.accounts.length;
258
+ this.currentAccountIndexByFamily.gemini =
259
+ clampNonNegativeInt(stored.activeIndexByFamily?.gemini, defaultIndex) % this.accounts.length;
260
+ this.cursorByFamily.claude = this.currentAccountIndexByFamily.claude;
261
+ this.cursorByFamily.gemini = this.currentAccountIndexByFamily.gemini;
262
+ }
263
+ // Persist updated fingerprint versions to disk
264
+ if (fingerprintVersionChanged) {
265
+ this.requestSaveToDisk();
266
+ }
267
+ // If current auth isn't in the loaded accounts, add it to the pool
268
+ if (authFallback && authParts?.refreshToken) {
269
+ const hasMatching = this.accounts.some((acc) => acc.parts.refreshToken === authParts.refreshToken);
270
+ if (!hasMatching) {
271
+ const now = this.now();
272
+ const newAccount = {
273
+ index: this.accounts.length,
274
+ email: undefined,
275
+ addedAt: now,
276
+ lastUsed: 0,
277
+ parts: authParts,
278
+ access: authFallback.access,
279
+ expires: authFallback.expires,
280
+ enabled: true,
281
+ rateLimitResetTimes: {},
282
+ touchedForQuota: {},
283
+ fingerprint: generateFingerprint(),
284
+ fingerprintHistory: [],
285
+ };
286
+ this.accounts.push(newAccount);
287
+ }
288
+ }
289
+ return;
290
+ }
291
+ if (authFallback) {
292
+ const parts = parseRefreshParts(authFallback.refresh);
293
+ if (parts.refreshToken) {
294
+ const now = this.now();
295
+ this.accounts = [
296
+ {
297
+ index: 0,
298
+ email: undefined,
299
+ addedAt: now,
300
+ lastUsed: 0,
301
+ parts,
302
+ access: authFallback.access,
303
+ expires: authFallback.expires,
304
+ enabled: true,
305
+ rateLimitResetTimes: {},
306
+ touchedForQuota: {},
307
+ },
308
+ ];
309
+ this.cursorByFamily = { claude: 0, gemini: 0 };
310
+ this.currentAccountIndexByFamily.claude = 0;
311
+ this.currentAccountIndexByFamily.gemini = 0;
312
+ }
313
+ }
314
+ }
315
+ getAccountCount() {
316
+ return this.getEnabledAccounts().length;
317
+ }
318
+ getTotalAccountCount() {
319
+ return this.accounts.length;
320
+ }
321
+ getEnabledAccounts() {
322
+ return this.accounts.filter((account) => account.enabled !== false);
323
+ }
324
+ getEffectiveSoftQuotaThreshold(thresholdPercent) {
325
+ // Soft-quota protection only has a purpose when another enabled account
326
+ // exists to rotate to. Never block the sole usable account.
327
+ return this.getEnabledAccounts().length > 1 ? thresholdPercent : 100;
328
+ }
329
+ getAccountsSnapshot() {
330
+ return this.accounts.map((a) => ({
331
+ ...a,
332
+ parts: { ...a.parts },
333
+ rateLimitResetTimes: { ...a.rateLimitResetTimes },
334
+ }));
335
+ }
336
+ getRequestSessionState(identity) {
337
+ const now = this.now();
338
+ this.pruneRequestSessionStates(now, identity.id);
339
+ const existing = this.requestSessionStates.get(identity.id);
340
+ if (existing) {
341
+ existing.lastAccessedAt = now;
342
+ if (identity.parentId) {
343
+ existing.parentId = identity.parentId;
344
+ }
345
+ return existing;
346
+ }
347
+ const state = {
348
+ parentId: identity.parentId ?? null,
349
+ currentAccountIndexByFamily: { claude: -1, gemini: -1 },
350
+ cursorByFamily: { ...this.cursorByFamily },
351
+ offsetAppliedByFamily: { claude: false, gemini: false },
352
+ usedAccounts: new Set(),
353
+ lastAccessedAt: now,
354
+ };
355
+ this.requestSessionStates.set(identity.id, state);
356
+ return state;
357
+ }
358
+ pruneRequestSessionStates(now, preservedId) {
359
+ const expiry = now - ACCOUNT_SESSION_STATE_TTL_MS;
360
+ for (const [id, state] of this.requestSessionStates) {
361
+ if (id !== preservedId && state.lastAccessedAt < expiry) {
362
+ this.requestSessionStates.delete(id);
363
+ }
364
+ }
365
+ if (this.requestSessionStates.size < MAX_ACCOUNT_SESSION_STATES ||
366
+ this.requestSessionStates.has(preservedId)) {
367
+ return;
368
+ }
369
+ let oldestId = null;
370
+ let oldestAccess = Number.POSITIVE_INFINITY;
371
+ for (const [id, state] of this.requestSessionStates) {
372
+ if (id !== preservedId && state.lastAccessedAt < oldestAccess) {
373
+ oldestId = id;
374
+ oldestAccess = state.lastAccessedAt;
375
+ }
376
+ }
377
+ if (oldestId) {
378
+ this.requestSessionStates.delete(oldestId);
379
+ }
380
+ }
381
+ getActiveIndex(family, identity) {
382
+ return identity
383
+ ? this.getRequestSessionState(identity).currentAccountIndexByFamily[family]
384
+ : this.currentAccountIndexByFamily[family];
385
+ }
386
+ setActiveIndex(family, index, identity) {
387
+ if (!identity) {
388
+ this.currentAccountIndexByFamily[family] = index;
389
+ return;
390
+ }
391
+ const state = this.getRequestSessionState(identity);
392
+ state.currentAccountIndexByFamily[family] = index;
393
+ if (!state.parentId) {
394
+ // Preserve a useful persisted starting point without coupling active root sessions.
395
+ this.currentAccountIndexByFamily[family] = index;
396
+ }
397
+ }
398
+ getCursor(family, identity) {
399
+ return identity
400
+ ? this.getRequestSessionState(identity).cursorByFamily[family]
401
+ : this.cursorByFamily[family];
402
+ }
403
+ advanceCursor(family, identity) {
404
+ const nextGlobalCursor = this.cursorByFamily[family] + 1;
405
+ this.cursorByFamily[family] = nextGlobalCursor;
406
+ if (identity) {
407
+ this.getRequestSessionState(identity).cursorByFamily[family] += 1;
408
+ }
409
+ }
410
+ getUsedAccounts(identity) {
411
+ return identity
412
+ ? this.getRequestSessionState(identity).usedAccounts
413
+ : this.sessionUsedAccounts;
414
+ }
415
+ preferAccountOutsideParent(accounts, family, identity) {
416
+ if (!identity) {
417
+ return accounts;
418
+ }
419
+ const parentId = this.getRequestSessionState(identity).parentId;
420
+ if (!parentId) {
421
+ return accounts;
422
+ }
423
+ const parentState = this.requestSessionStates.get(parentId);
424
+ const parentIndex = parentState?.currentAccountIndexByFamily[family] ?? -1;
425
+ if (parentIndex < 0) {
426
+ return accounts;
427
+ }
428
+ const isolated = accounts.filter((account) => account.index !== parentIndex);
429
+ return isolated.length > 0 ? isolated : accounts;
430
+ }
431
+ deleteSessionState(sessionId) {
432
+ this.requestSessionStates.delete(sessionId);
433
+ }
434
+ getCurrentAccountForFamily(family, identity) {
435
+ const currentIndex = this.getActiveIndex(family, identity);
436
+ if (currentIndex >= 0 && currentIndex < this.accounts.length) {
437
+ const account = this.accounts[currentIndex] ?? null;
438
+ // Only return account if it's enabled - disabled accounts should not be selected
439
+ if (account && account.enabled !== false) {
440
+ return account;
441
+ }
442
+ }
443
+ return null;
444
+ }
445
+ /**
446
+ * Numeric active indexes for each model family. Exposed so callers
447
+ * that persist `activeIndexByFamily` (e.g. command-data's remove
448
+ * path) can capture the live cursor per family without going
449
+ * through the account-lookup layer.
450
+ */
451
+ getActiveIndexByFamily(identity) {
452
+ return {
453
+ claude: this.getActiveIndex('claude', identity),
454
+ gemini: this.getActiveIndex('gemini', identity),
455
+ };
456
+ }
457
+ markSwitched(account, reason, family, identity) {
458
+ account.lastSwitchReason = reason;
459
+ this.setActiveIndex(family, account.index, identity);
460
+ }
461
+ /**
462
+ * Check if we should show an account switch toast.
463
+ * Debounces repeated toasts for the same account.
464
+ */
465
+ shouldShowAccountToast(accountIndex, debounceMs = 30000) {
466
+ const now = this.now();
467
+ if (accountIndex !== this.lastToastAccountIndex) {
468
+ return true;
469
+ }
470
+ return now - this.lastToastTime >= debounceMs;
471
+ }
472
+ markToastShown(accountIndex) {
473
+ this.lastToastAccountIndex = accountIndex;
474
+ this.lastToastTime = this.now();
475
+ }
476
+ getCurrentOrNextForFamily(family, model, strategy = 'sticky', headerStyle = 'antigravity', pidOffsetEnabled = false, softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000, identity,
477
+ /**
478
+ * Account indexes the caller has ruled out (e.g. the operator
479
+ * killswitch pre-filter). Every selection path — pinned session,
480
+ * round-robin, hybrid, and sticky fallback — skips these indexes
481
+ * so a killed current account falls through to the next eligible
482
+ * account instead of collapsing the request into the rate-limit
483
+ * wait path.
484
+ */
485
+ excludeIndexes) {
486
+ const quotaKey = getQuotaKey(family, headerStyle, model);
487
+ const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(softQuotaThresholdPercent);
488
+ // OpenCode may run many root and child sessions concurrently in one plugin
489
+ // process. Pin each exact session until its account becomes unavailable.
490
+ if (identity) {
491
+ const pinned = this.getCurrentAccountForFamily(family, identity);
492
+ if (pinned) {
493
+ clearExpiredRateLimits(pinned, this.now);
494
+ const unavailable = (excludeIndexes?.has(pinned.index) ?? false) ||
495
+ isRateLimitedForHeaderStyle(pinned, family, headerStyle, this.now, model) ||
496
+ isOverSoftQuotaThreshold(pinned, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model) ||
497
+ this.isAccountCoolingDown(pinned);
498
+ if (!unavailable) {
499
+ this.markTouchedForQuota(pinned, quotaKey);
500
+ return pinned;
501
+ }
502
+ }
503
+ }
504
+ if (strategy === 'round-robin') {
505
+ const next = this.getNextForFamily(family, model, headerStyle, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, identity, excludeIndexes);
506
+ if (next) {
507
+ this.markTouchedForQuota(next, quotaKey);
508
+ this.setActiveIndex(family, next.index, identity);
509
+ }
510
+ return next;
511
+ }
512
+ if (strategy === 'hybrid') {
513
+ const healthTracker = getHealthTracker();
514
+ const tokenTracker = getTokenTracker();
515
+ const eligibleAccounts = this.preferAccountOutsideParent(this.accounts.filter((acc) => acc.enabled !== false && !excludeIndexes?.has(acc.index)), family, identity);
516
+ const accountsWithMetrics = eligibleAccounts.map((acc) => {
517
+ clearExpiredRateLimits(acc, this.now);
518
+ return {
519
+ index: acc.index,
520
+ lastUsed: acc.lastUsed,
521
+ healthScore: healthTracker.getScore(acc.index),
522
+ isRateLimited: isRateLimitedForHeaderStyle(acc, family, headerStyle, this.now, model) ||
523
+ isOverSoftQuotaThreshold(acc, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model),
524
+ isCoolingDown: this.isAccountCoolingDown(acc),
525
+ };
526
+ });
527
+ // Get current account index for stickiness
528
+ const currentIndex = this.getActiveIndex(family, identity);
529
+ const selectedIndex = selectHybridAccount(accountsWithMetrics, tokenTracker, currentIndex, 50, this.now);
530
+ if (selectedIndex !== null) {
531
+ const selected = this.accounts[selectedIndex];
532
+ if (selected) {
533
+ selected.lastUsed = this.now();
534
+ this.markTouchedForQuota(selected, quotaKey);
535
+ this.setActiveIndex(family, selected.index, identity);
536
+ return selected;
537
+ }
538
+ }
539
+ }
540
+ // Fallback: sticky selection (used when hybrid finds no candidates)
541
+ // PID-based offset for multi-session distribution (opt-in)
542
+ // Different sessions (PIDs) will prefer different starting accounts
543
+ const offsetApplied = identity
544
+ ? this.getRequestSessionState(identity).offsetAppliedByFamily
545
+ : this.sessionOffsetApplied;
546
+ if (pidOffsetEnabled &&
547
+ !offsetApplied[family] &&
548
+ this.accounts.length > 1) {
549
+ const pidOffset = this.pid % this.accounts.length;
550
+ const activeIndex = this.getActiveIndex(family, identity);
551
+ const baseIndex = activeIndex >= 0 ? activeIndex : this.getCursor(family, identity);
552
+ const newIndex = (baseIndex + pidOffset) % this.accounts.length;
553
+ this.onDiagnostic?.('Applying PID account offset', {
554
+ pid: this.pid,
555
+ offset: pidOffset,
556
+ family,
557
+ fromIndex: baseIndex,
558
+ toIndex: newIndex,
559
+ });
560
+ this.setActiveIndex(family, newIndex, identity);
561
+ offsetApplied[family] = true;
562
+ }
563
+ const current = this.getCurrentAccountForFamily(family, identity);
564
+ if (current && !excludeIndexes?.has(current.index)) {
565
+ clearExpiredRateLimits(current, this.now);
566
+ const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(current, family, headerStyle, this.now, model);
567
+ const isOverThreshold = isOverSoftQuotaThreshold(current, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model);
568
+ if (!isLimitedForRequestedStyle &&
569
+ !isOverThreshold &&
570
+ !this.isAccountCoolingDown(current)) {
571
+ this.markTouchedForQuota(current, quotaKey);
572
+ return current;
573
+ }
574
+ }
575
+ const next = this.getNextForFamily(family, model, headerStyle, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, identity, excludeIndexes);
576
+ if (next) {
577
+ this.markTouchedForQuota(next, quotaKey);
578
+ this.setActiveIndex(family, next.index, identity);
579
+ }
580
+ return next;
581
+ }
582
+ getNextForFamily(family, model, headerStyle = 'antigravity', softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000, identity,
583
+ /** Indexes ruled out by the caller (e.g. killswitch pre-filter). */
584
+ excludeIndexes) {
585
+ const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(softQuotaThresholdPercent);
586
+ const allAvailable = this.accounts.filter((account) => {
587
+ clearExpiredRateLimits(account, this.now);
588
+ return (account.enabled !== false &&
589
+ !excludeIndexes?.has(account.index) &&
590
+ !isRateLimitedForHeaderStyle(account, family, headerStyle, this.now, model) &&
591
+ !isOverSoftQuotaThreshold(account, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model) &&
592
+ !this.isAccountCoolingDown(account));
593
+ });
594
+ const available = this.preferAccountOutsideParent(allAvailable, family, identity);
595
+ if (available.length === 0) {
596
+ return null;
597
+ }
598
+ const usedAccounts = this.getUsedAccounts(identity);
599
+ const sessionUsed = available.filter((account) => usedAccounts.has(account.index));
600
+ const candidates = sessionUsed.length > 0 ? sessionUsed : available;
601
+ const cursor = this.getCursor(family, identity);
602
+ const account = candidates[cursor % candidates.length];
603
+ if (!account) {
604
+ return null;
605
+ }
606
+ this.advanceCursor(family, identity);
607
+ return account;
608
+ }
609
+ markRateLimited(account, retryAfterMs, family, headerStyle = 'antigravity', model) {
610
+ const key = getQuotaKey(family, headerStyle, model);
611
+ account.rateLimitResetTimes[key] = this.now() + retryAfterMs;
612
+ }
613
+ /**
614
+ * Mark an account as used after a successful API request.
615
+ * This updates the lastUsed timestamp for freshness calculations.
616
+ * Should be called AFTER request completion, not during account selection.
617
+ */
618
+ markAccountUsed(accountIndex) {
619
+ const account = this.accounts.find((a) => a.index === accountIndex);
620
+ if (account) {
621
+ account.lastUsed = this.now();
622
+ }
623
+ }
624
+ recordSessionUsage(accountIndex, identity) {
625
+ this.getUsedAccounts(identity).add(accountIndex);
626
+ }
627
+ wasUsedInSession(accountIndex, identity) {
628
+ return this.getUsedAccounts(identity).has(accountIndex);
629
+ }
630
+ shouldProactivelyRotate(family, model, thresholdPercent, cacheTtlMs, identity) {
631
+ if (thresholdPercent <= 0)
632
+ return false;
633
+ const current = this.getCurrentAccountForFamily(family, identity);
634
+ if (!current?.cachedQuota || current.cachedQuotaUpdatedAt == null)
635
+ return false;
636
+ const age = this.now() - current.cachedQuotaUpdatedAt;
637
+ if (age > cacheTtlMs)
638
+ return false;
639
+ const quotaGroup = resolveQuotaGroup(family, model);
640
+ const groupData = current.cachedQuota[quotaGroup];
641
+ if (groupData?.remainingFraction == null)
642
+ return false;
643
+ const remainingPercent = Math.max(0, Math.min(100, groupData.remainingFraction * 100));
644
+ return remainingPercent < thresholdPercent;
645
+ }
646
+ proactivelyRotateForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs, identity) {
647
+ const currentIndex = this.getActiveIndex(family, identity);
648
+ const candidates = this.preferAccountOutsideParent(this.accounts.filter((acc) => {
649
+ if (acc.enabled === false)
650
+ return false;
651
+ if (acc.index === currentIndex)
652
+ return false;
653
+ clearExpiredRateLimits(acc, this.now);
654
+ if (isRateLimitedForHeaderStyle(acc, family, headerStyle, this.now, model))
655
+ return false;
656
+ if (isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, this.now, model))
657
+ return false;
658
+ if (this.isAccountCoolingDown(acc))
659
+ return false;
660
+ return true;
661
+ }), family, identity);
662
+ if (candidates.length === 0)
663
+ return null;
664
+ const usedAccounts = this.getUsedAccounts(identity);
665
+ const warmCandidates = candidates.filter((account) => usedAccounts.has(account.index));
666
+ const pool = warmCandidates.length > 0 ? warmCandidates : candidates;
667
+ const quotaGroup = resolveQuotaGroup(family, model);
668
+ pool.sort((a, b) => {
669
+ const aRemaining = a.cachedQuota?.[quotaGroup]?.remainingFraction ?? 0;
670
+ const bRemaining = b.cachedQuota?.[quotaGroup]?.remainingFraction ?? 0;
671
+ return bRemaining - aRemaining;
672
+ });
673
+ const selected = pool[0];
674
+ if (!selected)
675
+ return null;
676
+ const quotaKey = getQuotaKey(family, headerStyle, model);
677
+ this.markTouchedForQuota(selected, quotaKey);
678
+ this.setActiveIndex(family, selected.index, identity);
679
+ return selected;
680
+ }
681
+ markRateLimitedWithReason(account, family, headerStyle, model, reason, retryAfterMs, failureTtlMs = 3600_000) {
682
+ const now = this.now();
683
+ // TTL-based reset: if last failure was more than failureTtlMs ago, reset count
684
+ if (account.lastFailureTime !== undefined &&
685
+ now - account.lastFailureTime > failureTtlMs) {
686
+ account.consecutiveFailures = 0;
687
+ }
688
+ const failures = (account.consecutiveFailures ?? 0) + 1;
689
+ account.consecutiveFailures = failures;
690
+ account.lastFailureTime = now;
691
+ const backoffMs = calculateBackoffMs(reason, failures - 1, retryAfterMs, this.random);
692
+ const key = getQuotaKey(family, headerStyle, model);
693
+ account.rateLimitResetTimes[key] = now + backoffMs;
694
+ return backoffMs;
695
+ }
696
+ markRequestSuccess(account) {
697
+ if (account.consecutiveFailures) {
698
+ account.consecutiveFailures = 0;
699
+ }
700
+ }
701
+ clearAllRateLimitsForFamily(family, model) {
702
+ for (const account of this.accounts) {
703
+ if (family === 'claude') {
704
+ delete account.rateLimitResetTimes.claude;
705
+ }
706
+ else {
707
+ const antigravityKey = getQuotaKey(family, 'antigravity', model);
708
+ const cliKey = getQuotaKey(family, 'gemini-cli', model);
709
+ delete account.rateLimitResetTimes[antigravityKey];
710
+ delete account.rateLimitResetTimes[cliKey];
711
+ }
712
+ account.consecutiveFailures = 0;
713
+ }
714
+ }
715
+ shouldTryOptimisticReset(family, model) {
716
+ const minWaitMs = this.getMinWaitTimeForFamily(family, model);
717
+ return minWaitMs > 0 && minWaitMs <= 2_000;
718
+ }
719
+ markAccountCoolingDown(account, cooldownMs, reason) {
720
+ account.coolingDownUntil = this.now() + cooldownMs;
721
+ account.cooldownReason = reason;
722
+ }
723
+ isAccountCoolingDown(account) {
724
+ if (account.coolingDownUntil === undefined) {
725
+ return false;
726
+ }
727
+ if (this.now() >= account.coolingDownUntil) {
728
+ this.clearAccountCooldown(account);
729
+ return false;
730
+ }
731
+ return true;
732
+ }
733
+ clearAccountCooldown(account) {
734
+ delete account.coolingDownUntil;
735
+ delete account.cooldownReason;
736
+ }
737
+ getAccountCooldownReason(account) {
738
+ return this.isAccountCoolingDown(account)
739
+ ? account.cooldownReason
740
+ : undefined;
741
+ }
742
+ markTouchedForQuota(account, quotaKey) {
743
+ account.touchedForQuota[quotaKey] = this.now();
744
+ }
745
+ isFreshForQuota(account, quotaKey) {
746
+ const touchedAt = account.touchedForQuota[quotaKey];
747
+ if (!touchedAt)
748
+ return true;
749
+ const resetTime = account.rateLimitResetTimes[quotaKey];
750
+ if (resetTime && touchedAt < resetTime)
751
+ return true;
752
+ return false;
753
+ }
754
+ getFreshAccountsForQuota(quotaKey, family, model) {
755
+ return this.accounts.filter((acc) => {
756
+ clearExpiredRateLimits(acc, this.now);
757
+ return (acc.enabled !== false &&
758
+ this.isFreshForQuota(acc, quotaKey) &&
759
+ !isRateLimitedForFamily(acc, family, this.now, model) &&
760
+ !this.isAccountCoolingDown(acc));
761
+ });
762
+ }
763
+ isRateLimitedForHeaderStyle(account, family, headerStyle, model) {
764
+ return isRateLimitedForHeaderStyle(account, family, headerStyle, this.now, model);
765
+ }
766
+ getAvailableHeaderStyle(account, family, model) {
767
+ clearExpiredRateLimits(account, this.now);
768
+ if (family === 'claude') {
769
+ return isRateLimitedForHeaderStyle(account, family, 'antigravity', this.now)
770
+ ? null
771
+ : 'antigravity';
772
+ }
773
+ if (!isRateLimitedForHeaderStyle(account, family, 'antigravity', this.now, model)) {
774
+ return 'antigravity';
775
+ }
776
+ if (!isRateLimitedForHeaderStyle(account, family, 'gemini-cli', this.now, model)) {
777
+ return 'gemini-cli';
778
+ }
779
+ return null;
780
+ }
781
+ /**
782
+ * Check if any OTHER account has antigravity quota available for the given family/model.
783
+ *
784
+ * Used to determine whether to switch accounts vs fall back to gemini-cli:
785
+ * - If true: Switch to another account (preserve antigravity priority)
786
+ * - If false: All accounts exhausted antigravity, safe to fall back to gemini-cli
787
+ *
788
+ * @param currentAccountIndex - Index of the current account (will be excluded from check)
789
+ * @param family - Model family ("gemini" or "claude")
790
+ * @param model - Optional model name for model-specific rate limits
791
+ * @returns true if any other enabled, non-cooling-down account has antigravity available
792
+ */
793
+ hasOtherAccountWithAntigravityAvailable(currentAccountIndex, family, model) {
794
+ // Claude has no gemini-cli fallback - always return false
795
+ // (This method is only relevant for Gemini's dual quota pools)
796
+ if (family === 'claude') {
797
+ return false;
798
+ }
799
+ return this.accounts.some((acc) => {
800
+ // Skip current account
801
+ if (acc.index === currentAccountIndex) {
802
+ return false;
803
+ }
804
+ // Skip disabled accounts
805
+ if (acc.enabled === false) {
806
+ return false;
807
+ }
808
+ // Skip cooling down accounts
809
+ if (this.isAccountCoolingDown(acc)) {
810
+ return false;
811
+ }
812
+ // Clear expired rate limits before checking
813
+ clearExpiredRateLimits(acc, this.now);
814
+ // Check if antigravity is available for this account
815
+ return !isRateLimitedForHeaderStyle(acc, family, 'antigravity', this.now, model);
816
+ });
817
+ }
818
+ setAccountEnabled(accountIndex, enabled) {
819
+ const account = this.accounts[accountIndex];
820
+ if (!account) {
821
+ return false;
822
+ }
823
+ if (enabled && account.accountIneligible) {
824
+ return false;
825
+ }
826
+ account.enabled = enabled;
827
+ if (!enabled) {
828
+ for (const family of Object.keys(this.currentAccountIndexByFamily)) {
829
+ if (this.currentAccountIndexByFamily[family] === accountIndex) {
830
+ const next = this.accounts.find((a, i) => i !== accountIndex && a.enabled !== false);
831
+ this.currentAccountIndexByFamily[family] = next?.index ?? -1;
832
+ }
833
+ }
834
+ }
835
+ this.requestSaveToDisk();
836
+ return true;
837
+ }
838
+ markAccountVerificationRequired(accountIndex, reason, verifyUrl) {
839
+ const account = this.accounts[accountIndex];
840
+ if (!account) {
841
+ return false;
842
+ }
843
+ const timestamp = this.now();
844
+ account.verificationRequired = true;
845
+ account.verificationRequiredAt = timestamp;
846
+ account.verificationRequiredReason = reason?.trim() || undefined;
847
+ if (account.accountIneligible === true ||
848
+ account.accountIneligibleAt !== undefined ||
849
+ account.accountIneligibleReason !== undefined) {
850
+ account.accountIneligible = false;
851
+ account.accountIneligibleAt = undefined;
852
+ account.accountIneligibleReason = undefined;
853
+ account.eligibilityStateUpdatedAt = timestamp;
854
+ }
855
+ const normalizedVerifyUrl = verifyUrl?.trim();
856
+ if (normalizedVerifyUrl) {
857
+ account.verificationUrl = normalizedVerifyUrl;
858
+ }
859
+ if (account.enabled !== false) {
860
+ this.setAccountEnabled(accountIndex, false);
861
+ }
862
+ else {
863
+ this.requestSaveToDisk();
864
+ }
865
+ return true;
866
+ }
867
+ markAccountIneligible(accountIndex, reason) {
868
+ const account = this.accounts[accountIndex];
869
+ if (!account) {
870
+ return false;
871
+ }
872
+ const timestamp = this.now();
873
+ account.accountIneligible = true;
874
+ account.accountIneligibleAt = timestamp;
875
+ account.accountIneligibleReason =
876
+ reason?.trim() || 'Google marked this account as ineligible.';
877
+ account.eligibilityStateUpdatedAt = timestamp;
878
+ account.verificationRequired = false;
879
+ account.verificationRequiredAt = undefined;
880
+ account.verificationRequiredReason = undefined;
881
+ account.verificationUrl = undefined;
882
+ if (account.enabled !== false) {
883
+ this.setAccountEnabled(accountIndex, false);
884
+ }
885
+ else {
886
+ this.requestSaveToDisk();
887
+ }
888
+ return true;
889
+ }
890
+ clearAccountAccessBlocks(accountIndex, enableAccount = false) {
891
+ const account = this.accounts[accountIndex];
892
+ if (!account) {
893
+ return false;
894
+ }
895
+ const wasVerificationRequired = account.verificationRequired === true;
896
+ const wasIneligible = account.accountIneligible === true;
897
+ const hadMetadata = wasVerificationRequired ||
898
+ wasIneligible ||
899
+ account.verificationRequiredAt !== undefined ||
900
+ account.verificationRequiredReason !== undefined ||
901
+ account.verificationUrl !== undefined ||
902
+ account.accountIneligibleAt !== undefined ||
903
+ account.accountIneligibleReason !== undefined ||
904
+ account.eligibilityStateUpdatedAt !== undefined;
905
+ account.verificationRequired = false;
906
+ account.verificationRequiredAt = undefined;
907
+ account.verificationRequiredReason = undefined;
908
+ account.verificationUrl = undefined;
909
+ account.accountIneligible = false;
910
+ account.accountIneligibleAt = undefined;
911
+ account.accountIneligibleReason = undefined;
912
+ if (wasIneligible || account.eligibilityStateUpdatedAt !== undefined) {
913
+ account.eligibilityStateUpdatedAt = this.now();
914
+ }
915
+ if (enableAccount &&
916
+ (wasVerificationRequired || wasIneligible) &&
917
+ account.enabled === false) {
918
+ this.setAccountEnabled(accountIndex, true);
919
+ }
920
+ else if (hadMetadata) {
921
+ this.requestSaveToDisk();
922
+ }
923
+ return true;
924
+ }
925
+ removeAccountByIndex(accountIndex) {
926
+ if (accountIndex < 0 || accountIndex >= this.accounts.length) {
927
+ return false;
928
+ }
929
+ const account = this.accounts[accountIndex];
930
+ if (!account) {
931
+ return false;
932
+ }
933
+ return this.removeAccount(account);
934
+ }
935
+ removeAccount(account) {
936
+ const idx = this.accounts.indexOf(account);
937
+ if (idx < 0) {
938
+ return false;
939
+ }
940
+ this.accounts.splice(idx, 1);
941
+ this.accounts.forEach((acc, index) => {
942
+ acc.index = index;
943
+ });
944
+ if (this.accounts.length === 0) {
945
+ this.cursorByFamily = { claude: 0, gemini: 0 };
946
+ this.currentAccountIndexByFamily.claude = -1;
947
+ this.currentAccountIndexByFamily.gemini = -1;
948
+ this.requestSessionStates.clear();
949
+ return true;
950
+ }
951
+ for (const family of ['claude', 'gemini']) {
952
+ if (this.cursorByFamily[family] > idx) {
953
+ this.cursorByFamily[family] -= 1;
954
+ }
955
+ this.cursorByFamily[family] =
956
+ this.cursorByFamily[family] % this.accounts.length;
957
+ if (this.currentAccountIndexByFamily[family] > idx) {
958
+ this.currentAccountIndexByFamily[family] -= 1;
959
+ }
960
+ if (this.currentAccountIndexByFamily[family] >= this.accounts.length) {
961
+ this.currentAccountIndexByFamily[family] = -1;
962
+ }
963
+ for (const state of this.requestSessionStates.values()) {
964
+ const currentIndex = state.currentAccountIndexByFamily[family];
965
+ if (currentIndex === idx) {
966
+ state.currentAccountIndexByFamily[family] = -1;
967
+ }
968
+ else if (currentIndex > idx) {
969
+ state.currentAccountIndexByFamily[family] -= 1;
970
+ }
971
+ if (state.cursorByFamily[family] > idx) {
972
+ state.cursorByFamily[family] -= 1;
973
+ }
974
+ state.cursorByFamily[family] %= this.accounts.length;
975
+ }
976
+ }
977
+ for (const state of this.requestSessionStates.values()) {
978
+ state.usedAccounts = new Set([...state.usedAccounts]
979
+ .filter((accountIndex) => accountIndex !== idx)
980
+ .map((accountIndex) => accountIndex > idx ? accountIndex - 1 : accountIndex));
981
+ }
982
+ return true;
983
+ }
984
+ updateFromAuth(account, auth) {
985
+ const parts = parseRefreshParts(auth.refresh);
986
+ // Preserve existing projectId/managedProjectId if not in the new parts
987
+ account.parts = {
988
+ ...parts,
989
+ projectId: parts.projectId ?? account.parts.projectId,
990
+ managedProjectId: parts.managedProjectId ?? account.parts.managedProjectId,
991
+ };
992
+ // Keep the record-level fields in sync with the authoritative source.
993
+ account.projectId = parts.projectId ?? account.projectId;
994
+ account.managedProjectId =
995
+ parts.managedProjectId ?? account.managedProjectId;
996
+ account.access = auth.access;
997
+ account.expires = auth.expires;
998
+ }
999
+ toAuthDetails(account) {
1000
+ return {
1001
+ type: 'oauth',
1002
+ refresh: formatRefreshParts(account.parts),
1003
+ access: account.access,
1004
+ expires: account.expires,
1005
+ };
1006
+ }
1007
+ getMinWaitTimeForFamily(family, model, headerStyle, strict) {
1008
+ const available = this.accounts.filter((a) => {
1009
+ clearExpiredRateLimits(a, this.now);
1010
+ return (a.enabled !== false &&
1011
+ (strict && headerStyle
1012
+ ? !isRateLimitedForHeaderStyle(a, family, headerStyle, this.now, model)
1013
+ : !isRateLimitedForFamily(a, family, this.now, model)));
1014
+ });
1015
+ if (available.length > 0) {
1016
+ return 0;
1017
+ }
1018
+ const waitTimes = [];
1019
+ for (const a of this.accounts) {
1020
+ if (family === 'claude') {
1021
+ const t = a.rateLimitResetTimes.claude;
1022
+ if (t !== undefined)
1023
+ waitTimes.push(Math.max(0, t - this.now()));
1024
+ }
1025
+ else if (strict && headerStyle) {
1026
+ const key = getQuotaKey(family, headerStyle, model);
1027
+ const t = a.rateLimitResetTimes[key];
1028
+ if (t !== undefined)
1029
+ waitTimes.push(Math.max(0, t - this.now()));
1030
+ }
1031
+ else {
1032
+ // For Gemini, account becomes available when EITHER pool expires for this model/family
1033
+ const antigravityKey = getQuotaKey(family, 'antigravity', model);
1034
+ const cliKey = getQuotaKey(family, 'gemini-cli', model);
1035
+ const t1 = a.rateLimitResetTimes[antigravityKey];
1036
+ const t2 = a.rateLimitResetTimes[cliKey];
1037
+ const accountWait = Math.min(t1 !== undefined ? Math.max(0, t1 - this.now()) : Infinity, t2 !== undefined ? Math.max(0, t2 - this.now()) : Infinity);
1038
+ if (accountWait !== Infinity)
1039
+ waitTimes.push(accountWait);
1040
+ }
1041
+ }
1042
+ return waitTimes.length > 0 ? Math.min(...waitTimes) : 0;
1043
+ }
1044
+ getAccounts() {
1045
+ return [...this.accounts];
1046
+ }
1047
+ buildStorageSnapshot() {
1048
+ const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude);
1049
+ const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini);
1050
+ return {
1051
+ version: 4,
1052
+ accounts: this.accounts.map((a) => ({
1053
+ email: a.email,
1054
+ label: a.label,
1055
+ refreshToken: a.parts.refreshToken,
1056
+ projectId: a.parts.projectId ?? a.projectId,
1057
+ managedProjectId: a.parts.managedProjectId ?? a.managedProjectId,
1058
+ addedAt: a.addedAt,
1059
+ lastUsed: a.lastUsed,
1060
+ enabled: a.enabled,
1061
+ rateLimitResetTimes: Object.keys(a.rateLimitResetTimes).length > 0
1062
+ ? a.rateLimitResetTimes
1063
+ : undefined,
1064
+ fingerprint: a.fingerprint,
1065
+ fingerprintHistory: a.fingerprintHistory?.length
1066
+ ? a.fingerprintHistory
1067
+ : undefined,
1068
+ cachedQuota: a.cachedQuota && Object.keys(a.cachedQuota).length > 0
1069
+ ? a.cachedQuota
1070
+ : undefined,
1071
+ // Persist the opaque identity stamp alongside the quota so a later
1072
+ // loadFromDisk + projection can detect a stale snapshot captured
1073
+ // for a different account after an index shift.
1074
+ cachedQuotaAccountId: a.cachedQuotaAccountId,
1075
+ cachedQuotaUpdatedAt: a.cachedQuotaUpdatedAt,
1076
+ capturedTierId: a.capturedTierId,
1077
+ capturedPaidTierId: a.capturedPaidTierId,
1078
+ capturedTierAt: a.capturedTierAt,
1079
+ capturedTierSchemaVersion: a.capturedTierSchemaVersion,
1080
+ dailyRequestCounts: a.dailyRequestCounts,
1081
+ verificationRequired: a.verificationRequired,
1082
+ verificationRequiredAt: a.verificationRequiredAt,
1083
+ verificationRequiredReason: a.verificationRequiredReason,
1084
+ verificationUrl: a.verificationUrl,
1085
+ accountIneligible: a.accountIneligible,
1086
+ accountIneligibleAt: a.accountIneligibleAt,
1087
+ accountIneligibleReason: a.accountIneligibleReason,
1088
+ eligibilityStateUpdatedAt: a.eligibilityStateUpdatedAt,
1089
+ })),
1090
+ activeIndex: claudeIndex,
1091
+ activeIndexByFamily: {
1092
+ claude: claudeIndex,
1093
+ gemini: geminiIndex,
1094
+ },
1095
+ };
1096
+ }
1097
+ async saveToDisk() {
1098
+ await this.store.saveMerged(this.storagePath, this.buildStorageSnapshot());
1099
+ }
1100
+ /**
1101
+ * Persist via full-file replace (no merge). Required after destructive
1102
+ * operations (account removal) so a deleted account is not resurrected by
1103
+ * mergeAccountStorage re-reading it from disk.
1104
+ */
1105
+ async saveToDiskReplace() {
1106
+ const snapshot = this.buildStorageSnapshot();
1107
+ await this.store.mutate(this.storagePath, () => snapshot);
1108
+ }
1109
+ requestSaveToDisk() {
1110
+ if (this.disposed || this.savePending) {
1111
+ return;
1112
+ }
1113
+ this.savePending = true;
1114
+ this.saveTimeout = setTimeout(() => {
1115
+ this.saveInFlight = this.executeSave().finally(() => {
1116
+ this.saveInFlight = null;
1117
+ });
1118
+ }, 1000);
1119
+ }
1120
+ async flushSaveToDisk() {
1121
+ if (!this.savePending) {
1122
+ await this.saveInFlight;
1123
+ return;
1124
+ }
1125
+ return new Promise((resolve, reject) => {
1126
+ this.savePromiseResolvers.push({ resolve, reject });
1127
+ });
1128
+ }
1129
+ async dispose() {
1130
+ if (this.disposed)
1131
+ return;
1132
+ this.disposed = true;
1133
+ if (this.saveTimeout) {
1134
+ clearTimeout(this.saveTimeout);
1135
+ this.saveTimeout = null;
1136
+ }
1137
+ if (this.savePending) {
1138
+ await this.executeSave();
1139
+ }
1140
+ await this.saveInFlight;
1141
+ }
1142
+ async executeSave() {
1143
+ this.savePending = false;
1144
+ this.saveTimeout = null;
1145
+ const resolvers = this.savePromiseResolvers;
1146
+ this.savePromiseResolvers = [];
1147
+ try {
1148
+ await this.saveToDisk();
1149
+ for (const { resolve } of resolvers) {
1150
+ resolve();
1151
+ }
1152
+ }
1153
+ catch (error) {
1154
+ if (isStorageLockContention(error)) {
1155
+ this.onDiagnostic?.('Skipped account-state persist due to storage lock contention', {
1156
+ error: String(error),
1157
+ });
1158
+ for (const { resolve } of resolvers) {
1159
+ resolve();
1160
+ }
1161
+ return;
1162
+ }
1163
+ this.onDiagnostic?.('Failed to persist account state', {
1164
+ error: String(error),
1165
+ });
1166
+ for (const { reject } of resolvers) {
1167
+ reject(error);
1168
+ }
1169
+ }
1170
+ }
1171
+ // ========== Fingerprint Management ==========
1172
+ /**
1173
+ * Regenerate fingerprint for an account, saving the old one to history.
1174
+ * @param accountIndex - Index of the account to regenerate fingerprint for
1175
+ * @returns The new fingerprint, or null if account not found
1176
+ */
1177
+ regenerateAccountFingerprint(accountIndex) {
1178
+ const account = this.accounts[accountIndex];
1179
+ if (!account)
1180
+ return null;
1181
+ // Save current fingerprint to history if it exists
1182
+ if (account.fingerprint) {
1183
+ const historyEntry = {
1184
+ fingerprint: account.fingerprint,
1185
+ timestamp: this.now(),
1186
+ reason: 'regenerated',
1187
+ };
1188
+ if (!account.fingerprintHistory) {
1189
+ account.fingerprintHistory = [];
1190
+ }
1191
+ // Add to beginning of history (most recent first)
1192
+ account.fingerprintHistory.unshift(historyEntry);
1193
+ // Trim to max history size
1194
+ if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
1195
+ account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
1196
+ }
1197
+ }
1198
+ // Generate and assign new fingerprint
1199
+ account.fingerprint = generateFingerprint();
1200
+ this.requestSaveToDisk();
1201
+ return account.fingerprint;
1202
+ }
1203
+ /**
1204
+ * Restore a fingerprint from history for an account.
1205
+ * @param accountIndex - Index of the account
1206
+ * @param historyIndex - Index in the fingerprint history to restore from (0 = most recent)
1207
+ * @returns The restored fingerprint, or null if account/history not found
1208
+ */
1209
+ restoreAccountFingerprint(accountIndex, historyIndex) {
1210
+ const account = this.accounts[accountIndex];
1211
+ if (!account)
1212
+ return null;
1213
+ const history = account.fingerprintHistory;
1214
+ if (!history || historyIndex < 0 || historyIndex >= history.length) {
1215
+ return null;
1216
+ }
1217
+ // Capture the fingerprint to restore BEFORE modifying history
1218
+ const fingerprintToRestore = history[historyIndex].fingerprint;
1219
+ // Save current fingerprint to history before restoring (if it exists)
1220
+ if (account.fingerprint) {
1221
+ const historyEntry = {
1222
+ fingerprint: account.fingerprint,
1223
+ timestamp: this.now(),
1224
+ reason: 'restored',
1225
+ };
1226
+ account.fingerprintHistory.unshift(historyEntry);
1227
+ // Trim to max history size
1228
+ if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
1229
+ account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
1230
+ }
1231
+ }
1232
+ // Restore the fingerprint
1233
+ account.fingerprint = { ...fingerprintToRestore, createdAt: this.now() };
1234
+ this.requestSaveToDisk();
1235
+ return account.fingerprint;
1236
+ }
1237
+ /**
1238
+ * Get fingerprint history for an account.
1239
+ * @param accountIndex - Index of the account
1240
+ * @returns Array of fingerprint versions, or empty array if not found
1241
+ */
1242
+ getAccountFingerprintHistory(accountIndex) {
1243
+ const account = this.accounts[accountIndex];
1244
+ if (!account?.fingerprintHistory) {
1245
+ return [];
1246
+ }
1247
+ return [...account.fingerprintHistory];
1248
+ }
1249
+ updateQuotaCache(accountIndex, quotaGroups, expectedRefreshToken) {
1250
+ const account = this.accounts[accountIndex];
1251
+ if (!account ||
1252
+ (account.parts.refreshToken !== expectedRefreshToken &&
1253
+ expectedRefreshToken !== undefined))
1254
+ return;
1255
+ account.cachedQuota = quotaGroups;
1256
+ // Stamp the cached quota with an opaque identity derived from the refresh
1257
+ // token so a later projection can detect a stale snapshot captured for
1258
+ // a different account after an index shift.
1259
+ account.cachedQuotaAccountId = quotaAccountIdentity(account.parts.refreshToken);
1260
+ account.cachedQuotaUpdatedAt = this.now();
1261
+ }
1262
+ /**
1263
+ * Apply a subset of fields from a quota-fetch `updatedAccount` result onto
1264
+ * the live in-memory record for the given index. Only patches fields that
1265
+ * are present and non-empty in `patch` to avoid overwriting valid state
1266
+ * with stale or missing values.
1267
+ *
1268
+ * Identity guard: if `expectedRefreshToken` is provided and the account at
1269
+ * `accountIndex` no longer carries that token (concurrent reorder/replace),
1270
+ * the patch is silently dropped.
1271
+ *
1272
+ * `managedProjectId` is intentionally absent from the patch type: the only
1273
+ * caller (`BackgroundQuotaRefresh`) routes through `PollerAccountView` which
1274
+ * exposes only `capturedTierId`/`capturedTierAt`; project-context updates
1275
+ * happen via `ensureProjectContext`, not through this method.
1276
+ */
1277
+ applyUpdatedAccount(accountIndex, patch, expectedRefreshToken) {
1278
+ const account = this.accounts[accountIndex];
1279
+ if (!account ||
1280
+ (expectedRefreshToken !== undefined &&
1281
+ account.parts.refreshToken !== expectedRefreshToken))
1282
+ return;
1283
+ if (patch.capturedTierId !== undefined) {
1284
+ account.capturedTierId = patch.capturedTierId;
1285
+ }
1286
+ if (patch.capturedPaidTierId !== undefined) {
1287
+ account.capturedPaidTierId = patch.capturedPaidTierId;
1288
+ }
1289
+ if (patch.capturedTierAt !== undefined) {
1290
+ account.capturedTierAt = patch.capturedTierAt;
1291
+ }
1292
+ if (patch.capturedTierSchemaVersion !== undefined) {
1293
+ account.capturedTierSchemaVersion = patch.capturedTierSchemaVersion;
1294
+ }
1295
+ }
1296
+ /**
1297
+ * Record a successful API request for an account.
1298
+ * Tracks per model family with daily reset.
1299
+ */
1300
+ recordRequest(accountIndex, family) {
1301
+ const account = this.accounts[accountIndex];
1302
+ if (!account)
1303
+ return;
1304
+ const today = new Date(this.now()).toISOString().slice(0, 10);
1305
+ if (!account.dailyRequestCounts ||
1306
+ account.dailyRequestCounts.date !== today) {
1307
+ account.dailyRequestCounts = { date: today, claude: 0, gemini: 0 };
1308
+ }
1309
+ account.dailyRequestCounts[family]++;
1310
+ account.lastUsed = this.now();
1311
+ // Also track for session
1312
+ this.recordSessionRequest(accountIndex, family);
1313
+ }
1314
+ /**
1315
+ * Get request counts for an account for today.
1316
+ */
1317
+ getDailyRequestCounts(accountIndex) {
1318
+ const account = this.accounts[accountIndex];
1319
+ if (!account?.dailyRequestCounts)
1320
+ return null;
1321
+ const today = new Date(this.now()).toISOString().slice(0, 10);
1322
+ if (account.dailyRequestCounts.date !== today)
1323
+ return null;
1324
+ return { ...account.dailyRequestCounts };
1325
+ }
1326
+ /**
1327
+ * Get total daily request counts across all accounts for a model family.
1328
+ */
1329
+ getTotalDailyRequests(family) {
1330
+ const today = new Date(this.now()).toISOString().slice(0, 10);
1331
+ let total = 0;
1332
+ for (const account of this.accounts) {
1333
+ if (account.dailyRequestCounts?.date === today) {
1334
+ total += account.dailyRequestCounts[family];
1335
+ }
1336
+ }
1337
+ return total;
1338
+ }
1339
+ /**
1340
+ * Get a summary of daily request distribution across accounts.
1341
+ * Returns accounts sorted by request count (descending).
1342
+ */
1343
+ getDailyRequestSummary(family) {
1344
+ const today = new Date(this.now()).toISOString().slice(0, 10);
1345
+ const result = [];
1346
+ for (const account of this.accounts) {
1347
+ const count = account.dailyRequestCounts?.date === today
1348
+ ? account.dailyRequestCounts[family]
1349
+ : 0;
1350
+ if (count > 0) {
1351
+ result.push({ index: account.index, email: account.email, count });
1352
+ }
1353
+ }
1354
+ return result.sort((a, b) => b.count - a.count);
1355
+ }
1356
+ /**
1357
+ * Record a request for the current session (in-memory only).
1358
+ */
1359
+ recordSessionRequest(accountIndex, family) {
1360
+ const key = String(accountIndex);
1361
+ const current = this.sessionRequestCounts.get(key) ?? {
1362
+ claude: 0,
1363
+ gemini: 0,
1364
+ };
1365
+ current[family]++;
1366
+ this.sessionRequestCounts.set(key, current);
1367
+ }
1368
+ /**
1369
+ * Get a summary of the current session's request usage.
1370
+ */
1371
+ getSessionSummary() {
1372
+ const durationMs = this.now() - this.sessionStartTime;
1373
+ const durationMinutes = Math.round(durationMs / 60000);
1374
+ const durationHours = durationMs / 3600000;
1375
+ let totalClaude = 0;
1376
+ let totalGemini = 0;
1377
+ const perAccount = [];
1378
+ for (const [key, counts] of this.sessionRequestCounts) {
1379
+ const idx = Number(key);
1380
+ const account = this.accounts[idx];
1381
+ totalClaude += counts.claude;
1382
+ totalGemini += counts.gemini;
1383
+ if (counts.claude > 0 || counts.gemini > 0) {
1384
+ perAccount.push({
1385
+ index: idx,
1386
+ email: account?.email,
1387
+ claude: counts.claude,
1388
+ gemini: counts.gemini,
1389
+ });
1390
+ }
1391
+ }
1392
+ const totalRequests = totalClaude + totalGemini;
1393
+ const requestsPerHour = durationHours > 0 ? Math.round(totalRequests / durationHours) : 0;
1394
+ return {
1395
+ durationMinutes,
1396
+ totalClaude,
1397
+ totalGemini,
1398
+ requestsPerHour,
1399
+ accountsUsed: perAccount.length,
1400
+ perAccount: perAccount.sort((a, b) => b.claude + b.gemini - (a.claude + a.gemini)),
1401
+ };
1402
+ }
1403
+ isAccountOverSoftQuota(account, family, thresholdPercent, cacheTtlMs, model) {
1404
+ return isOverSoftQuotaThreshold(account, family, this.getEffectiveSoftQuotaThreshold(thresholdPercent), cacheTtlMs, this.now, model);
1405
+ }
1406
+ getAccountsForQuotaCheck() {
1407
+ return this.accounts.map((a) => ({
1408
+ email: a.email,
1409
+ refreshToken: a.parts.refreshToken,
1410
+ projectId: a.parts.projectId ?? a.projectId,
1411
+ managedProjectId: a.parts.managedProjectId ?? a.managedProjectId,
1412
+ addedAt: a.addedAt,
1413
+ lastUsed: a.lastUsed,
1414
+ enabled: a.enabled,
1415
+ }));
1416
+ }
1417
+ getOldestQuotaCacheAge() {
1418
+ let oldest = null;
1419
+ for (const acc of this.accounts) {
1420
+ if (acc.enabled === false)
1421
+ continue;
1422
+ if (acc.cachedQuotaUpdatedAt == null)
1423
+ return null;
1424
+ const age = this.now() - acc.cachedQuotaUpdatedAt;
1425
+ if (oldest === null || age > oldest)
1426
+ oldest = age;
1427
+ }
1428
+ return oldest;
1429
+ }
1430
+ areAllAccountsOverSoftQuota(family, thresholdPercent, cacheTtlMs, model) {
1431
+ if (thresholdPercent >= 100)
1432
+ return false;
1433
+ const enabled = this.accounts.filter((a) => a.enabled !== false);
1434
+ if (enabled.length <= 1)
1435
+ return false;
1436
+ return enabled.every((a) => isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, this.now, model));
1437
+ }
1438
+ /**
1439
+ * Get minimum wait time until any account's soft quota resets.
1440
+ * Returns 0 if any account is available (not over threshold).
1441
+ * Returns the minimum resetTime across all over-threshold accounts.
1442
+ * Returns null if no resetTime data is available.
1443
+ */
1444
+ getMinWaitTimeForSoftQuota(family, thresholdPercent, cacheTtlMs, model) {
1445
+ if (thresholdPercent >= 100)
1446
+ return 0;
1447
+ const enabled = this.accounts.filter((a) => a.enabled !== false);
1448
+ if (enabled.length === 0)
1449
+ return null;
1450
+ if (enabled.length === 1)
1451
+ return 0;
1452
+ // If any account is available (not over threshold), no wait needed
1453
+ const available = enabled.filter((a) => !isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, this.now, model));
1454
+ if (available.length > 0)
1455
+ return 0;
1456
+ // All accounts are over threshold - find earliest reset time
1457
+ // For gemini family, we MUST have the model to distinguish pro vs flash quotas.
1458
+ // Fail-open (return null = no wait info) if model is missing to avoid blocking on wrong quota.
1459
+ if (!model && family !== 'claude')
1460
+ return null;
1461
+ const quotaGroup = resolveQuotaGroup(family, model);
1462
+ const now = this.now();
1463
+ const waitTimes = [];
1464
+ for (const acc of enabled) {
1465
+ const groupData = acc.cachedQuota?.[quotaGroup];
1466
+ if (groupData?.resetTime) {
1467
+ const resetTimestamp = Date.parse(groupData.resetTime);
1468
+ if (Number.isFinite(resetTimestamp)) {
1469
+ waitTimes.push(Math.max(0, resetTimestamp - now));
1470
+ }
1471
+ }
1472
+ }
1473
+ if (waitTimes.length === 0)
1474
+ return null;
1475
+ const minWait = Math.min(...waitTimes);
1476
+ // Treat 0 as stale cache (resetTime in the past) → fail-open to avoid spin loop
1477
+ return minWait === 0 ? null : minWait;
1478
+ }
1479
+ }
1480
+ //# sourceMappingURL=account-manager.js.map