pi-antigravity-rotator 1.1.0 → 1.3.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.3.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 {
@@ -355,6 +366,82 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
355
366
 
356
367
  .pulse { animation: pulse 2s ease-in-out infinite; }
357
368
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
369
+
370
+ .badge-pro { background: rgba(52, 211, 153, 0.15); color: var(--green); }
371
+ .badge-free { background: rgba(110, 110, 130, 0.08); color: var(--text-dim); }
372
+ .badge-fmgr { background: rgba(124, 92, 252, 0.15); color: var(--accent); font-size: 9px; }
373
+
374
+ .advisor-panel {
375
+ background: var(--surface);
376
+ border: 1px solid var(--border);
377
+ border-radius: var(--radius);
378
+ padding: 16px 18px;
379
+ margin-bottom: 24px;
380
+ }
381
+
382
+ .advisor-title {
383
+ font-size: 11px;
384
+ text-transform: uppercase;
385
+ letter-spacing: 0.8px;
386
+ color: var(--text-dim);
387
+ margin-bottom: 10px;
388
+ display: flex;
389
+ align-items: center;
390
+ gap: 8px;
391
+ }
392
+
393
+ .advisor-slots {
394
+ font-size: 12px;
395
+ font-family: 'JetBrains Mono', monospace;
396
+ color: var(--text);
397
+ margin-left: auto;
398
+ text-transform: none;
399
+ letter-spacing: 0;
400
+ }
401
+
402
+ .advisor-action {
403
+ display: flex;
404
+ align-items: center;
405
+ gap: 10px;
406
+ padding: 8px 10px;
407
+ margin-bottom: 6px;
408
+ border-radius: 8px;
409
+ font-size: 12px;
410
+ }
411
+
412
+ .advisor-action.add-pro {
413
+ background: rgba(52, 211, 153, 0.06);
414
+ border-left: 3px solid var(--green);
415
+ }
416
+
417
+ .advisor-action.remove-pro {
418
+ background: rgba(251, 191, 36, 0.06);
419
+ border-left: 3px solid var(--yellow);
420
+ }
421
+
422
+ .advisor-action-type {
423
+ font-weight: 600;
424
+ font-size: 10px;
425
+ text-transform: uppercase;
426
+ letter-spacing: 0.5px;
427
+ padding: 2px 6px;
428
+ border-radius: 4px;
429
+ flex-shrink: 0;
430
+ }
431
+
432
+ .advisor-action.add-pro .advisor-action-type {
433
+ background: rgba(52, 211, 153, 0.15);
434
+ color: var(--green);
435
+ }
436
+
437
+ .advisor-action.remove-pro .advisor-action-type {
438
+ background: rgba(251, 191, 36, 0.15);
439
+ color: var(--yellow);
440
+ }
441
+
442
+ .advisor-action-label { font-weight: 500; }
443
+ .advisor-action-reason { color: var(--text-dim); font-size: 11px; margin-left: auto; }
444
+ .advisor-empty { color: var(--text-dim); font-size: 12px; font-style: italic; }
358
445
  </style>
359
446
  </head>
360
447
  <body>
@@ -364,7 +451,8 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
364
451
  <div class="header-stats">
365
452
  Uptime: <span id="uptime">--</span> |
366
453
  Port: <span id="port">--</span> |
367
- Rotation: <span id="rotation">--</span> reqs
454
+ Rotation: <span id="rotation">--</span> reqs |
455
+ <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
456
  </div>
369
457
  </div>
370
458
 
@@ -385,6 +473,8 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
385
473
 
386
474
  <div class="model-routing" id="modelRouting"></div>
387
475
 
476
+ <div class="advisor-panel" id="proAdvisor" style="display:none"></div>
477
+
388
478
  <div class="accounts-grid" id="accounts"></div>
389
479
 
390
480
  <script>
@@ -443,7 +533,7 @@ function renderModelRouting(activeAccounts) {
443
533
  return '<div class="model-route">' +
444
534
  '<span class="model-name">' + e[0] + '</span>' +
445
535
  '<span class="route-arrow">-></span>' +
446
- '<span class="account-name">' + e[1] + '</span>' +
536
+ '<span class="account-name">' + maskText(e[1]) + '</span>' +
447
537
  '</div>';
448
538
  }).join('');
449
539
  container.innerHTML = '<div class="model-routing-title">Model Routing</div>' + rows;
