pi-antigravity-rotator 1.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "1.1.0",
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",
package/src/dashboard.ts CHANGED
@@ -254,6 +254,17 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
254
254
  word-break: break-all;
255
255
  }
256
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
+
257
268
  .card-actions { margin-top: 10px; display: flex; gap: 8px; }
258
269
 
259
270
  .btn-enable {
@@ -364,7 +375,8 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
364
375
  <div class="header-stats">
365
376
  Uptime: <span id="uptime">--</span> |
366
377
  Port: <span id="port">--</span> |
367
- 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>
368
380
  </div>
369
381
  </div>
370
382
 
@@ -443,7 +455,7 @@ function renderModelRouting(activeAccounts) {
443
455
  return '<div class="model-route">' +
444
456
  '<span class="model-name">' + e[0] + '</span>' +
445
457
  '<span class="route-arrow">-></span>' +
446
- '<span class="account-name">' + e[1] + '</span>' +
458
+ '<span class="account-name">' + maskText(e[1]) + '</span>' +
447
459
  '</div>';
448
460
  }).join('');
449
461
  container.innerHTML = '<div class="model-routing-title">Model Routing</div>' + rows;
@@ -487,13 +499,13 @@ function renderAccounts(data) {
487
499
 
488
500
  return '<div class="account-card ' + a.status + '">' +
489
501
  '<div class="card-header">' +
490
- '<div class="card-label">' + a.label + '</div>' +
502
+ '<div class="card-label">' + maskText(a.label) + '</div>' +
491
503
  '<div class="card-badges">' +
492
504
  '<span class="badge badge-' + a.status + (isActive ? ' pulse' : '') + '">' + a.status + '</span>' +
493
505
  modelBadges +
494
506
  '</div>' +
495
507
  '</div>' +
496
- '<div class="card-email">' + a.email + '</div>' +
508
+ '<div class="card-email">' + maskEmail(a.email) + '</div>' +
497
509
  (a.quota && a.quota.length > 0 ? renderQuotaBars(a.quota) : '') +
498
510
  '<div class="card-stats">' +
499
511
  '<div class="card-stat"><div class="stat-label">Requests</div><div class="stat-value">' +
@@ -506,7 +518,12 @@ function renderAccounts(data) {
506
518
  (a.hasValidToken ? 'var(--green)' : 'var(--text-dim)') + '">' +
507
519
  (a.hasValidToken ? 'Valid' : 'Expired') + '</div></div>' +
508
520
  '</div>' +
509
- (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
+ '') : '') +
510
527
  (isDisabled ? '<div class="card-actions"><button class="btn-enable" onclick="enableAccount(\\'' +
511
528
  a.email + '\\')">Re-enable</button></div>' : '') +
512
529
  (isCooldown && cooldownPercent > 0 ? '<div class="cooldown-bar" style="width:' + cooldownPercent + '%"></div>' : '') +
@@ -514,6 +531,25 @@ function renderAccounts(data) {
514
531
  }).join('');
515
532
  }
516
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
+
517
553
  async function enableAccount(email) {
518
554
  await fetch('/api/enable/' + encodeURIComponent(email), { method: 'POST' });
519
555
  refresh();
@@ -524,11 +560,23 @@ async function refresh() {
524
560
  var res = await fetch('/api/status');
525
561
  var data = await res.json();
526
562
  renderAccounts(data);
563
+ var btn = document.getElementById('maskBtn');
564
+ if (btn) btn.textContent = MASK_MODE ? 'PII: Hidden' : 'PII: Visible';
527
565
  } catch (err) {
528
566
  console.error('Status fetch failed:', err);
529
567
  }
530
568
  }
531
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
+
532
580
  refresh();
533
581
  setInterval(refresh, 3000);
534
582
  </script>
package/src/proxy.ts CHANGED
@@ -1,6 +1,7 @@
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";
@@ -92,14 +93,12 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
92
93
 
