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 +1 -1
- package/src/dashboard.ts +157 -5
- package/src/proxy.ts +52 -85
- package/src/rotator.ts +105 -1
- package/src/types.ts +22 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antigravity-rotator",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
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}
|
|
143
|
+
log(`Endpoint ${endpoint} returned ${response.status}, cascading...`);
|
|
144
|
+
response.text().catch(() => {});
|
|
150
145
|
continue;
|
|
151
146
|
}
|
|
152
147
|
|
|
153
|
-
|
|
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
|
|
196
|
+
const response = await forwardRequest(account, { ...body }, flattenHeaders(req.headers));
|
|
211
197
|
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
const
|
|
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 (
|
|
223
|
-
|
|
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 (
|
|
232
|
-
const errorText =
|
|
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-
|
|
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 (
|
|
247
|
-
const errorText =
|
|
248
|
-
log(`[${label}] Server error ${
|
|
249
|
-
|
|
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, `${
|
|
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
|
|
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
|
-
|
|
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(
|
|
257
|
+
res.writeHead(response.status, responseHeaders);
|
|
274
258
|
|
|
275
|
-
// Stream
|
|
276
|
-
if (
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
|
333
|
-
const
|
|
311
|
+
const method = req.method?.toUpperCase();
|
|
312
|
+
const url = req.url || "";
|
|
313
|
+
const pathname = url.split("?")[0];
|
|
334
314
|
|
|
335
|
-
|
|
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(`
|
|
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)
|