pi-antigravity-rotator 1.0.1 → 1.2.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.
package/README.md CHANGED
@@ -67,7 +67,7 @@ After starting the proxy, open `http://localhost:51200/dashboard` or `http://<yo
67
67
  The dashboard shows:
68
68
 
69
69
  - **Model Routing table** -- Which account each model (Gemini Pro, Flash, Claude) is currently routed to
70
- - **Account cards** with:
70
+ - **Account cards** sorted by total quota (highest first), flagged/disabled last:
71
71
  - Status badge: `active`, `ready`, `cooldown`, `flagged`, `disabled`, or `error`
72
72
  - Model badges: which models this account is currently serving
73
73
  - Per-model quota bars with timer type (`fresh`/`7d`/`5h`) and reset countdown
@@ -121,27 +121,40 @@ Three mechanisms trigger rotation, scoped to the specific model:
121
121
 
122
122
  The proxy detects blocked/suspended accounts at three levels:
123
123
 
124
- 1. **Quota API check** (on startup + every poll) -- If the quota API returns `403 PERMISSION_DENIED` with "violation of Terms of Service", the account is immediately flagged. This catches suspended accounts before any request is wasted.
124
+ 1. **Quota API check** (on startup + every poll) -- If the quota API returns `403 PERMISSION_DENIED` with "violation of Terms of Service", the account is immediately flagged.
125
125
 
126
- 2. **API endpoint 401** (on request) -- If all 3 endpoints (daily, autopush, prod) reject the token with `401 UNAUTHENTICATED`, the account is flagged. The proxy cascades through all endpoints before giving up.
126
+ 2. **API 401** (on request) -- If the prod endpoint rejects the token with `401 UNAUTHENTICATED`, the account is flagged.
127
127
 
128
- 3. **API endpoint 403** (on request) -- If the response body contains infringement keywords (`infring`, `suspend`, `abus`, `terminat`, `violat`, `banned`, `policy`, `forbidden`), the account is flagged.
128
+ 3. **API 403** (on request) -- If the response body contains infringement keywords (`infring`, `suspend`, `abus`, `terminat`, `violat`, `banned`, `policy`, `forbidden`), the account is flagged.
129
129
 
130
- Flagged accounts are **immediately excluded** from all model routing. The dashboard shows a red `FLAGGED` badge with the error message. Use the Re-enable button or `POST /api/enable/<email>` to clear the flag after resolving the issue with Google.
130
+ Flagged accounts are **immediately excluded** from all model routing. The dashboard shows a red `FLAGGED` badge with the error message. Use the Re-enable button or `POST /api/enable/<email>` to clear the flag.
131
131
 
132
- ### Endpoint Cascade
132
+ ### Cooldown Management
133
133
 
134
- The proxy tries three Google API endpoints in order for each request:
134
+ - Cooldowns are capped at **30 minutes** max
135
+ - Stale cooldowns from previous sessions are capped on startup
136
+ - Use `POST /api/reset-cooldowns` to clear all cooldowns at once
137
+ - Quota-based rotation only triggers if a healthy account is available; the proxy won't rotate away from a working account if there's no better alternative
135
138
 
136
- 1. `daily-cloudcode-pa.sandbox.googleapis.com`
137
- 2. `autopush-cloudcode-pa.sandbox.googleapis.com`
138
- 3. `cloudcode-pa.googleapis.com` (prod)
139
+ ### Error Handling
139
140
 
140
- On `401`, `403`, or `404`, it cascades to the next endpoint. Only the final endpoint's response is used for flagging decisions.
141
+ - **429** (rate limit) -- account is marked exhausted with cooldown, rotates to next
142
+ - **503** (no capacity) -- returned directly to the agent for its own retry/backoff
143
+ - **5xx** (other server errors) -- account error counter incremented, rotates to next
141
144
 
142
145
  ## Configuration
143
146
 
144
- All configuration is in `accounts.json`, created automatically by `npm run login`:
147
+ Config files (`accounts.json`, `state.json`) are stored in `~/.pi-antigravity-rotator/` by default. Override with:
148
+
149
+ ```bash
150
+ # Environment variable
151
+ export PI_ROTATOR_DIR=/path/to/config
152
+
153
+ # Or CLI flag
154
+ pi-antigravity-rotator start --config-dir /path/to/config
155
+ ```
156
+
157
+ `accounts.json` is created automatically by the login command:
145
158
 
146
159
  ```json