93
94
  /**
94
95
  * Forward a request to the real Antigravity endpoint with credential swapping.
95
- * Handles endpoint cascade (daily -> autopush -> prod) and 429 retry.
96
96
  */
97
97
  async function forwardRequest(
98
98
  account: AccountRuntime,
99
99
  body: RequestBody,
100
100
  originalHeaders: Record<string, string>,
101
- rotator: AccountRotator,
102
- ): Promise<{ status: number; headers: Headers; body: ReadableStream<Uint8Array> | null }> {
101
+ ): Promise<Response> {
103
102
  // Swap credentials
104
103
  body.project = account.config.projectId;
105
104
  const requestBody = JSON.stringify(body);
@@ -129,11 +128,9 @@ async function forwardRequest(
129
128
  const isProd = endpointIdx === ANTIGRAVITY_ENDPOINTS.length - 1;
130
129
 
131
130
  try {
132
- // Timeout non-prod endpoints after 10s to avoid long hangs
133
131
  const controller = !isProd ? new AbortController() : undefined;
134
132
  const timeout = controller ? setTimeout(() => controller.abort(), 10_000) : undefined;
135
133
 
136
- const fetchStart = Date.now();
137
134
  const response = await fetch(url, {
138
135
  method: "POST",
139
136
  headers: forwardHeaders,
@@ -142,23 +139,13 @@ async function forwardRequest(
142
139
  });
143
140
  if (timeout) clearTimeout(timeout);
144
141
 
145
- const fetchMs = Date.now() - fetchStart;
146
-
147
- // On 401/403/404, try next endpoint
148
142
  if ((response.status === 401 || response.status === 403 || response.status === 404) && endpointIdx < ANTIGRAVITY_ENDPOINTS.length - 1) {
149
- log(`Endpoint ${endpoint} returned ${response.status} (${fetchMs}ms), cascading...`);
143
+ log(`Endpoint ${endpoint} returned ${response.status}, cascading...`);
144
+ response.text().catch(() => {});
150
145
  continue;
151
146
  }
152
147
 
153
- if (endpointIdx > 0) {
154
- log(`Using endpoint ${endpoint} (${fetchMs}ms)`);
155
- }
156
-
157
- return {
158
- status: response.status,
159
- headers: response.headers,
160
- body: response.body,
161
- };
148
+ return response;
162
149
  } catch (err) {
163
150
  if (endpointIdx < ANTIGRAVITY_ENDPOINTS.length - 1) {
164
151
  log(`Endpoint ${endpoint} failed: ${err instanceof Error ? err.message : err}, cascading...`);
@@ -194,7 +181,6 @@ async function handleProxyRequest(
194
181
  return;
195
182
  }
196
183
 
197
- // Try up to MAX_ENDPOINT_RETRIES accounts on 429
198
184
  for (let attempt = 0; attempt < MAX_ENDPOINT_RETRIES; attempt++) {
199
185
  const account = await rotator.getActiveAccount(body.model);
200
186
  if (!account) {
@@ -207,31 +193,29 @@ async function handleProxyRequest(
207
193
  log(`[${label}] Forwarding ${body.model} request (attempt ${attempt + 1})`);
208
194
 
209
195
  try {
210
- const upstream = await forwardRequest(account, { ...body }, flattenHeaders(req.headers), rotator);
196
+ const response = await forwardRequest(account, { ...body }, flattenHeaders(req.headers));
211
197
 
212
- if (upstream.status === 429) {
213
- // Rate limited - extract delay, mark exhausted, try next account
214
- const errorText = upstream.body ? await streamToString(upstream.body) : "";
215
- const cooldownMs = capCooldown(extractRetryDelay(errorText, upstream.headers));
198
+ if (response.status === 429) {
199
+ const errorText = await response.text().catch(() => "");
200
+ const cooldownMs = capCooldown(extractRetryDelay(errorText, response.headers));
216
201
  log(`[${label}] 429 rate limited, cooldown ${Math.ceil(cooldownMs / 1000)}s`);
217
202
  rotator.markExhausted(account, cooldownMs);
218
203
  await rotator.rotateToNext(body.model);
219
204
  continue;
220
205
  }
221
206
 
222
- if (upstream.status === 401) {
223
- // Token was valid but API rejected it - account is blocked
224
- const errorText = upstream.body ? await streamToString(upstream.body) : "";
207
+ if (response.status === 401) {
208
+ const errorText = await response.text().catch(() => "");
225
209
  log(`[${label}] BLOCKED (401): ${errorText.slice(0, 200)}`);
226
210
  rotator.markFlagged(account, `Account blocked (401): ${errorText.slice(0, 300)}`);
227
211
  await rotator.rotateToNext(body.model);
228
212
  continue;
229
213
  }
230
214
 
231
- if (upstream.status === 403) {
232
- const errorText = upstream.body ? await streamToString(upstream.body) : "";
215
+ if (response.status === 403) {
216
+ const errorText = await response.text().catch(() => "");
233
217
  const lower = errorText.toLowerCase();
234
- const flagPatterns = ["infring", "suspend", "abus", "terminat", "violat", "banned", "policy", "forbidden"];
218
+ const flagPatterns = ["infring", "suspend", "abus", "terminat", "violat", "banned", "policy", "forbidden", "verif"];
235
219
  const isFlagged = flagPatterns.some((p) => lower.includes(p));
236
220
 
237
221
  if (isFlagged) {
@@ -240,54 +224,56 @@ async function handleProxyRequest(
240
224
  await rotator.rotateToNext(body.model);
241
225
  continue;
242
226
  }
243
- // 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;
244
232
  }
245
233
 
246
- if (upstream.status >= 500) {
247
- const errorText = upstream.body ? await streamToString(upstream.body) : "";
248
- log(`[${label}] Server error ${upstream.status}: ${errorText.slice(0, 200)}`);
249
- // On 503, don't rotate -- it's a Google capacity issue, not an account issue
250
- // Return the error to the agent so it can handle retries/backoff
251
- if (upstream.status === 503) {
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) {
252
238
  res.writeHead(503, { "Content-Type": "application/json" });
253
239
  res.end(errorText || JSON.stringify({ error: "Server unavailable" }));
254
240
  return;
255
241
  }
256
- rotator.markError(account, `${upstream.status}: ${errorText.slice(0, 200)}`);
242
+ rotator.markError(account, `${response.status}: ${errorText.slice(0, 200)}`);
257
243
  await rotator.rotateToNext(body.model);
258
244
  continue;
259
245
  }
260
246
 
261
- // Success or client error (4xx other than 429)
247
+ // Success or non-error client response
262
248
  const shouldRotate = rotator.recordRequest(account);
263
249
 
264
- // Copy response headers
265
250
  const responseHeaders: Record<string, string> = {};
266
- upstream.headers.forEach((value, key) => {
267
- // Skip hop-by-hop headers
251
+ response.headers.forEach((value, key) => {
268
252
  if (key.toLowerCase() !== "transfer-encoding" && key.toLowerCase() !== "connection") {
269
253
  responseHeaders[key] = value;
270
254
  }
271
255
  });
272
256
 
273
- res.writeHead(upstream.status, responseHeaders);
257
+ res.writeHead(response.status, responseHeaders);
274
258
 
275
- // Stream the body back to the client
276
- if (upstream.body) {
277
- const reader = upstream.body.getReader();
259
+ // Stream body using Node.js Readable (avoids ReadableStream locking issues)
260
+ if (response.body) {
278
261
  try {
279
- while (true) {
280
- const { done, value } = await reader.read();
281
- if (done) break;
282
- res.write(value);
283
- }
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
+ });
284
271
  } catch (err) {
285
- log(`[${label}] Stream error: ${err}`);
272
+ log(`[${label}] Stream setup error: ${err}`);
286
273
  }
287
274
  }
288
275
  res.end();
289
276
 
290
- // Rotate after response completes if threshold was hit
291
277
  if (shouldRotate) {
292
278
  await rotator.rotateToNext(body.model);
293
279
  }
@@ -295,13 +281,18 @@ async function handleProxyRequest(
295
281
  } catch (err) {
296
282
  log(`[${label}] Request failed: ${err}`);
297
283
  rotator.markError(account, err instanceof Error ? err.message : String(err));
284
+ if (res.headersSent) {
285
+ res.end();
286
+ return;
287
+ }
298
288
  await rotator.rotateToNext(body.model);
299
289
  continue;
300
290
  }
301
291
  }
302
292
 
303
- // All retry attempts exhausted
304
- res.writeHead(502, { "Content-Type": "application/json" });
293
+ if (!res.headersSent) {
294
+ res.writeHead(502, { "Content-Type": "application/json" });
295
+ }
305
296
  res.end(JSON.stringify({ error: "All retry attempts failed" }));
306
297
  }
307
298
 
@@ -315,25 +306,13 @@ function flattenHeaders(headers: IncomingMessage["headers"]): Record<string, str
315
306
  return flat;
316
307
  }
317
308
 
318
- async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
319
- const reader = stream.getReader();
320
- const decoder = new TextDecoder();
321
- let result = "";
322
- while (true) {
323
- const { done, value } = await reader.read();
324
- if (done) break;
325
- result += decoder.decode(value, { stream: true });
326
- }
327
- return result;
328
- }
329
-
330
309
  export function startProxy(rotator: AccountRotator, port: number): void {
331
310
  const server = createServer((req, res) => {
332
- const url = req.url || "/";
333
- const method = req.method || "GET";
311
+ const method = req.method?.toUpperCase();
312
+ const url = req.url || "";
313
+ const pathname = url.split("?")[0];
334
314
 
335
- // Dashboard and API routes
336
- if (method === "GET" && (url === "/" || url === "/dashboard")) {
315
+ if (method === "GET" && (pathname === "/" || pathname === "/dashboard")) {
337
316
  serveDashboard(res);
338
317
  return;
339
318
  }
@@ -366,24 +345,12 @@ export function startProxy(rotator: AccountRotator, port: number): void {
366
345
  return;
367
346
  }
368
347
 
369
- // 404
370
348
  res.writeHead(404, { "Content-Type": "application/json" });
371
349
  res.end(JSON.stringify({ error: "Not found" }));
372
350
  });
373
351
 
374
352
  server.listen(port, "0.0.0.0", () => {
375
- log(`Proxy listening on http://0.0.0.0:${port}`);
376
- log(`Dashboard: http://localhost:${port}/dashboard`);
377
- 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`);
378
355
  });
379
-
380
- // Graceful shutdown
381
- const shutdown = () => {
382
- log("Shutting down...");
383
- rotator.saveState();
384
- server.close();
385
- process.exit(0);
386
- };
387
- process.on("SIGINT", shutdown);
388
- process.on("SIGTERM", shutdown);
389
356
  }
package/src/rotator.ts CHANGED
@@ -310,6 +310,16 @@ export class AccountRotator {
310
310
 
311
311
  const current = this.accounts[idx];
312
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
+ }
313
323
  await this.ensureValidToken(current);
314
324
  return current;
315
325
  }
@@ -333,9 +343,13 @@ export class AccountRotator {
333
343
  const account = this.accounts[i];
334
344
 
335
345
  if (this.isAvailable(account, now)) {
336
- const priority = this.getModelTimerPriority(account, modelKey);
337
346
  const quota = this.getModelQuota(account, modelKey);
338
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
+
339
353
  if (priority < bestPriority || (priority === bestPriority && quota > bestQuota)) {
340
354
  best = account;
341
355
  bestPriority = priority;
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
  };