@@ -487,13 +577,15 @@ function renderAccounts(data) {
487
577
 
488
578
  return '<div class="account-card ' + a.status + '">' +
489
579
  '<div class="card-header">' +
490
- '<div class="card-label">' + a.label + '</div>' +
580
+ '<div class="card-label">' + maskText(a.label) + '</div>' +
491
581
  '<div class="card-badges">' +
582
+ (a.proDetected ? '<span class="badge badge-pro">PRO</span>' : '<span class="badge badge-free">FREE</span>') +
583
+ (a.familyManager ? '<span class="badge badge-fmgr">FAMILY MGR</span>' : '') +
492
584
  '<span class="badge badge-' + a.status + (isActive ? ' pulse' : '') + '">' + a.status + '</span>' +
493
585
  modelBadges +
494
586
  '</div>' +
495
587
  '</div>' +
496
- '<div class="card-email">' + a.email + '</div>' +
588
+ '<div class="card-email">' + maskEmail(a.email) + '</div>' +
497
589
  (a.quota && a.quota.length > 0 ? renderQuotaBars(a.quota) : '') +
498
590
  '<div class="card-stats">' +
499
591
  '<div class="card-stat"><div class="stat-label">Requests</div><div class="stat-value">' +
@@ -506,12 +598,38 @@ function renderAccounts(data) {
506
598
  (a.hasValidToken ? 'var(--green)' : 'var(--text-dim)') + '">' +
507
599
  (a.hasValidToken ? 'Valid' : 'Expired') + '</div></div>' +
508
600
  '</div>' +
509
- (a.lastError ? '<div class="card-error">' + a.lastError.slice(0, 150) + '</div>' : '') +
601
+ (a.lastError ? '<div class="card-error">' + a.lastError.slice(0, 150) + '</div>' +
602
+ (a.lastError.toLowerCase().includes('verif') ?
603
+ '<div class="card-hint">Fix: Open Antigravity IDE, sign in with this account, and complete the verification prompt. Then click Re-enable.</div>' :
604
+ a.lastError.toLowerCase().includes('terms of service') ?
605
+ '<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>' :
606
+ '') : '') +
510
607
  (isDisabled ? '<div class="card-actions"><button class="btn-enable" onclick="enableAccount(\\'' +
511
608
  a.email + '\\')">Re-enable</button></div>' : '') +
512
609
  (isCooldown && cooldownPercent > 0 ? '<div class="cooldown-bar" style="width:' + cooldownPercent + '%"></div>' : '') +
513
610
  '</div>';
514
611
  }).join('');
612
+
613
+ renderProAdvisor(data.proAdvisor);
614
+ }
615
+
616
+ var MASK_MODE = new URLSearchParams(window.location.search).has('mask');
617
+ var maskCounter = 0;
618
+ var maskMap = {};
619
+
620
+ function maskText(text) {
621
+ if (!MASK_MODE) return text;
622
+ if (!maskMap[text]) {
623
+ maskCounter++;
624
+ maskMap[text] = 'Account ' + maskCounter;
625
+ }
626
+ return maskMap[text];
627
+ }
628
+
629
+ function maskEmail(email) {
630
+ if (!MASK_MODE) return email;
631
+ var masked = maskText(email.split('@')[0]);
632
+ return masked.toLowerCase().replace(/ /g, '-') + '@***.com';
515
633
  }
516
634
 
