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 +26 -12
- package/package.json +4 -2
- package/src/dashboard.ts +68 -6
- package/src/proxy.ts +74 -76
- package/src/rotator.ts +53 -4
- package/src/types.ts +1 -3
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**
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
###
|
|
132
|
+
### Cooldown Management
|
|
133
133
|
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
2. `autopush-cloudcode-pa.sandbox.googleapis.com`
|
|
138
|
-
3. `cloudcode-pa.googleapis.com` (prod)
|
|
139
|
+
### Error Handling
|
|
139
140
|
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
196
|
+
const response = await forwardRequest(account, { ...body }, flattenHeaders(req.headers));
|
|
192
197
|
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
const
|
|
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 (
|
|
204
|
-
|
|
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 (
|
|
213
|
-
const errorText =
|
|
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-
|
|
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 (
|
|
228
|
-
const errorText =
|
|
229
|
-
log(`[${label}] Server error ${
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
257
|
+
res.writeHead(response.status, responseHeaders);
|
|
248
258
|
|
|
249
|
-
// Stream
|
|
250
|
-
if (
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
307
|
-
const
|
|
311
|
+
const method = req.method?.toUpperCase();
|
|
312
|
+
const url = req.url || "";
|
|
313
|
+
const pathname = url.split("?")[0];
|
|
308
314
|
|
|
309
|
-
|
|
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(`
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|