antigravity-claude-proxy 1.0.0

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.
@@ -0,0 +1,633 @@
1
+ /**
2
+ * Account Manager
3
+ * Manages multiple Antigravity accounts with sticky selection,
4
+ * automatic failover, and smart cooldown for rate-limited accounts.
5
+ */
6
+
7
+ import { readFile, writeFile, mkdir, access } from 'fs/promises';
8
+ import { constants as fsConstants } from 'fs';
9
+ import { dirname } from 'path';
10
+ import { execSync } from 'child_process';
11
+ import {
12
+ ACCOUNT_CONFIG_PATH,
13
+ ANTIGRAVITY_DB_PATH,
14
+ DEFAULT_COOLDOWN_MS,
15
+ TOKEN_REFRESH_INTERVAL_MS,
16
+ ANTIGRAVITY_ENDPOINT_FALLBACKS,
17
+ ANTIGRAVITY_HEADERS,
18
+ DEFAULT_PROJECT_ID,
19
+ MAX_WAIT_BEFORE_ERROR_MS
20
+ } from './constants.js';
21
+ import { refreshAccessToken } from './oauth.js';
22
+ import { formatDuration } from './utils/helpers.js';
23
+
24
+ export class AccountManager {
25
+ #accounts = [];
26
+ #currentIndex = 0;
27
+ #configPath;
28
+ #settings = {};
29
+ #initialized = false;
30
+
31
+ // Per-account caches
32
+ #tokenCache = new Map(); // email -> { token, extractedAt }
33
+ #projectCache = new Map(); // email -> projectId
34
+
35
+ constructor(configPath = ACCOUNT_CONFIG_PATH) {
36
+ this.#configPath = configPath;
37
+ }
38
+
39
+ /**
40
+ * Initialize the account manager by loading config
41
+ */
42
+ async initialize() {
43
+ if (this.#initialized) return;
44
+
45
+ try {
46
+ // Check if config file exists using async access
47
+ await access(this.#configPath, fsConstants.F_OK);
48
+ const configData = await readFile(this.#configPath, 'utf-8');
49
+ const config = JSON.parse(configData);
50
+
51
+ this.#accounts = (config.accounts || []).map(acc => ({
52
+ ...acc,
53
+ isRateLimited: acc.isRateLimited || false,
54
+ rateLimitResetTime: acc.rateLimitResetTime || null,
55
+ lastUsed: acc.lastUsed || null
56
+ }));
57
+
58
+ this.#settings = config.settings || {};
59
+ this.#currentIndex = config.activeIndex || 0;
60
+
61
+ // Clamp currentIndex to valid range
62
+ if (this.#currentIndex >= this.#accounts.length) {
63
+ this.#currentIndex = 0;
64
+ }
65
+
66
+ console.log(`[AccountManager] Loaded ${this.#accounts.length} account(s) from config`);
67
+ } catch (error) {
68
+ if (error.code === 'ENOENT') {
69
+ // No config file - use single account from Antigravity database
70
+ console.log('[AccountManager] No config file found. Using Antigravity database (single account mode)');
71
+ } else {
72
+ console.error('[AccountManager] Failed to load config:', error.message);
73
+ }
74
+ // Fall back to default account
75
+ await this.#loadDefaultAccount();
76
+ }
77
+
78
+ // Clear any expired rate limits
79
+ this.clearExpiredLimits();
80
+
81
+ this.#initialized = true;
82
+ }
83
+
84
+ /**
85
+ * Load the default account from Antigravity's database
86
+ */
87
+ async #loadDefaultAccount() {
88
+ try {
89
+ const authData = this.#extractTokenFromDB();
90
+ if (authData?.apiKey) {
91
+ this.#accounts = [{
92
+ email: authData.email || 'default@antigravity',
93
+ source: 'database',
94
+ isRateLimited: false,
95
+ rateLimitResetTime: null,
96
+ lastUsed: null
97
+ }];
98
+ // Pre-cache the token
99
+ this.#tokenCache.set(this.#accounts[0].email, {
100
+ token: authData.apiKey,
101
+ extractedAt: Date.now()
102
+ });
103
+ console.log(`[AccountManager] Loaded default account: ${this.#accounts[0].email}`);
104
+ }
105
+ } catch (error) {
106
+ console.error('[AccountManager] Failed to load default account:', error.message);
107
+ // Create empty account list - will fail on first request
108
+ this.#accounts = [];
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Extract token from Antigravity's SQLite database
114
+ */
115
+ #extractTokenFromDB(dbPath = ANTIGRAVITY_DB_PATH) {
116
+ const result = execSync(
117
+ `sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus';"`,
118
+ { encoding: 'utf-8', timeout: 5000 }
119
+ );
120
+
121
+ if (!result || !result.trim()) {
122
+ throw new Error('No auth status found in database');
123
+ }
124
+
125
+ return JSON.parse(result.trim());
126
+ }
127
+
128
+ /**
129
+ * Get the number of accounts
130
+ * @returns {number} Number of configured accounts
131
+ */
132
+ getAccountCount() {
133
+ return this.#accounts.length;
134
+ }
135
+
136
+ /**
137
+ * Check if all accounts are rate-limited
138
+ * @returns {boolean} True if all accounts are rate-limited
139
+ */
140
+ isAllRateLimited() {
141
+ if (this.#accounts.length === 0) return true;
142
+ return this.#accounts.every(acc => acc.isRateLimited);
143
+ }
144
+
145
+ /**
146
+ * Get list of available (non-rate-limited, non-invalid) accounts
147
+ * @returns {Array<Object>} Array of available account objects
148
+ */
149
+ getAvailableAccounts() {
150
+ return this.#accounts.filter(acc => !acc.isRateLimited && !acc.isInvalid);
151
+ }
152
+
153
+ /**
154
+ * Get list of invalid accounts
155
+ * @returns {Array<Object>} Array of invalid account objects
156
+ */
157
+ getInvalidAccounts() {
158
+ return this.#accounts.filter(acc => acc.isInvalid);
159
+ }
160
+
161
+ /**
162
+ * Clear expired rate limits
163
+ * @returns {number} Number of rate limits cleared
164
+ */
165
+ clearExpiredLimits() {
166
+ const now = Date.now();
167
+ let cleared = 0;
168
+
169
+ for (const account of this.#accounts) {
170
+ if (account.isRateLimited && account.rateLimitResetTime && account.rateLimitResetTime <= now) {
171
+ account.isRateLimited = false;
172
+ account.rateLimitResetTime = null;
173
+ cleared++;
174
+ console.log(`[AccountManager] Rate limit expired for: ${account.email}`);
175
+ }
176
+ }
177
+
178
+ if (cleared > 0) {
179
+ this.saveToDisk();
180
+ }
181
+
182
+ return cleared;
183
+ }
184
+
185
+ /**
186
+ * Clear all rate limits to force a fresh check
187
+ * (Optimistic retry strategy)
188
+ * @returns {void}
189
+ */
190
+ resetAllRateLimits() {
191
+ for (const account of this.#accounts) {
192
+ account.isRateLimited = false;
193
+ // distinct from "clearing" expired limits, we blindly reset here
194
+ // we keep the time? User said "clear isRateLimited value, and rateLimitResetTime"
195
+ // So we clear both.
196
+ account.rateLimitResetTime = null;
197
+ }
198
+ console.log('[AccountManager] Reset all rate limits for optimistic retry');
199
+ }
200
+
201
+ /**
202
+ * Pick the next available account (fallback when current is unavailable).
203
+ * Sets activeIndex to the selected account's index.
204
+ * @returns {Object|null} The next available account or null if none available
205
+ */
206
+ pickNext() {
207
+ this.clearExpiredLimits();
208
+
209
+ const available = this.getAvailableAccounts();
210
+ if (available.length === 0) {
211
+ return null;
212
+ }
213
+
214
+ // Clamp index to valid range
215
+ if (this.#currentIndex >= this.#accounts.length) {
216
+ this.#currentIndex = 0;
217
+ }
218
+
219
+ // Find next available account starting from index AFTER current
220
+ for (let i = 1; i <= this.#accounts.length; i++) {
221
+ const idx = (this.#currentIndex + i) % this.#accounts.length;
222
+ const account = this.#accounts[idx];
223
+
224
+ if (!account.isRateLimited && !account.isInvalid) {
225
+ // Set activeIndex to this account (not +1)
226
+ this.#currentIndex = idx;
227
+ account.lastUsed = Date.now();
228
+
229
+ const position = idx + 1;
230
+ const total = this.#accounts.length;
231
+ console.log(`[AccountManager] Using account: ${account.email} (${position}/${total})`);
232
+
233
+ // Persist the change (don't await to avoid blocking)
234
+ this.saveToDisk();
235
+
236
+ return account;
237
+ }
238
+ }
239
+
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * Get the current account without advancing the index (sticky selection).
245
+ * Used for cache continuity - sticks to the same account until rate-limited.
246
+ * @returns {Object|null} The current account or null if unavailable/rate-limited
247
+ */
248
+ getCurrentStickyAccount() {
249
+ this.clearExpiredLimits();
250
+
251
+ if (this.#accounts.length === 0) {
252
+ return null;
253
+ }
254
+
255
+ // Clamp index to valid range
256
+ if (this.#currentIndex >= this.#accounts.length) {
257
+ this.#currentIndex = 0;
258
+ }
259
+
260
+ // Get current account directly (activeIndex = current account)
261
+ const account = this.#accounts[this.#currentIndex];
262
+
263
+ // Return if available
264
+ if (account && !account.isRateLimited && !account.isInvalid) {
265
+ account.lastUsed = Date.now();
266
+ // Persist the change (don't await to avoid blocking)
267
+ this.saveToDisk();
268
+ return account;
269
+ }
270
+
271
+ return null;
272
+ }
273
+
274
+ /**
275
+ * Check if we should wait for the current account's rate limit to reset.
276
+ * Used for sticky account selection - wait if rate limit is short (≤ threshold).
277
+ * @returns {{shouldWait: boolean, waitMs: number, account: Object|null}}
278
+ */
279
+ shouldWaitForCurrentAccount() {
280
+ if (this.#accounts.length === 0) {
281
+ return { shouldWait: false, waitMs: 0, account: null };
282
+ }
283
+
284
+ // Clamp index to valid range
285
+ if (this.#currentIndex >= this.#accounts.length) {
286
+ this.#currentIndex = 0;
287
+ }
288
+
289
+ // Get current account directly (activeIndex = current account)
290
+ const account = this.#accounts[this.#currentIndex];
291
+
292
+ if (!account || account.isInvalid) {
293
+ return { shouldWait: false, waitMs: 0, account: null };
294
+ }
295
+
296
+ if (account.isRateLimited && account.rateLimitResetTime) {
297
+ const waitMs = account.rateLimitResetTime - Date.now();
298
+
299
+ // If wait time is within threshold, recommend waiting
300
+ if (waitMs > 0 && waitMs <= MAX_WAIT_BEFORE_ERROR_MS) {
301
+ return { shouldWait: true, waitMs, account };
302
+ }
303
+ }
304
+
305
+ return { shouldWait: false, waitMs: 0, account };
306
+ }
307
+
308
+ /**
309
+ * Pick an account with sticky selection preference.
310
+ * Prefers the current account for cache continuity, only switches when:
311
+ * - Current account is rate-limited for > 2 minutes
312
+ * - Current account is invalid
313
+ * @returns {{account: Object|null, waitMs: number}} Account to use and optional wait time
314
+ */
315
+ pickStickyAccount() {
316
+ // First try to get the current sticky account
317
+ const stickyAccount = this.getCurrentStickyAccount();
318
+ if (stickyAccount) {
319
+ return { account: stickyAccount, waitMs: 0 };
320
+ }
321
+
322
+ // Check if we should wait for current account
323
+ const waitInfo = this.shouldWaitForCurrentAccount();
324
+ if (waitInfo.shouldWait) {
325
+ console.log(`[AccountManager] Waiting ${formatDuration(waitInfo.waitMs)} for sticky account: ${waitInfo.account.email}`);
326
+ return { account: null, waitMs: waitInfo.waitMs };
327
+ }
328
+
329
+ // Current account unavailable for too long, switch to next available
330
+ const nextAccount = this.pickNext();
331
+ if (nextAccount) {
332
+ console.log(`[AccountManager] Switched to new account for cache: ${nextAccount.email}`);
333
+ }
334
+ return { account: nextAccount, waitMs: 0 };
335
+ }
336
+
337
+ /**
338
+ * Mark an account as rate-limited
339
+ * @param {string} email - Email of the account to mark
340
+ * @param {number|null} resetMs - Time in ms until rate limit resets (optional)
341
+ */
342
+ markRateLimited(email, resetMs = null) {
343
+ const account = this.#accounts.find(a => a.email === email);
344
+ if (!account) return;
345
+
346
+ account.isRateLimited = true;
347
+ const cooldownMs = resetMs || this.#settings.cooldownDurationMs || DEFAULT_COOLDOWN_MS;
348
+ account.rateLimitResetTime = Date.now() + cooldownMs;
349
+
350
+ console.log(
351
+ `[AccountManager] Rate limited: ${email}. Available in ${formatDuration(cooldownMs)}`
352
+ );
353
+
354
+ this.saveToDisk();
355
+ }
356
+
357
+ /**
358
+ * Mark an account as invalid (credentials need re-authentication)
359
+ * @param {string} email - Email of the account to mark
360
+ * @param {string} reason - Reason for marking as invalid
361
+ */
362
+ markInvalid(email, reason = 'Unknown error') {
363
+ const account = this.#accounts.find(a => a.email === email);
364
+ if (!account) return;
365
+
366
+ account.isInvalid = true;
367
+ account.invalidReason = reason;
368
+ account.invalidAt = Date.now();
369
+
370
+ console.log(
371
+ `[AccountManager] ⚠ Account INVALID: ${email}`
372
+ );
373
+ console.log(
374
+ `[AccountManager] Reason: ${reason}`
375
+ );
376
+ console.log(
377
+ `[AccountManager] Run 'npm run accounts' to re-authenticate this account`
378
+ );
379
+
380
+ this.saveToDisk();
381
+ }
382
+
383
+ /**
384
+ * Get the minimum wait time until any account becomes available
385
+ * @returns {number} Wait time in milliseconds
386
+ */
387
+ getMinWaitTimeMs() {
388
+ if (!this.isAllRateLimited()) return 0;
389
+
390
+ const now = Date.now();
391
+ let minWait = Infinity;
392
+ let soonestAccount = null;
393
+
394
+ for (const account of this.#accounts) {
395
+ if (account.rateLimitResetTime) {
396
+ const wait = account.rateLimitResetTime - now;
397
+ if (wait > 0 && wait < minWait) {
398
+ minWait = wait;
399
+ soonestAccount = account;
400
+ }
401
+ }
402
+ }
403
+
404
+ if (soonestAccount) {
405
+ console.log(`[AccountManager] Shortest wait: ${formatDuration(minWait)} (account: ${soonestAccount.email})`);
406
+ }
407
+
408
+ return minWait === Infinity ? DEFAULT_COOLDOWN_MS : minWait;
409
+ }
410
+
411
+ /**
412
+ * Get OAuth token for an account
413
+ * @param {Object} account - Account object with email and credentials
414
+ * @returns {Promise<string>} OAuth access token
415
+ * @throws {Error} If token refresh fails
416
+ */
417
+ async getTokenForAccount(account) {
418
+ // Check cache first
419
+ const cached = this.#tokenCache.get(account.email);
420
+ if (cached && (Date.now() - cached.extractedAt) < TOKEN_REFRESH_INTERVAL_MS) {
421
+ return cached.token;
422
+ }
423
+
424
+ // Get fresh token based on source
425
+ let token;
426
+
427
+ if (account.source === 'oauth' && account.refreshToken) {
428
+ // OAuth account - use refresh token to get new access token
429
+ try {
430
+ const tokens = await refreshAccessToken(account.refreshToken);
431
+ token = tokens.accessToken;
432
+ // Clear invalid flag on success
433
+ if (account.isInvalid) {
434
+ account.isInvalid = false;
435
+ account.invalidReason = null;
436
+ await this.saveToDisk();
437
+ }
438
+ console.log(`[AccountManager] Refreshed OAuth token for: ${account.email}`);
439
+ } catch (error) {
440
+ console.error(`[AccountManager] Failed to refresh token for ${account.email}:`, error.message);
441
+ // Mark account as invalid (credentials need re-auth)
442
+ this.markInvalid(account.email, error.message);
443
+ throw new Error(`AUTH_INVALID: ${account.email}: ${error.message}`);
444
+ }
445
+ } else if (account.source === 'manual' && account.apiKey) {
446
+ token = account.apiKey;
447
+ } else {
448
+ // Extract from database
449
+ const dbPath = account.dbPath || ANTIGRAVITY_DB_PATH;
450
+ const authData = this.#extractTokenFromDB(dbPath);
451
+ token = authData.apiKey;
452
+ }
453
+
454
+ // Cache the token
455
+ this.#tokenCache.set(account.email, {
456
+ token,
457
+ extractedAt: Date.now()
458
+ });
459
+
460
+ return token;
461
+ }
462
+
463
+ /**
464
+ * Get project ID for an account
465
+ * @param {Object} account - Account object
466
+ * @param {string} token - OAuth access token
467
+ * @returns {Promise<string>} Project ID
468
+ */
469
+ async getProjectForAccount(account, token) {
470
+ // Check cache first
471
+ const cached = this.#projectCache.get(account.email);
472
+ if (cached) {
473
+ return cached;
474
+ }
475
+
476
+ // OAuth or manual accounts may have projectId specified
477
+ if (account.projectId) {
478
+ this.#projectCache.set(account.email, account.projectId);
479
+ return account.projectId;
480
+ }
481
+
482
+ // Discover project via loadCodeAssist API
483
+ const project = await this.#discoverProject(token);
484
+ this.#projectCache.set(account.email, project);
485
+ return project;
486
+ }
487
+
488
+ /**
489
+ * Discover project ID via Cloud Code API
490
+ */
491
+ async #discoverProject(token) {
492
+ for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
493
+ try {
494
+ const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
495
+ method: 'POST',
496
+ headers: {
497
+ 'Authorization': `Bearer ${token}`,
498
+ 'Content-Type': 'application/json',
499
+ ...ANTIGRAVITY_HEADERS
500
+ },
501
+ body: JSON.stringify({
502
+ metadata: {
503
+ ideType: 'IDE_UNSPECIFIED',
504
+ platform: 'PLATFORM_UNSPECIFIED',
505
+ pluginType: 'GEMINI'
506
+ }
507
+ })
508
+ });
509
+
510
+ if (!response.ok) continue;
511
+
512
+ const data = await response.json();
513
+
514
+ if (typeof data.cloudaicompanionProject === 'string') {
515
+ return data.cloudaicompanionProject;
516
+ }
517
+ if (data.cloudaicompanionProject?.id) {
518
+ return data.cloudaicompanionProject.id;
519
+ }
520
+ } catch (error) {
521
+ console.log(`[AccountManager] Project discovery failed at ${endpoint}:`, error.message);
522
+ }
523
+ }
524
+
525
+ console.log(`[AccountManager] Using default project: ${DEFAULT_PROJECT_ID}`);
526
+ return DEFAULT_PROJECT_ID;
527
+ }
528
+
529
+ /**
530
+ * Clear project cache for an account (useful on auth errors)
531
+ * @param {string|null} email - Email to clear cache for, or null to clear all
532
+ */
533
+ clearProjectCache(email = null) {
534
+ if (email) {
535
+ this.#projectCache.delete(email);
536
+ } else {
537
+ this.#projectCache.clear();
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Clear token cache for an account (useful on auth errors)
543
+ * @param {string|null} email - Email to clear cache for, or null to clear all
544
+ */
545
+ clearTokenCache(email = null) {
546
+ if (email) {
547
+ this.#tokenCache.delete(email);
548
+ } else {
549
+ this.#tokenCache.clear();
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Save current state to disk (async)
555
+ * @returns {Promise<void>}
556
+ */
557
+ async saveToDisk() {
558
+ try {
559
+ // Ensure directory exists
560
+ const dir = dirname(this.#configPath);
561
+ await mkdir(dir, { recursive: true });
562
+
563
+ const config = {
564
+ accounts: this.#accounts.map(acc => ({
565
+ email: acc.email,
566
+ source: acc.source,
567
+ dbPath: acc.dbPath || null,
568
+ refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined,
569
+ apiKey: acc.source === 'manual' ? acc.apiKey : undefined,
570
+ projectId: acc.projectId || undefined,
571
+ addedAt: acc.addedAt || undefined,
572
+ isRateLimited: acc.isRateLimited,
573
+ rateLimitResetTime: acc.rateLimitResetTime,
574
+ isInvalid: acc.isInvalid || false,
575
+ invalidReason: acc.invalidReason || null,
576
+ lastUsed: acc.lastUsed
577
+ })),
578
+ settings: this.#settings,
579
+ activeIndex: this.#currentIndex
580
+ };
581
+
582
+ await writeFile(this.#configPath, JSON.stringify(config, null, 2));
583
+ } catch (error) {
584
+ console.error('[AccountManager] Failed to save config:', error.message);
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Get status object for logging/API
590
+ * @returns {{accounts: Array, settings: Object}} Status object with accounts and settings
591
+ */
592
+ getStatus() {
593
+ const available = this.getAvailableAccounts();
594
+ const rateLimited = this.#accounts.filter(a => a.isRateLimited);
595
+ const invalid = this.getInvalidAccounts();
596
+
597
+ return {
598
+ total: this.#accounts.length,
599
+ available: available.length,
600
+ rateLimited: rateLimited.length,
601
+ invalid: invalid.length,
602
+ summary: `${this.#accounts.length} total, ${available.length} available, ${rateLimited.length} rate-limited, ${invalid.length} invalid`,
603
+ accounts: this.#accounts.map(a => ({
604
+ email: a.email,
605
+ source: a.source,
606
+ isRateLimited: a.isRateLimited,
607
+ rateLimitResetTime: a.rateLimitResetTime,
608
+ isInvalid: a.isInvalid || false,
609
+ invalidReason: a.invalidReason || null,
610
+ lastUsed: a.lastUsed
611
+ }))
612
+ };
613
+ }
614
+
615
+ /**
616
+ * Get settings
617
+ * @returns {Object} Current settings object
618
+ */
619
+ getSettings() {
620
+ return { ...this.#settings };
621
+ }
622
+
623
+ /**
624
+ * Get all accounts (internal use for quota fetching)
625
+ * Returns the full account objects including credentials
626
+ * @returns {Array<Object>} Array of account objects
627
+ */
628
+ getAllAccounts() {
629
+ return this.#accounts;
630
+ }
631
+ }
632
+
633
+ export default AccountManager;