517
635
  async function enableAccount(email) {
@@ -519,16 +637,50 @@ async function enableAccount(email) {
519
637
  refresh();
520
638
  }
521
639
 
640
+ function renderProAdvisor(advisor) {
641
+ var panel = document.getElementById('proAdvisor');
642
+ if (!advisor) { panel.style.display = 'none'; return; }
643
+ panel.style.display = 'block';
644
+ var title = '<div class="advisor-title">Pro Family Advisor' +
645
+ '<span class="advisor-slots">Slots: ' + advisor.currentProCount + '/' + advisor.maxProSlots + '</span></div>';
646
+ if (advisor.actions.length === 0) {
647
+ panel.innerHTML = title + '<div class="advisor-empty">No actions recommended</div>';
648
+ return;
649
+ }
650
+ var rows = advisor.actions.map(function(a) {
651
+ var cls = a.type === 'add-pro' ? 'add-pro' : 'remove-pro';
652
+ var typeLabel = a.type === 'add-pro' ? 'Add Pro' : 'Remove Pro';
653
+ return '<div class="advisor-action ' + cls + '">' +
654
+ '<span class="advisor-action-type">' + typeLabel + '</span>' +
655
+ '<span class="advisor-action-label">' + maskText(a.label) + '</span>' +
656
+ '<span class="advisor-action-reason">' + a.reason + '</span>' +
657
+ '</div>';
658
+ }).join('');
659
+ panel.innerHTML = title + rows;
660
+ }
661
+
522
662
  async function refresh() {
523
663
  try {
524
664
  var res = await fetch('/api/status');
525
665
  var data = await res.json();
526
666
  renderAccounts(data);
667
+ var btn = document.getElementById('maskBtn');
668
+ if (btn) btn.textContent = MASK_MODE ? 'PII: Hidden' : 'PII: Visible';
527
669
  } catch (err) {
528
670
  console.error('Status fetch failed:', err);
529
671
  }
530
672
  }
531
673
 
674
+ function toggleMask() {
675
+ var url = new URL(window.location);
676
+ if (MASK_MODE) {
677
+ url.searchParams.delete('mask');
678
+ } else {
679
+ url.searchParams.set('mask', '1');
680
+ }
681
+ window.location.href = url.toString();
682
+ }
683
+
532
684
  refresh();
533
685
  setInterval(refresh, 3000);
534
686
  </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
@@ -9,6 +9,7 @@ import {
9
9
  type ModelQuota,
10
10
  type ModelRotationState,
11
11
  type PersistedState,
12
+ type ProAdvisorAction,
12
13
  type StatusResponse,
13
14
  CLIENT_ID,
14
15
  CLIENT_SECRET,
@@ -310,6 +311,16 @@ export class AccountRotator {
310
311
 
311
312
  const current = this.accounts[idx];
312
313
  if (current && this.isAvailable(current, now)) {
314
+ // Check if this account has quota for the requested model
315
+ if (modelKey) {
316
+ const quota = this.getModelQuota(current, modelKey);
317
+ if (quota === 0) {
318
+ this.log(
319
+ `${current.config.label || current.config.email} [${modelKey}]: 0% quota, skipping`,
320
+ );
321
+ return this.rotateModel(modelKey);
322
+ }
323
+ }
313
324
  await this.ensureValidToken(current);
314
325
  return current;
315
326
  }
@@ -333,9 +344,13 @@ export class AccountRotator {
333
344
  const account = this.accounts[i];
334
345
 
335
346
  if (this.isAvailable(account, now)) {
336
- const priority = this.getModelTimerPriority(account, modelKey);
337
347
  const quota = this.getModelQuota(account, modelKey);
338
348
 
349
+ // Skip accounts with 0% quota for this model (they will 429)
350
+ if (quota === 0) continue;
351
+
352
+ const priority = this.getModelTimerPriority(account, modelKey);
353
+
339
354
  if (priority < bestPriority || (priority === bestPriority && quota > bestQuota)) {
340
355
  best = account;
341
356
  bestPriority = priority;
@@ -600,6 +615,8 @@ export class AccountRotator {
600
615
  consecutiveErrors: a.consecutiveErrors,
601
616
  hasValidToken: !!(a.accessToken && a.tokenExpires > now),
602
617
  quota: a.quota,
618
+ proDetected: this.isProAccount(a),
619
+ familyManager: !!a.config.familyManager,
603
620
  };
604
621
  });
605
622
 
@@ -610,6 +627,7 @@ export class AccountRotator {
610
627
  totalRequestsAllAccounts: this.accounts.reduce((sum, a) => sum + a.totalRequests, 0),
611
628
  uptime: now - this.startTime,
612
629
  accounts,
630
+ proAdvisor: this.getProAdvisor(),
613
631
  };
614
632
  }
615
633
 
@@ -621,4 +639,90 @@ export class AccountRotator {
621
639
  const ts = new Date().toISOString().slice(11, 19);
622
640
  console.log(`[${ts}] [rotator] ${msg}`);
623
641
  }
642
+
643
+ // =========================================================================
644
+ // Pro Family Sharing Advisor
645
+ // =========================================================================
646
+
647
+ // Model keys relevant for Pro advisor decisions (ignore Flash)
648
+ private static PRO_ADVISOR_MODELS = ["gemini-3.1-pro", "claude-opus-4-6-thinking"];
649
+
650
+ private isProAccount(account: AccountRuntime): boolean {
651
+ return account.quota.some((q) => q.timerType === "5h");
652
+ }
653
+
654
+ private getProAdvisor(): StatusResponse["proAdvisor"] {
655
+ const maxSlots = this.config.proSlots ?? 6;
656
+ const proAccounts = this.accounts.filter((a) => !a.disabled && !a.flagged && this.isProAccount(a));
657
+ const currentProCount = proAccounts.length;
658
+ const actions: ProAdvisorAction[] = [];
659
+
660
+ // Suggest "remove-pro" for Pro accounts (not family manager) with 0% on all advisor models
661
+ for (const account of proAccounts) {
662
+ if (account.config.familyManager) continue;
663
+ const advisorQuotas = account.quota.filter((q) =>
664
+ AccountRotator.PRO_ADVISOR_MODELS.some((m) => q.modelKey.includes(m) || m.includes(q.modelKey)),
665
+ );
666
+ if (advisorQuotas.length === 0) continue;
667
+ const allExhausted = advisorQuotas.every((q) => q.percentRemaining === 0);
668
+ if (allExhausted) {
669
+ actions.push({
670
+ type: "remove-pro",
671
+ email: account.config.email,
672
+ label: account.config.label || account.config.email,
673
+ reason: "Pro quota exhausted on G3Pro and Claude",
674
+ });
675
+ }
676
+ }
677
+
678
+ // Suggest "add-pro" for Free accounts with 0% quota and long reset, if slots available
679
+ const slotsAvailable = maxSlots - currentProCount + actions.filter((a) => a.type === "remove-pro").length;
680
+ if (slotsAvailable > 0) {
681
+ const candidates: { account: AccountRuntime; maxResetMs: number }[] = [];
682
+
683
+ for (const account of this.accounts) {
684
+ if (account.disabled || account.flagged) continue;
685
+ if (this.isProAccount(account)) continue;
686
+
687
+ const advisorQuotas = account.quota.filter((q) =>
688
+ AccountRotator.PRO_ADVISOR_MODELS.some((m) => q.modelKey.includes(m) || m.includes(q.modelKey)),
689
+ );
690
+ if (advisorQuotas.length === 0) continue;
691
+
692
+ // Only suggest if at least one advisor model is at 0%
693
+ const hasExhausted = advisorQuotas.some((q) => q.percentRemaining === 0);
694
+ if (!hasExhausted) continue;
695
+
696
+ // Find the longest reset time among exhausted models
697
+ let maxResetMs = 0;
698
+ for (const q of advisorQuotas) {
699
+ if (q.percentRemaining === 0 && q.resetTime) {
700
+ const resetMs = new Date(q.resetTime).getTime() - Date.now();
701
+ if (resetMs > maxResetMs) maxResetMs = resetMs;
702
+ }
703
+ }
704
+
705
+ // Only suggest if reset is > 24h away (otherwise not worth the Pro slot)
706
+ if (maxResetMs > 24 * 60 * 60 * 1000) {
707
+ candidates.push({ account, maxResetMs });
708
+ }
709
+ }
710
+
711
+ // Sort by longest reset time first (maximizes benefit)
712
+ candidates.sort((a, b) => b.maxResetMs - a.maxResetMs);
713
+
714
+ for (const { account, maxResetMs } of candidates.slice(0, slotsAvailable)) {
715
+ const days = Math.floor(maxResetMs / (24 * 60 * 60 * 1000));
716
+ const hours = Math.floor((maxResetMs % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
717
+ actions.push({
718
+ type: "add-pro",
719
+ email: account.config.email,
720
+ label: account.config.label || account.config.email,
721
+ reason: `0% quota, resets in ${days}d ${hours}h`,
722
+ });
723
+ }
724
+ }
725
+
726
+ return { currentProCount, maxProSlots: maxSlots, actions };
727
+ }
624
728
  }
package/src/types.ts CHANGED
@@ -9,6 +9,8 @@ export interface AccountConfig {
9
9
  label?: string;
10
10
  // Optional - pro/free is detected dynamically from quota API reset times
11
11
  type?: AccountType;
12
+ // This account owns the family plan and can never be removed from Pro
13
+ familyManager?: boolean;
12
14
  }
13
15
 
14
16
  export interface Config {
@@ -19,6 +21,8 @@ export interface Config {
19
21
  rotateOnQuotaDrop: number;
20
22
  // How often to poll quota (ms). Default: 5min
21
23
  quotaPollIntervalMs: number;
24
+ // Max simultaneous Pro accounts (owner + members). Default: 6
25
+ proSlots?: number;
22
26
  }
23
27
 
24
28
  // Quota API response from Google
@@ -59,7 +63,7 @@ export const QUOTA_MODEL_KEYS: Record<string, { key: string; altKeys: string[];
59
63
  },
60
64
  claude: {
61
65
  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"],
66
+ 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
67
  display: "Claude",
64
68
  },
65
69
  };
@@ -134,6 +138,12 @@ export interface StatusResponse {
134
138
  // Per-model active account
135
139
  activeAccounts: Record<string, string>;
136
140
  accounts: AccountStatus[];
141
+ // Pro family sharing advisor
142
+ proAdvisor: {
143
+ currentProCount: number;
144
+ maxProSlots: number;
145
+ actions: ProAdvisorAction[];
146
+ };
137
147
  }
138
148
 
139
149
  export interface AccountStatus {
@@ -151,6 +161,17 @@ export interface AccountStatus {
151
161
  consecutiveErrors: number;
152
162
  hasValidToken: boolean;
153
163
  quota: ModelQuota[];
164
+ // Pro family sharing
165
+ proDetected: boolean;
166
+ familyManager: boolean;
167
+ }
168
+
169
+ // Pro advisor suggestion
170
+ export interface ProAdvisorAction {
171
+ type: "add-pro" | "remove-pro";
172
+ email: string;
173
+ label: string;
174
+ reason: string;
154
175
  }
155
176
 
156
177
  // Antigravity OAuth constants (same as pi-mono)