147
160
  {
@@ -185,6 +198,7 @@ All configuration is in `accounts.json`, created automatically by `npm run login
185
198
  | `GET` | `/dashboard` | Web dashboard |
186
199
  | `GET` | `/api/status` | JSON status: accounts, quotas, model routing, flags |
187
200
  | `POST` | `/api/enable/<email>` | Clear flagged/disabled state and re-enable an account |
201
+ | `POST` | `/api/reset-cooldowns` | Clear all cooldowns on all accounts |
188
202
  | `POST` | `/v1internal:streamGenerateContent` | Proxy endpoint (used by pi) |
189
203
 
190
204
  ## Running as a Service
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Multi-account rotation proxy for Google Antigravity with per-model routing, real-time quota tracking, and infringement detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,8 @@
20
20
  "keywords": [
21
21
  "pi-package",
22
22
  "pi",
23
+ "pi-coding",
24
+ "pi-coding-agent",
23
25
  "antigravity",
24
26
  "proxy",
25
27
  "rotation",
@@ -31,7 +33,7 @@
31
33
  "type": "git",
32
34
  "url": "https://github.com/tuxevil/pi-antigravity-rotator.git"
33
35
  },
34
- "author": "tuxevil",
36
+ "author": "Sebastián Real (tuxevil)",
35
37
  "dependencies": {
36
38
  "tsx": "^4.19.0"
37
39
  },
package/src/dashboard.ts CHANGED
@@ -22,6 +22,12 @@ export function serveEnableApi(res: ServerResponse, rotator: AccountRotator, ema
22
22
  res.end(JSON.stringify({ ok, email }));
23
23
  }
24
24
 
25
+ export function serveResetCooldownsApi(res: ServerResponse, rotator: AccountRotator): void {
26
+ const count = rotator.resetAllCooldowns();
27
+ res.writeHead(200, { "Content-Type": "application/json" });
28
+ res.end(JSON.stringify({ ok: true, resetCount: count }));
29
+ }
30
+
25
31
  const DASHBOARD_HTML = `<!DOCTYPE html>
26
32
  <html lang="en">
27
33
  <head>
@@ -248,6 +254,17 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
248
254
  word-break: break-all;
249
255
  }
250
256
 
257
+ .card-hint {
258
+ margin-top: 6px;
259
+ padding: 8px 10px;
260
+ background: rgba(250, 204, 21, 0.08);
261
+ border-radius: 8px;
262
+ border-left: 3px solid var(--yellow);
263
+ font-size: 11px;
264
+ color: var(--yellow);
265
+ line-height: 1.4;
266
+ }
267
+
251
268
  .card-actions { margin-top: 10px; display: flex; gap: 8px; }
252
269
 
253
270
  .btn-enable {
@@ -358,7 +375,8 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
358
375
  <div class="header-stats">
359
376
  Uptime: <span id="uptime">--</span> |
360
377
  Port: <span id="port">--</span> |
361
- Rotation: <span id="rotation">--</span> reqs
378
+ Rotation: <span id="rotation">--</span> reqs |
379
+ <button id="maskBtn" onclick="toggleMask()" style="background:none;border:1px solid var(--border);color:var(--text-dim);padding:2px 8px;border-radius:4px;cursor:pointer;font-size:12px;font-family:inherit;">PII: Visible</button>
362
380
  </div>
363
381
  </div>
364
382
 
@@ -437,7 +455,7 @@ function renderModelRouting(activeAccounts) {
437
455
  return '<div class="model-route">' +
438
456
  '<span class="model-name">' + e[0] + '</span>' +
439
457
  '<span class="route-arrow">-></span>' +
440
- '<span class="account-name">' + e[1] + '</span>' +
458
+ '<span class="account-name">' + maskText(e[1]) + '</span>' +
441
459
  '</div>';
442
460
  }).join('');
443
461
  container.innerHTML = '<div class="model-routing-title">Model Routing</div>' + rows;
@@ -456,7 +474,15 @@ function renderAccounts(data) {
456
474
  renderModelRouting(data.activeAccounts);
457
475
 
458
476
  var container = document.getElementById('accounts');
459
- container.innerHTML = data.accounts.map(function(a) {
477
+ var sorted = data.accounts.slice().sort(function(a, b) {
478
+ var aFlagged = a.status === 'flagged' || a.status === 'disabled' ? 1 : 0;
479
+ var bFlagged = b.status === 'flagged' || b.status === 'disabled' ? 1 : 0;
480
+ if (aFlagged !== bFlagged) return aFlagged - bFlagged;
481
+ var aQuota = (a.quota || []).reduce(function(s, q) { return s + q.percentRemaining; }, 0);
482
+ var bQuota = (b.quota || []).reduce(function(s, q) { return s + q.percentRemaining; }, 0);
483
+ return bQuota - aQuota;
484
+ });
485
+ container.innerHTML = sorted.map(function(a) {
460
486
  var isActive = a.status === 'active';
461
487
  var isCooldown = a.status === 'cooldown' || a.status === 'exhausted';
462
488
  var isDisabled = a.status === 'disabled' || a.status === 'flagged';
@@ -473,13 +499,13 @@ function renderAccounts(data) {
473
499
 
474
500
  return '<div class="account-card ' + a.status + '">' +
475
501
  '<div class="card-header">' +
476
- '<div class="card-label">' + a.label + '</div>' +
502
+ '<div class="card-label">' + maskText(a.label) + '</div>' +
477
503
  '<div class="card-badges">' +
478
504
  '<span class="badge badge-' + a.status + (isActive ? ' pulse' : '') + '">' + a.status + '</span>' +
479
505
  modelBadges +
480
506
  '</div>' +
481
507
  '</div>' +
482
- '<div class="card-email">' + a.email + '</div>' +
508
+ '<div class="card-email">' + maskEmail(a.email) + '</div>' +
483
509
  (a.quota && a.quota.length > 0 ? renderQuotaBars(a.quota) : '') +
484
510
  '<div class="card-stats">' +
485
511
  '<div class="card-stat"><div class="stat-label">Requests</div><div class="stat-value">' +
@@ -492,7 +518,12 @@ function renderAccounts(data) {
492
518
  (a.hasValidToken ? 'var(--green)' : 'var(--text-dim)') + '">' +
493
519
  (a.hasValidToken ? 'Valid' : 'Expired') + '</div></div>' +
494
520
  '</div>' +
495
- (a.lastError ? '<div class="card-error">' + a.lastError.slice(0, 150) + '</div>' : '') +
521
+ (a.lastError ? '<div class="card-error">' + a.lastError.slice(0, 150) + '</div>' +
522
+ (a.lastError.toLowerCase().includes('verif') ?
523
+ '<div class="card-hint">Fix: Open Antigravity IDE, sign in with this account, and complete the verification prompt. Then click Re-enable.</div>' :
524
+ a.lastError.toLowerCase().includes('terms of service') ?
525
+ '<div class="card-hint">This account was suspended by Google. Submit an appeal at <a href="https://support.google.com/accounts/troubleshooter/2402620" target="_blank" style="color:var(--cyan)">Google Account Recovery</a>, then Re-enable.</div>' :
526
+ '') : '') +
496
527
  (isDisabled ? '<div class="card-actions"><button class="btn-enable" onclick="enableAccount(\\'' +
497
528
  a.email + '\\')">Re-enable</button></div>' : '') +
498
529
  (isCooldown && cooldownPercent > 0 ? '<div class="cooldown-bar" style="width:' + cooldownPercent + '%"></div>' : '') +
@@ -500,6 +531,25 @@ function renderAccounts(data) {
500
531
  }).join('');
501
532
  }
502
533
 
534
+ var MASK_MODE = new URLSearchParams(window.location.search).has('mask');
535
+ var maskCounter = 0;
536
+ var maskMap = {};
537
+
538
+ function maskText(text) {
539
+ if (!MASK_MODE) return text;
540
+ if (!maskMap[text]) {
541
+ maskCounter++;
542
+ maskMap[text] = 'Account ' + maskCounter;
543
+ }
544
+ return maskMap[text];
545
+ }
546
+
547
+ function maskEmail(email) {
548
+ if (!MASK_MODE) return email;
549
+ var masked = maskText(email.split('@')[0]);
550
+ return masked.toLowerCase().replace(/ /g, '-') + '@***.com';
551
+ }
552
+
503
553
  async function enableAccount(email) {
504
554
  await fetch('/api/enable/' + encodeURIComponent(email), { method: 'POST' });
505
555
  refresh();
@@ -510,11 +560,23 @@ async function refresh() {
510
560
  var res = await fetch('/api/status');
511
561
  var data = await res.json();
512
562
  renderAccounts(data);
563
+ var btn = document.getElementById('maskBtn');
564
+ if (btn) btn.textContent = MASK_MODE ? 'PII: Hidden' : 'PII: Visible';
513
565
  } catch (err) {
514
566
  console.error('Status fetch failed:', err);
515
567
  }
516
568
  }
517
569
 
570
+ function toggleMask() {
571
+ var url = new URL(window.location);
572
+ if (MASK_MODE) {
573
+ url.searchParams.delete('mask');
574
+ } else {
575
+ url.searchParams.set('mask', '1');
576
+ }
577
+ window.location.href = url.toString();
578
+ }
579
+
518
580
  refresh();
519
581
  setInterval(refresh, 3000);
520
582
  </script>
package/src/proxy.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  // HTTP reverse proxy - forwards requests to Antigravity with credential rotation
2
2
 
3
3
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4
+ import { Readable } from "node:stream";
4
5
  import { ANTIGRAVITY_ENDPOINTS } from "./types.js";
5
6
  import type { AccountRuntime } from "./types.js";
6
7
  import type { AccountRotator } from "./rotator.js";
7
- import { serveDashboard, serveStatusApi, serveEnableApi } from "./dashboard.js";
8
+ import { serveDashboard, serveStatusApi, serveEnableApi, serveResetCooldownsApi } from "./dashboard.js";
8
9
 
9
10
  const MAX_ENDPOINT_RETRIES = 3;
11
+ const MAX_COOLDOWN_MS = 30 * 60 * 1000; // 30 minutes max cooldown
10
12
 
11
13
  interface RequestBody {
12
14
  project: string;
@@ -73,6 +75,10 @@ function extractRetryDelay(errorText: string, headers: Headers): number {
73
75
  return 60_000;
74
76
  }
75
77
 
78
+ function capCooldown(ms: number): number {
79
+ return Math.min(ms, MAX_COOLDOWN_MS);
80
+ }
81
+
76
82
  /**
77
83
  * Read the full request body from an IncomingMessage.
78
84
  */
@@ -87,14 +93,12 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
87
93
 
88
94
  /**
89
95
  * Forward a request to the real Antigravity endpoint with credential swapping.
90
- * Handles endpoint cascade (daily -> autopush -> prod) and 429 retry.
91
96
  */
92
97
  async function forwardRequest(
93
98
  account: AccountRuntime,
94
99
  body: RequestBody,
95
100
  originalHeaders: Record<string, string>,
96
- rotator: AccountRotator,
97
- ): Promise<{ status: number; headers: Headers; body: ReadableStream<Uint8Array> | null }> {
101
+ ): Promise<Response> {
98
102
  // Swap credentials
99
103
  body.project = account.config.projectId;
100
104
  const requestBody = JSON.stringify(body);
@@ -117,32 +121,34 @@ async function forwardRequest(
117
121
  delete forwardHeaders["transfer-encoding"];
118
122
  delete forwardHeaders["content-length"];
119
123
 
120
- // Try endpoints with cascade on 403/404
124
+ // Try endpoints with cascade on 401/403/404
121
125
  for (let endpointIdx = 0; endpointIdx < ANTIGRAVITY_ENDPOINTS.length; endpointIdx++) {
122
126
  const endpoint = ANTIGRAVITY_ENDPOINTS[endpointIdx];
123
127
  const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
128
+ const isProd = endpointIdx === ANTIGRAVITY_ENDPOINTS.length - 1;
124
129
 
125
130
  try {
131
+ const controller = !isProd ? new AbortController() : undefined;
132
+ const timeout = controller ? setTimeout(() => controller.abort(), 10_000) : undefined;
133
+
126
134
  const response = await fetch(url, {
127
135
  method: "POST",
128
136
  headers: forwardHeaders,
129
137
  body: requestBody,
138
+ signal: controller?.signal,
130
139
  });
140
+ if (timeout) clearTimeout(timeout);
131
141
 
132
- // On 401/403/404, try next endpoint
133
142
  if ((response.status === 401 || response.status === 403 || response.status === 404) && endpointIdx < ANTIGRAVITY_ENDPOINTS.length - 1) {
134
143
  log(`Endpoint ${endpoint} returned ${response.status}, cascading...`);
144
+ response.text().catch(() => {});
135
145
  continue;
136
146
  }
137
147
 
138
- return {
139
- status: response.status,
140
- headers: response.headers,
141
- body: response.body,
142
- };
148
+ return response;
143
149
  } catch (err) {
144
150
  if (endpointIdx < ANTIGRAVITY_ENDPOINTS.length - 1) {
145
- log(`Endpoint ${endpoint} failed: ${err}, cascading...`);
151
+ log(`Endpoint ${endpoint} failed: ${err instanceof Error ? err.message : err}, cascading...`);
146
152
  continue;
147
153
  }
148
154
  throw err;
@@ -175,7 +181,6 @@ async function handleProxyRequest(
175
181
  return;
176
182
  }
177
183
 
178
- // Try up to MAX_ENDPOINT_RETRIES accounts on 429
179
184
  for (let attempt = 0; attempt < MAX_ENDPOINT_RETRIES; attempt++) {
180
185
  const account = await rotator.getActiveAccount(body.model);
181
186
  if (!account) {
@@ -188,31 +193,29 @@ async function handleProxyRequest(
188
193
  log(`[${label}] Forwarding ${body.model} request (attempt ${attempt + 1})`);
189
194
 
190
195
  try {
191
- const upstream = await forwardRequest(account, { ...body }, flattenHeaders(req.headers), rotator);
196
+ const response = await forwardRequest(account, { ...body }, flattenHeaders(req.headers));
192
197
 
193
- if (upstream.status === 429) {
194
- // Rate limited - extract delay, mark exhausted, try next account
195
- const errorText = upstream.body ? await streamToString(upstream.body) : "";
196
- const cooldownMs = extractRetryDelay(errorText, upstream.headers);
198
+ if (response.status === 429) {
199
+ const errorText = await response.text().catch(() => "");
200
+ const cooldownMs = capCooldown(extractRetryDelay(errorText, response.headers));
197
201
  log(`[${label}] 429 rate limited, cooldown ${Math.ceil(cooldownMs / 1000)}s`);
198
202
  rotator.markExhausted(account, cooldownMs);
199
203
  await rotator.rotateToNext(body.model);
200
204
  continue;
201
205
  }
202
206
 
203
- if (upstream.status === 401) {
204
- // Token was valid but API rejected it - account is blocked
205
- const errorText = upstream.body ? await streamToString(upstream.body) : "";
207
+ if (response.status === 401) {
208
+ const errorText = await response.text().catch(() => "");
206
209
  log(`[${label}] BLOCKED (401): ${errorText.slice(0, 200)}`);
207
210
  rotator.markFlagged(account, `Account blocked (401): ${errorText.slice(0, 300)}`);
208
211
  await rotator.rotateToNext(body.model);
209
212
  continue;
210
213
  }
211
214
 
212
- if (upstream.status === 403) {
213
- const errorText = upstream.body ? await streamToString(upstream.body) : "";
215
+ if (response.status === 403) {
216
+ const errorText = await response.text().catch(() => "");
214
217
  const lower = errorText.toLowerCase();
215
- const flagPatterns = ["infring", "suspend", "abus", "terminat", "violat", "banned", "policy", "forbidden"];
218
+ const flagPatterns = ["infring", "suspend", "abus", "terminat", "violat", "banned", "policy", "forbidden", "verif"];
216
219
  const isFlagged = flagPatterns.some((p) => lower.includes(p));
217
220
 
218
221
  if (isFlagged) {
@@ -221,47 +224,56 @@ async function handleProxyRequest(
221
224
  await rotator.rotateToNext(body.model);
222
225
  continue;
223
226
  }
224
- // Non-infringement 403 falls through to cascade in forwardRequest
227
+ // Non-flagging 403: return to client
228
+ log(`[${label}] 403: ${errorText.slice(0, 200)}`);
229
+ res.writeHead(403, { "Content-Type": "application/json" });
230
+ res.end(errorText || JSON.stringify({ error: "Forbidden" }));
231
+ return;
225
232
  }
226
233
 
227
- if (upstream.status >= 500) {
228
- const errorText = upstream.body ? await streamToString(upstream.body) : "";
229
- log(`[${label}] Server error ${upstream.status}: ${errorText.slice(0, 200)}`);
230
- rotator.markError(account, `${upstream.status}: ${errorText.slice(0, 200)}`);
234
+ if (response.status >= 500) {
235
+ const errorText = await response.text().catch(() => "");
236
+ log(`[${label}] Server error ${response.status}: ${errorText.slice(0, 200)}`);
237
+ if (response.status === 503) {
238
+ res.writeHead(503, { "Content-Type": "application/json" });
239
+ res.end(errorText || JSON.stringify({ error: "Server unavailable" }));
240
+ return;
241
+ }
242
+ rotator.markError(account, `${response.status}: ${errorText.slice(0, 200)}`);
231
243
  await rotator.rotateToNext(body.model);
232
244
  continue;
233
245
  }
234
246
 
235
- // Success or client error (4xx other than 429)
247
+ // Success or non-error client response
236
248
  const shouldRotate = rotator.recordRequest(account);
237
249
 
238
- // Copy response headers
239
250
  const responseHeaders: Record<string, string> = {};
240
- upstream.headers.forEach((value, key) => {
241
- // Skip hop-by-hop headers
251
+ response.headers.forEach((value, key) => {
242
252
  if (key.toLowerCase() !== "transfer-encoding" && key.toLowerCase() !== "connection") {
243
253
  responseHeaders[key] = value;
244
254
  }
245
255
  });
246
256
 
247
- res.writeHead(upstream.status, responseHeaders);
257
+ res.writeHead(response.status, responseHeaders);
248
258
 
249
- // Stream the body back to the client
250
- if (upstream.body) {
251
- const reader = upstream.body.getReader();
259
+ // Stream body using Node.js Readable (avoids ReadableStream locking issues)
260
+ if (response.body) {
252
261
  try {
253
- while (true) {
254
- const { done, value } = await reader.read();
255
- if (done) break;
256
- res.write(value);
257
- }
262
+ const nodeStream = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream);
263
+ await new Promise<void>((resolve) => {
264
+ nodeStream.on("data", (chunk: Buffer) => res.write(chunk));
265
+ nodeStream.on("end", resolve);
266
+ nodeStream.on("error", (err) => {
267
+ log(`[${label}] Stream error: ${err}`);
268
+ resolve();
269
+ });
270
+ });
258
271
  } catch (err) {
259
- log(`[${label}] Stream error: ${err}`);
272
+ log(`[${label}] Stream setup error: ${err}`);
260
273
  }
261
274
  }
262
275
  res.end();
263
276
 
264
- // Rotate after response completes if threshold was hit
265
277
  if (shouldRotate) {
266
278
  await rotator.rotateToNext(body.model);
267
279
  }
@@ -269,13 +281,18 @@ async function handleProxyRequest(
269
281
  } catch (err) {
270
282
  log(`[${label}] Request failed: ${err}`);
271
283
  rotator.markError(account, err instanceof Error ? err.message : String(err));
284
+ if (res.headersSent) {
285
+ res.end();
286
+ return;
287
+ }
272
288
  await rotator.rotateToNext(body.model);
273
289
  continue;
274
290
  }
275
291
  }
276
292
 
277
- // All retry attempts exhausted
278
- res.writeHead(502, { "Content-Type": "application/json" });
293
+ if (!res.headersSent) {
294
+ res.writeHead(502, { "Content-Type": "application/json" });
295
+ }
279
296
  res.end(JSON.stringify({ error: "All retry attempts failed" }));
280
297
  }
281
298
 
@@ -289,25 +306,13 @@ function flattenHeaders(headers: IncomingMessage["headers"]): Record<string, str
289
306
  return flat;
290
307
  }
291
308
 
292
- async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
293
- const reader = stream.getReader();
294
- const decoder = new TextDecoder();
295
- let result = "";
296
- while (true) {
297
- const { done, value } = await reader.read();
298
- if (done) break;
299
- result += decoder.decode(value, { stream: true });
300
- }
301
- return result;
302
- }
303
-
304
309
  export function startProxy(rotator: AccountRotator, port: number): void {
305
310
  const server = createServer((req, res) => {
306
- const url = req.url || "/";
307
- const method = req.method || "GET";
311
+ const method = req.method?.toUpperCase();
312
+ const url = req.url || "";
313
+ const pathname = url.split("?")[0];
308
314
 
309
- // Dashboard and API routes
310
- if (method === "GET" && (url === "/" || url === "/dashboard")) {
315
+ if (method === "GET" && (pathname === "/" || pathname === "/dashboard")) {
311
316
  serveDashboard(res);
312
317
  return;
313
318
  }
@@ -323,6 +328,11 @@ export function startProxy(rotator: AccountRotator, port: number): void {
323
328
  return;
324
329
  }
325
330
 
331
+ if (method === "POST" && url === "/api/reset-cooldowns") {
332
+ serveResetCooldownsApi(res, rotator);
333
+ return;
334
+ }
335
+
326
336
  // Proxy route
327
337
  if (method === "POST" && url.includes("v1internal")) {
328
338
  handleProxyRequest(req, res, rotator).catch((err) => {
@@ -335,24 +345,12 @@ export function startProxy(rotator: AccountRotator, port: number): void {
335
345
  return;
336
346
  }
337
347
 
338
- // 404
339
348
  res.writeHead(404, { "Content-Type": "application/json" });
340
349
  res.end(JSON.stringify({ error: "Not found" }));
341
350
  });
342
351
 
343
352
  server.listen(port, "0.0.0.0", () => {
344
- log(`Proxy listening on http://0.0.0.0:${port}`);
345
- log(`Dashboard: http://localhost:${port}/dashboard`);
346
- log(`API endpoint: http://localhost:${port}/v1internal:streamGenerateContent?alt=sse`);
353
+ console.log(`[proxy] Listening on 0.0.0.0:${port}`);
354
+ console.log(`[proxy] Dashboard: http://localhost:${port}/dashboard`);
347
355
  });
348
-
349
- // Graceful shutdown
350
- const shutdown = () => {
351
- log("Shutting down...");
352
- rotator.saveState();
353
- server.close();
354
- process.exit(0);
355
- };
356
- process.on("SIGINT", shutdown);
357
- process.on("SIGTERM", shutdown);
358
356
  }
package/src/rotator.ts CHANGED
@@ -87,6 +87,14 @@ export class AccountRotator {
87
87
  account.flagged = saved.flagged ?? false;
88
88
  }
89
89
  }
90
+ // Cap any stale cooldowns to 30 min max from now
91
+ const maxCooldown = 30 * 60 * 1000;
92
+ const now = Date.now();
93
+ for (const account of this.accounts) {
94
+ if (account.cooldownUntil > now + maxCooldown) {
95
+ account.cooldownUntil = now + maxCooldown;
96
+ }
97
+ }
90
98
  this.log("Loaded state from disk");
91
99
  } catch {
92
100
  this.log("Could not load state, starting fresh");
@@ -170,10 +178,21 @@ export class AccountRotator {
170
178
  } else {
171
179
  const drop = mState.quotaAtRotationStart - modelQuota;
172
180
  if (drop >= this.config.rotateOnQuotaDrop) {
173
- this.log(
174
- `${account.config.label || account.config.email} [${modelKey}]: quota dropped ${drop}% (${mState.quotaAtRotationStart}% -> ${modelQuota}%), rotating`,
181
+ // Only rotate if there's a healthy account to rotate to
182
+ const hasHealthy = this.accounts.some(
183
+ (a, idx) => idx !== mState.activeAccountIndex && this.isAvailable(a, Date.now()),
175
184
  );
176
- await this.rotateModel(modelKey);
185
+ if (hasHealthy) {
186
+ this.log(
187
+ `${account.config.label || account.config.email} [${modelKey}]: quota dropped ${drop}% (${mState.quotaAtRotationStart}% -> ${modelQuota}%), rotating`,
188
+ );
189
+ await this.rotateModel(modelKey);
190
+ } else {
191
+ this.log(
192
+ `${account.config.label || account.config.email} [${modelKey}]: quota dropped ${drop}% but no healthy accounts available, staying`,
193
+ );
194
+ mState.quotaAtRotationStart = modelQuota; // Reset baseline
195
+ }
177
196
  }
178
197
  }
179
198
  }
@@ -291,6 +310,16 @@ export class AccountRotator {
291
310
 
292
311
  const current = this.accounts[idx];
293
312
  if (current && this.isAvailable(current, now)) {
313
+ // Check if this account has quota for the requested model
314
+ if (modelKey) {
315
+ const quota = this.getModelQuota(current, modelKey);
316
+ if (quota === 0) {
317
+ this.log(
318
+ `${current.config.label || current.config.email} [${modelKey}]: 0% quota, skipping`,
319
+ );
320
+ return this.rotateModel(modelKey);
321
+ }
322
+ }
294
323
  await this.ensureValidToken(current);
295
324
  return current;
296
325
  }
@@ -314,9 +343,13 @@ export class AccountRotator {
314
343
  const account = this.accounts[i];
315
344
 
316
345
  if (this.isAvailable(account, now)) {
317
- const priority = this.getModelTimerPriority(account, modelKey);
318
346
  const quota = this.getModelQuota(account, modelKey);
319
347
 
348
+ // Skip accounts with 0% quota for this model (they will 429)
349
+ if (quota === 0) continue;
350
+
351
+ const priority = this.getModelTimerPriority(account, modelKey);
352
+
320
353
  if (priority < bestPriority || (priority === bestPriority && quota > bestQuota)) {
321
354
  best = account;
322
355
  bestPriority = priority;
@@ -455,6 +488,22 @@ export class AccountRotator {
455
488
  return true;
456
489
  }
457
490
 
491
+ resetAllCooldowns(): number {
492
+ let count = 0;
493
+ for (const account of this.accounts) {
494
+ if (account.cooldownUntil > Date.now()) {
495
+ account.cooldownUntil = 0;
496
+ account.quotaExhaustedAt = 0;
497
+ count++;
498
+ }
499
+ }
500
+ if (count > 0) {
501
+ this.saveState();
502
+ this.log(`Reset cooldowns on ${count} accounts`);
503
+ }
504
+ return count;
505
+ }
506
+
458
507
  async ensureValidToken(account: AccountRuntime): Promise<void> {
459
508
  const now = Date.now();
460
509
  if (account.accessToken && account.tokenExpires > now) {
package/src/types.ts CHANGED
@@ -59,7 +59,7 @@ export const QUOTA_MODEL_KEYS: Record<string, { key: string; altKeys: string[];
59
59
  },
60
60
  claude: {
61
61
  key: "claude-opus-4-6-thinking",
62
- altKeys: ["claude-opus-4-5-thinking", "claude-opus-4-5", "claude-sonnet-4-6", "claude-sonnet-4-5"],
62
+ altKeys: ["claude-opus-4-5-thinking", "claude-opus-4-5", "claude-sonnet-4-6-thinking", "claude-sonnet-4-6", "claude-sonnet-4-5-thinking", "claude-sonnet-4-5"],
63
63
  display: "Claude",
64
64
  },
65
65
  };
@@ -161,8 +161,6 @@ export const CLIENT_SECRET = atob("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQ
161
161
  export const TOKEN_URL = "https://oauth2.googleapis.com/token";
162
162
 
163
163
  export const ANTIGRAVITY_ENDPOINTS = [
164
- "https://daily-cloudcode-pa.sandbox.googleapis.com",
165
- "https://autopush-cloudcode-pa.sandbox.googleapis.com",
166
164
  "https://cloudcode-pa.googleapis.com",
167
165
  ] as const;
168
166