pi-antigravity-rotator 1.3.1 → 1.3.2
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 +20 -7
- package/package.json +1 -1
- package/src/dashboard.ts +372 -13
- package/src/index.ts +6 -0
- package/src/login.ts +6 -0
- package/src/proxy.ts +90 -52
- package/src/rotator.ts +256 -77
- package/src/types.ts +34 -0
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ Multi-account rotation proxy for Google Antigravity. Distributes API usage acros
|
|
|
10
10
|
- **Smart rotation** -- Rotates only the specific model whose quota dropped, leaving other models on their current accounts
|
|
11
11
|
- **Infringement detection** -- On 403 with infringement/abuse/suspension keywords, the account is immediately flagged and excluded from routing
|
|
12
12
|
- **Automatic failover** -- On 429 rate limits, instantly switches the affected model to the next available account
|
|
13
|
+
- **Concurrency guardrails** -- Limits each account to one in-flight request by default to avoid bursty pressure
|
|
14
|
+
- **Protective pause** -- Pauses all routing for several hours after serious ToS/abuse-style flags so the rest of the pool is not burned
|
|
13
15
|
- **Token auto-refresh** -- Tokens are refreshed automatically before expiry; no manual management
|
|
14
16
|
- **Endpoint cascade** -- Tries daily, autopush, and prod API endpoints for resilience
|
|
15
17
|
- **Web dashboard** -- Real-time view of model routing table, per-account quota bars with per-model timers, and flagged account alerts
|
|
@@ -73,7 +75,7 @@ The dashboard shows:
|
|
|
73
75
|
- Per-model quota bars with timer type (`fresh`/`7d`/`5h`) and reset countdown
|
|
74
76
|
- Request counts, last used time, token status
|
|
75
77
|
- Error messages for flagged/errored accounts
|
|
76
|
-
- Re-enable button for
|
|
78
|
+
- Re-enable button for disabled accounts
|
|
77
79
|
|
|
78
80
|

|
|
79
81
|
|
|
@@ -127,21 +129,33 @@ The proxy detects blocked/suspended accounts at three levels:
|
|
|
127
129
|
|
|
128
130
|
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
131
|
|
|
130
|
-
Flagged accounts are **immediately excluded** from all model routing. The dashboard shows a red `FLAGGED` badge with the error message.
|
|
132
|
+
Flagged accounts are **immediately excluded** from all model routing. The dashboard shows a red `FLAGGED` badge with the error message and quarantine guidance. Flagged accounts are intentionally kept out of rotation until the provider explicitly restores access.
|
|
131
133
|
|
|
132
134
|
### Cooldown Management
|
|
133
135
|
|
|
134
136
|
- Cooldowns are capped at **30 minutes** max
|
|
135
137
|
- Stale cooldowns from previous sessions are capped on startup
|
|
136
|
-
-
|
|
138
|
+
- The dashboard shows why routing is waiting, how long until the next retry window, and which accounts are cooling down
|
|
137
139
|
- 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
|
|
138
140
|
|
|
139
141
|
### Error Handling
|
|
140
142
|
|
|
141
143
|
- **429** (rate limit) -- account is marked exhausted with cooldown, rotates to next
|
|
142
|
-
- **503** (no capacity) -- returned directly to the agent
|
|
144
|
+
- **503** (no capacity) -- returned directly to the agent when all healthy accounts are cooling down, busy, flagged, or disabled
|
|
143
145
|
- **5xx** (other server errors) -- account error counter incremented, rotates to next
|
|
144
146
|
|
|
147
|
+
### Dashboard Visibility
|
|
148
|
+
|
|
149
|
+
The dashboard is intended to replace day-to-day `journalctl` digging for normal operations. The top status panel shows:
|
|
150
|
+
|
|
151
|
+
- The current routing state (`healthy`, `cooldown_wait`, `busy`, `paused`, `stopped`)
|
|
152
|
+
- The exact stop or wait reason
|
|
153
|
+
- The next retry window when cooldowns are active
|
|
154
|
+
- Protective pause remaining time and the provider signal that triggered it
|
|
155
|
+
- Pool counts for available, ready, active, cooldown, busy, flagged, disabled, and error accounts
|
|
156
|
+
- An `Attention Needed` section summarizing flagged, cooling, disabled, and error accounts
|
|
157
|
+
- A recent event feed with the latest rotator/proxy incidents that led to the current state
|
|
158
|
+
|
|
145
159
|
## Configuration
|
|
146
160
|
|
|
147
161
|
Config files (`accounts.json`, `state.json`) are stored in `~/.pi-antigravity-rotator/` by default. Override with:
|
|
@@ -197,8 +211,7 @@ pi-antigravity-rotator start --config-dir /path/to/config
|
|
|
197
211
|
|--------|------|-------------|
|
|
198
212
|
| `GET` | `/dashboard` | Web dashboard |
|
|
199
213
|
| `GET` | `/api/status` | JSON status: accounts, quotas, model routing, flags |
|
|
200
|
-
| `POST` | `/api/enable/<email>` |
|
|
201
|
-
| `POST` | `/api/reset-cooldowns` | Clear all cooldowns on all accounts |
|
|
214
|
+
| `POST` | `/api/enable/<email>` | Re-enable a disabled account after its underlying issue is fixed |
|
|
202
215
|
| `POST` | `/v1internal:streamGenerateContent` | Proxy endpoint (used by pi) |
|
|
203
216
|
|
|
204
217
|
## Running as a Service
|
|
@@ -235,7 +248,7 @@ Check the error message. Common causes: revoked OAuth consent, expired refresh t
|
|
|
235
248
|
Quota data appears after the first poll cycle (up to 5 minutes). Ensure accounts have valid tokens.
|
|
236
249
|
|
|
237
250
|
**All accounts exhausted**
|
|
238
|
-
The proxy
|
|
251
|
+
The proxy now returns `503` and waits for cooldown or manual recovery. It does not reuse cooling-down accounts.
|
|
239
252
|
|
|
240
253
|
**Multiple agents on different models**
|
|
241
254
|
This is fully supported. Each model routes independently. Agent 1 using Gemini Pro and Agent 2 using Claude will each have their own active account and won't interfere with each other's rotation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antigravity-rotator",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
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
|
@@ -18,16 +18,10 @@ export function serveStatusApi(res: ServerResponse, rotator: AccountRotator): vo
|
|
|
18
18
|
|
|
19
19
|
export function serveEnableApi(res: ServerResponse, rotator: AccountRotator, email: string): void {
|
|
20
20
|
const ok = rotator.enableAccount(email);
|
|
21
|
-
res.writeHead(ok ? 200 :
|
|
21
|
+
res.writeHead(ok ? 200 : 409, { "Content-Type": "application/json" });
|
|
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
|
-
|
|
31
25
|
const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
32
26
|
<html lang="en">
|
|
33
27
|
<head>
|
|
@@ -442,6 +436,203 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
442
436
|
.advisor-action-label { font-weight: 500; }
|
|
443
437
|
.advisor-action-reason { color: var(--text-dim); font-size: 11px; margin-left: auto; }
|
|
444
438
|
.advisor-empty { color: var(--text-dim); font-size: 12px; font-style: italic; }
|
|
439
|
+
.routing-panel {
|
|
440
|
+
border-radius: var(--radius);
|
|
441
|
+
padding: 14px 16px;
|
|
442
|
+
margin-bottom: 24px;
|
|
443
|
+
}
|
|
444
|
+
.routing-panel strong { display: block; margin-bottom: 4px; }
|
|
445
|
+
.routing-panel.state-healthy {
|
|
446
|
+
background: rgba(52, 211, 153, 0.08);
|
|
447
|
+
border: 1px solid rgba(52, 211, 153, 0.24);
|
|
448
|
+
border-left: 4px solid var(--green);
|
|
449
|
+
}
|
|
450
|
+
.routing-panel.state-healthy strong { color: var(--green); }
|
|
451
|
+
.routing-panel.state-cooldown_wait {
|
|
452
|
+
background: rgba(251, 191, 36, 0.08);
|
|
453
|
+
border: 1px solid rgba(251, 191, 36, 0.24);
|
|
454
|
+
border-left: 4px solid var(--yellow);
|
|
455
|
+
}
|
|
456
|
+
.routing-panel.state-cooldown_wait strong { color: var(--yellow); }
|
|
457
|
+
.routing-panel.state-busy {
|
|
458
|
+
background: rgba(96, 165, 250, 0.08);
|
|
459
|
+
border: 1px solid rgba(96, 165, 250, 0.24);
|
|
460
|
+
border-left: 4px solid var(--blue);
|
|
461
|
+
}
|
|
462
|
+
.routing-panel.state-busy strong { color: var(--blue); }
|
|
463
|
+
.routing-panel.state-paused,
|
|
464
|
+
.routing-panel.state-stopped {
|
|
465
|
+
background: rgba(248, 113, 113, 0.08);
|
|
466
|
+
border: 1px solid rgba(248, 113, 113, 0.25);
|
|
467
|
+
border-left: 4px solid var(--red);
|
|
468
|
+
}
|
|
469
|
+
.routing-panel.state-paused strong,
|
|
470
|
+
.routing-panel.state-stopped strong { color: var(--red); }
|
|
471
|
+
.ops-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
|
|
472
|
+
.ops-warning { margin-top:10px; font-size:11px; color: var(--text-dim); line-height:1.45; }
|
|
473
|
+
.btn-secondary {
|
|
474
|
+
font-size: 11px;
|
|
475
|
+
padding: 4px 12px;
|
|
476
|
+
border: 1px solid var(--border);
|
|
477
|
+
background: transparent;
|
|
478
|
+
color: var(--text);
|
|
479
|
+
border-radius: 6px;
|
|
480
|
+
cursor: pointer;
|
|
481
|
+
font-family: var(--font);
|
|
482
|
+
font-weight: 500;
|
|
483
|
+
}
|
|
484
|
+
.health-grid {
|
|
485
|
+
display:grid;
|
|
486
|
+
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
|
487
|
+
gap: 8px;
|
|
488
|
+
margin-top: 12px;
|
|
489
|
+
}
|
|
490
|
+
.health-pill {
|
|
491
|
+
background: rgba(255,255,255,0.04);
|
|
492
|
+
border: 1px solid var(--border);
|
|
493
|
+
border-radius: 8px;
|
|
494
|
+
padding: 8px 10px;
|
|
495
|
+
}
|
|
496
|
+
.health-pill .label {
|
|
497
|
+
font-size: 10px;
|
|
498
|
+
text-transform: uppercase;
|
|
499
|
+
letter-spacing: 0.5px;
|
|
500
|
+
color: var(--text-dim);
|
|
501
|
+
}
|
|
502
|
+
.health-pill .value {
|
|
503
|
+
font-size: 15px;
|
|
504
|
+
font-family: 'JetBrains Mono', monospace;
|
|
505
|
+
margin-top: 4px;
|
|
506
|
+
}
|
|
507
|
+
.operator-panel {
|
|
508
|
+
background: var(--surface);
|
|
509
|
+
border: 1px solid var(--border);
|
|
510
|
+
border-radius: var(--radius);
|
|
511
|
+
padding: 16px 18px;
|
|
512
|
+
margin-bottom: 24px;
|
|
513
|
+
}
|
|
514
|
+
.operator-title {
|
|
515
|
+
font-size: 11px;
|
|
516
|
+
text-transform: uppercase;
|
|
517
|
+
letter-spacing: 0.8px;
|
|
518
|
+
color: var(--text-dim);
|
|
519
|
+
margin-bottom: 10px;
|
|
520
|
+
}
|
|
521
|
+
.operator-list {
|
|
522
|
+
display: grid;
|
|
523
|
+
gap: 8px;
|
|
524
|
+
}
|
|
525
|
+
.operator-item {
|
|
526
|
+
display: flex;
|
|
527
|
+
align-items: flex-start;
|
|
528
|
+
justify-content: space-between;
|
|
529
|
+
gap: 12px;
|
|
530
|
+
padding: 10px 12px;
|
|
531
|
+
border-radius: 8px;
|
|
532
|
+
background: rgba(255,255,255,0.03);
|
|
533
|
+
border: 1px solid var(--border);
|
|
534
|
+
}
|
|
535
|
+
.operator-item strong {
|
|
536
|
+
display: block;
|
|
537
|
+
font-size: 12px;
|
|
538
|
+
margin-bottom: 2px;
|
|
539
|
+
}
|
|
540
|
+
.operator-item span {
|
|
541
|
+
display: block;
|
|
542
|
+
font-size: 11px;
|
|
543
|
+
color: var(--text-dim);
|
|
544
|
+
line-height: 1.45;
|
|
545
|
+
}
|
|
546
|
+
.operator-meta {
|
|
547
|
+
font-family: 'JetBrains Mono', monospace;
|
|
548
|
+
font-size: 11px;
|
|
549
|
+
color: var(--text);
|
|
550
|
+
white-space: nowrap;
|
|
551
|
+
flex-shrink: 0;
|
|
552
|
+
}
|
|
553
|
+
.events-panel {
|
|
554
|
+
background: var(--surface);
|
|
555
|
+
border: 1px solid var(--border);
|
|
556
|
+
border-radius: var(--radius);
|
|
557
|
+
padding: 16px 18px;
|
|
558
|
+
margin-bottom: 24px;
|
|
559
|
+
}
|
|
560
|
+
.events-list {
|
|
561
|
+
display: grid;
|
|
562
|
+
gap: 8px;
|
|
563
|
+
}
|
|
564
|
+
.events-toolbar {
|
|
565
|
+
display: flex;
|
|
566
|
+
gap: 8px;
|
|
567
|
+
flex-wrap: wrap;
|
|
568
|
+
margin-bottom: 12px;
|
|
569
|
+
}
|
|
570
|
+
.event-filter {
|
|
571
|
+
font-size: 11px;
|
|
572
|
+
padding: 5px 10px;
|
|
573
|
+
border: 1px solid var(--border);
|
|
574
|
+
background: transparent;
|
|
575
|
+
color: var(--text-dim);
|
|
576
|
+
border-radius: 999px;
|
|
577
|
+
cursor: pointer;
|
|
578
|
+
font-family: var(--font);
|
|
579
|
+
font-weight: 600;
|
|
580
|
+
}
|
|
581
|
+
.event-filter.active {
|
|
582
|
+
background: rgba(124, 92, 252, 0.14);
|
|
583
|
+
border-color: rgba(124, 92, 252, 0.28);
|
|
584
|
+
color: var(--text);
|
|
585
|
+
}
|
|
586
|
+
.event-item {
|
|
587
|
+
display: grid;
|
|
588
|
+
grid-template-columns: 90px 56px 1fr;
|
|
589
|
+
gap: 10px;
|
|
590
|
+
align-items: start;
|
|
591
|
+
padding: 10px 12px;
|
|
592
|
+
border-radius: 8px;
|
|
593
|
+
background: rgba(255,255,255,0.03);
|
|
594
|
+
border: 1px solid var(--border);
|
|
595
|
+
}
|
|
596
|
+
.event-item.level-warn {
|
|
597
|
+
border-color: rgba(251, 191, 36, 0.22);
|
|
598
|
+
}
|
|
599
|
+
.event-item.level-error {
|
|
600
|
+
border-color: rgba(248, 113, 113, 0.22);
|
|
601
|
+
}
|
|
602
|
+
.event-time {
|
|
603
|
+
font-family: 'JetBrains Mono', monospace;
|
|
604
|
+
font-size: 11px;
|
|
605
|
+
color: var(--text-dim);
|
|
606
|
+
white-space: nowrap;
|
|
607
|
+
}
|
|
608
|
+
.event-source {
|
|
609
|
+
font-size: 10px;
|
|
610
|
+
font-weight: 700;
|
|
611
|
+
text-transform: uppercase;
|
|
612
|
+
letter-spacing: 0.5px;
|
|
613
|
+
padding: 2px 6px;
|
|
614
|
+
border-radius: 999px;
|
|
615
|
+
text-align: center;
|
|
616
|
+
}
|
|
617
|
+
.event-source.rotator {
|
|
618
|
+
background: rgba(124, 92, 252, 0.14);
|
|
619
|
+
color: var(--accent);
|
|
620
|
+
}
|
|
621
|
+
.event-source.proxy {
|
|
622
|
+
background: rgba(96, 165, 250, 0.14);
|
|
623
|
+
color: var(--blue);
|
|
624
|
+
}
|
|
625
|
+
.event-message {
|
|
626
|
+
font-size: 12px;
|
|
627
|
+
line-height: 1.45;
|
|
628
|
+
color: var(--text);
|
|
629
|
+
word-break: break-word;
|
|
630
|
+
}
|
|
631
|
+
.events-empty {
|
|
632
|
+
font-size: 12px;
|
|
633
|
+
color: var(--text-dim);
|
|
634
|
+
padding: 10px 2px 2px;
|
|
635
|
+
}
|
|
445
636
|
</style>
|
|
446
637
|
</head>
|
|
447
638
|
<body>
|
|
@@ -452,6 +643,7 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
452
643
|
Uptime: <span id="uptime">--</span> |
|
|
453
644
|
Port: <span id="port">--</span> |
|
|
454
645
|
Rotation: <span id="rotation">--</span> reqs |
|
|
646
|
+
Updated: <span id="lastRefresh">--</span> |
|
|
455
647
|
<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>
|
|
456
648
|
</div>
|
|
457
649
|
</div>
|
|
@@ -471,6 +663,12 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
471
663
|
</div>
|
|
472
664
|
</div>
|
|
473
665
|
|
|
666
|
+
<div class="routing-panel state-stopped" id="routingHealth"></div>
|
|
667
|
+
|
|
668
|
+
<div class="operator-panel" id="attentionPanel" style="display:none"></div>
|
|
669
|
+
|
|
670
|
+
<div class="events-panel" id="recentEventsPanel" style="display:none"></div>
|
|
671
|
+
|
|
474
672
|
<div class="model-routing" id="modelRouting"></div>
|
|
475
673
|
|
|
476
674
|
<div class="advisor-panel" id="proAdvisor" style="display:none"></div>
|
|
@@ -544,12 +742,53 @@ function renderAccounts(data) {
|
|
|
544
742
|
document.getElementById('uptime').textContent = formatDuration(data.uptime);
|
|
545
743
|
document.getElementById('port').textContent = data.proxyPort;
|
|
546
744
|
document.getElementById('rotation').textContent = data.requestsPerRotation;
|
|
745
|
+
document.getElementById('lastRefresh').textContent = new Date(now).toLocaleTimeString();
|
|
547
746
|
document.getElementById('totalRequests').textContent = data.totalRequestsAllAccounts;
|
|
548
747
|
document.getElementById('accountCounts').textContent = data.accounts.length;
|
|
549
748
|
document.getElementById('healthyCount').textContent =
|
|
550
749
|
data.accounts.filter(function(a) { return a.status === 'active' || a.status === 'ready'; }).length;
|
|
551
750
|
|
|
751
|
+
var routingHealth = document.getElementById('routingHealth');
|
|
752
|
+
var health = data.routingHealth || {};
|
|
753
|
+
var state = health.state || 'stopped';
|
|
754
|
+
var stateColor = {
|
|
755
|
+
healthy: 'var(--green)',
|
|
756
|
+
paused: 'var(--red)',
|
|
757
|
+
cooldown_wait: 'var(--yellow)',
|
|
758
|
+
busy: 'var(--blue)',
|
|
759
|
+
stopped: 'var(--red)'
|
|
760
|
+
}[state];
|
|
761
|
+
routingHealth.className = 'routing-panel state-' + state;
|
|
762
|
+
var nextRetry = health.nextRetryIn > 0 ? '<div style="margin-top:6px;">Next retry window: <span style="font-family:JetBrains Mono, monospace;">' + formatDuration(health.nextRetryIn) + '</span></div>' : '';
|
|
763
|
+
var pauseWindow = data.protectivePauseRemaining > 0
|
|
764
|
+
? '<div style="margin-top:6px;">Protective pause: <span style="font-family:JetBrains Mono, monospace;">' + formatDuration(data.protectivePauseRemaining) + '</span> remaining</div>'
|
|
765
|
+
: '';
|
|
766
|
+
var healthGrid =
|
|
767
|
+
'<div class="health-grid">' +
|
|
768
|
+
renderHealthPill('Available', health.availableCount || 0) +
|
|
769
|
+
renderHealthPill('Active', health.activeCount || 0) +
|
|
770
|
+
renderHealthPill('Ready', health.readyCount || 0) +
|
|
771
|
+
renderHealthPill('Cooldown', health.cooldownCount || 0) +
|
|
772
|
+
renderHealthPill('Busy', health.busyCount || 0) +
|
|
773
|
+
renderHealthPill('Flagged', health.flaggedCount || 0) +
|
|
774
|
+
renderHealthPill('Disabled', health.disabledCount || 0) +
|
|
775
|
+
renderHealthPill('Error', health.errorCount || 0) +
|
|
776
|
+
'</div>';
|
|
777
|
+
routingHealth.innerHTML =
|
|
778
|
+
'<strong style="color:' + stateColor + '">Routing: ' + String(health.state || 'unknown').replace(/_/g, ' ') + '</strong>' +
|
|
779
|
+
'<div>' + (health.reason || 'No routing health information available') + '</div>' +
|
|
780
|
+
nextRetry +
|
|
781
|
+
pauseWindow +
|
|
782
|
+
(data.protectivePauseReason && data.protectivePauseRemaining > 0 ? '<div style="margin-top:6px;color:var(--text-dim);font-family:JetBrains Mono, monospace;">' + data.protectivePauseReason.slice(0, 220) + '</div>' : '') +
|
|
783
|
+
healthGrid +
|
|
784
|
+
'<div class="ops-buttons">' +
|
|
785
|
+
'<button class="btn-secondary" onclick="refresh()">Refresh</button>' +
|
|
786
|
+
'</div>' +
|
|
787
|
+
'<div class="ops-warning">When routing stops, that is intentional. The dashboard now surfaces the stop reason, retry window, protective pause, and blocker counts here so the operator does not need to rely on system logs.</div>';
|
|
788
|
+
|
|
552
789
|
renderModelRouting(data.activeAccounts);
|
|
790
|
+
renderAttentionPanel(data);
|
|
791
|
+
renderRecentEvents(data.recentEvents);
|
|
553
792
|
|
|
554
793
|
var container = document.getElementById('accounts');
|
|
555
794
|
var sorted = data.accounts.slice().sort(function(a, b) {
|
|
@@ -563,8 +802,6 @@ function renderAccounts(data) {
|
|
|
563
802
|
container.innerHTML = sorted.map(function(a) {
|
|
564
803
|
var isActive = a.status === 'active';
|
|
565
804
|
var isCooldown = a.status === 'cooldown' || a.status === 'exhausted';
|
|
566
|
-
var isDisabled = a.status === 'disabled' || a.status === 'flagged';
|
|
567
|
-
|
|
568
805
|
var cooldownPercent = 0;
|
|
569
806
|
if (isCooldown && a.cooldownRemaining > 0) {
|
|
570
807
|
var totalCooldown = a.cooldownUntil - (a.lastUsed || now);
|
|
@@ -594,18 +831,20 @@ function renderAccounts(data) {
|
|
|
594
831
|
(a.lastUsed ? formatTime(a.lastUsed) : '--') + '</div></div>' +
|
|
595
832
|
(isCooldown ? '<div class="card-stat"><div class="stat-label">Cooldown</div><div class="stat-value" style="color:var(--yellow)">' +
|
|
596
833
|
formatDuration(a.cooldownRemaining) + '</div></div>' : '') +
|
|
834
|
+
(a.inFlightRequests > 0 ? '<div class="card-stat"><div class="stat-label">In Flight</div><div class="stat-value" style="color:var(--blue)">' +
|
|
835
|
+
a.inFlightRequests + '</div></div>' : '') +
|
|
597
836
|
'<div class="card-stat"><div class="stat-label">Token</div><div class="stat-value" style="color:' +
|
|
598
837
|
(a.hasValidToken ? 'var(--green)' : 'var(--text-dim)') + '">' +
|
|
599
838
|
(a.hasValidToken ? 'Valid' : 'Expired') + '</div></div>' +
|
|
600
839
|
'</div>' +
|
|
601
840
|
(a.lastError ? '<div class="card-error">' + a.lastError.slice(0, 150) + '</div>' +
|
|
602
841
|
(a.lastError.toLowerCase().includes('verif') ?
|
|
603
|
-
'<div class="card-hint">
|
|
842
|
+
'<div class="card-hint">Open Antigravity IDE, sign in with this account, and resolve the verification prompt outside the rotator. Keep the account quarantined until that is complete.</div>' :
|
|
604
843
|
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(--
|
|
844
|
+
'<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(--blue)">Google Account Recovery</a> and keep it out of rotation unless Google explicitly restores access.</div>' :
|
|
606
845
|
'') : '') +
|
|
607
|
-
(
|
|
608
|
-
|
|
846
|
+
(isCooldown ? '<div class="card-hint">Cooling down after a provider rate-limit response. The rotator will wait for the retry window instead of forcing more traffic into this account.</div>' : '') +
|
|
847
|
+
(a.status === 'disabled' ? '<div class="card-actions"><button class="btn-enable" onclick="enableAccount(\\'' + a.email + '\\')">Re-enable</button></div>' : '') +
|
|
609
848
|
(isCooldown && cooldownPercent > 0 ? '<div class="cooldown-bar" style="width:' + cooldownPercent + '%"></div>' : '') +
|
|
610
849
|
'</div>';
|
|
611
850
|
}).join('');
|
|
@@ -613,7 +852,113 @@ function renderAccounts(data) {
|
|
|
613
852
|
renderProAdvisor(data.proAdvisor);
|
|
614
853
|
}
|
|
615
854
|
|
|
855
|
+
function renderHealthPill(label, value) {
|
|
856
|
+
return '<div class="health-pill"><div class="label">' + label + '</div><div class="value">' + value + '</div></div>';
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function renderAttentionPanel(data) {
|
|
860
|
+
var panel = document.getElementById('attentionPanel');
|
|
861
|
+
var accounts = data.accounts || [];
|
|
862
|
+
var flagged = accounts.filter(function(a) { return a.status === 'flagged'; });
|
|
863
|
+
var disabled = accounts.filter(function(a) { return a.status === 'disabled'; });
|
|
864
|
+
var errors = accounts.filter(function(a) { return a.status === 'error'; });
|
|
865
|
+
var cooldown = accounts
|
|
866
|
+
.filter(function(a) { return a.status === 'cooldown'; })
|
|
867
|
+
.sort(function(a, b) { return a.cooldownRemaining - b.cooldownRemaining; })
|
|
868
|
+
.slice(0, 4);
|
|
869
|
+
var items = [];
|
|
870
|
+
|
|
871
|
+
if (flagged.length > 0) {
|
|
872
|
+
items.push(renderAttentionItem(
|
|
873
|
+
'Flagged by provider',
|
|
874
|
+
flagged.length + ' account(s) are quarantined after a provider enforcement signal. Keep them out of rotation until the provider explicitly restores access.',
|
|
875
|
+
flagged.map(function(a) { return maskText(a.label); }).join(', ')
|
|
876
|
+
));
|
|
877
|
+
}
|
|
878
|
+
if (cooldown.length > 0) {
|
|
879
|
+
items.push(renderAttentionItem(
|
|
880
|
+
'Cooling down',
|
|
881
|
+
'These are the next accounts expected to come back. Routing waits for their retry windows instead of forcing traffic into them.',
|
|
882
|
+
cooldown.map(function(a) { return maskText(a.label) + ' ' + formatDuration(a.cooldownRemaining); }).join(' | ')
|
|
883
|
+
));
|
|
884
|
+
}
|
|
885
|
+
if (disabled.length > 0) {
|
|
886
|
+
items.push(renderAttentionItem(
|
|
887
|
+
'Disabled accounts',
|
|
888
|
+
'These accounts hit repeated operational errors and were taken out of service. Re-enable only after the underlying problem is fixed.',
|
|
889
|
+
disabled.map(function(a) { return maskText(a.label); }).join(', ')
|
|
890
|
+
));
|
|
891
|
+
}
|
|
892
|
+
if (errors.length > 0) {
|
|
893
|
+
items.push(renderAttentionItem(
|
|
894
|
+
'Recent errors',
|
|
895
|
+
'These accounts are still visible but currently erroring. Review the per-account error details below before they escalate to disabled.',
|
|
896
|
+
errors.map(function(a) { return maskText(a.label); }).join(', ')
|
|
897
|
+
));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (items.length === 0) {
|
|
901
|
+
panel.style.display = 'none';
|
|
902
|
+
panel.innerHTML = '';
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
panel.style.display = 'block';
|
|
907
|
+
panel.innerHTML = '<div class="operator-title">Attention Needed</div><div class="operator-list">' + items.join('') + '</div>';
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function renderAttentionItem(title, description, meta) {
|
|
911
|
+
return '<div class="operator-item">' +
|
|
912
|
+
'<div><strong>' + title + '</strong><span>' + description + '</span></div>' +
|
|
913
|
+
'<div class="operator-meta">' + meta + '</div>' +
|
|
914
|
+
'</div>';
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function renderRecentEvents(events) {
|
|
918
|
+
var panel = document.getElementById('recentEventsPanel');
|
|
919
|
+
var allEvents = events || [];
|
|
920
|
+
if (allEvents.length === 0) {
|
|
921
|
+
panel.style.display = 'none';
|
|
922
|
+
panel.innerHTML = '';
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
var list = allEvents.filter(matchesEventFilter).slice(0, 14);
|
|
927
|
+
var toolbar =
|
|
928
|
+
'<div class="events-toolbar">' +
|
|
929
|
+
renderEventFilterButton('all', 'All') +
|
|
930
|
+
renderEventFilterButton('errors', 'Errors Only') +
|
|
931
|
+
renderEventFilterButton('proxy', 'Proxy Only') +
|
|
932
|
+
renderEventFilterButton('rotator', 'Rotator Only') +
|
|
933
|
+
'</div>';
|
|
934
|
+
var rows = list.map(function(event) {
|
|
935
|
+
return '<div class="event-item level-' + (event.level || 'info') + '">' +
|
|
936
|
+
'<div class="event-time">' + formatTime(event.timestamp) + '</div>' +
|
|
937
|
+
'<div class="event-source ' + event.source + '">' + escapeHtml(event.source) + '</div>' +
|
|
938
|
+
'<div class="event-message">' + escapeHtml(event.message) + '</div>' +
|
|
939
|
+
'</div>';
|
|
940
|
+
}).join('');
|
|
941
|
+
|
|
942
|
+
panel.style.display = 'block';
|
|
943
|
+
panel.innerHTML =
|
|
944
|
+
'<div class="operator-title">Recent Events</div>' +
|
|
945
|
+
toolbar +
|
|
946
|
+
(rows ? '<div class="events-list">' + rows + '</div>' : '<div class="events-empty">No events match the current filter.</div>');
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function renderEventFilterButton(filter, label) {
|
|
950
|
+
return '<button class="event-filter' + (EVENT_FILTER === filter ? ' active' : '') + '" onclick="setEventFilter("' + filter + '")">' + label + '</button>';
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function matchesEventFilter(event) {
|
|
954
|
+
if (EVENT_FILTER === 'errors') return event.level === 'error';
|
|
955
|
+
if (EVENT_FILTER === 'proxy') return event.source === 'proxy';
|
|
956
|
+
if (EVENT_FILTER === 'rotator') return event.source === 'rotator';
|
|
957
|
+
return true;
|
|
958
|
+
}
|
|
959
|
+
|
|
616
960
|
var MASK_MODE = new URLSearchParams(window.location.search).has('mask');
|
|
961
|
+
var EVENT_FILTER = new URLSearchParams(window.location.search).get('events') || 'all';
|
|
617
962
|
var maskCounter = 0;
|
|
618
963
|
var maskMap = {};
|
|
619
964
|
|
|
@@ -632,6 +977,20 @@ function maskEmail(email) {
|
|
|
632
977
|
return masked.toLowerCase().replace(/ /g, '-') + '@***.com';
|
|
633
978
|
}
|
|
634
979
|
|
|
980
|
+
function escapeHtml(text) {
|
|
981
|
+
return String(text)
|
|
982
|
+
.replace(/&/g, '&')
|
|
983
|
+
.replace(/</g, '<')
|
|
984
|
+
.replace(/>/g, '>')
|
|
985
|
+
.replace(/\"/g, '"')
|
|
986
|
+
.replace(/'/g, ''');
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function setEventFilter(filter) {
|
|
990
|
+
EVENT_FILTER = filter;
|
|
991
|
+
refresh();
|
|
992
|
+
}
|
|
993
|
+
|
|
635
994
|
async function enableAccount(email) {
|
|
636
995
|
await fetch('/api/enable/' + encodeURIComponent(email), { method: 'POST' });
|
|
637
996
|
refresh();
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,10 @@ function loadConfig(): Config {
|
|
|
28
28
|
config.requestsPerRotation = config.requestsPerRotation || 5;
|
|
29
29
|
config.rotateOnQuotaDrop = config.rotateOnQuotaDrop ?? 20;
|
|
30
30
|
config.quotaPollIntervalMs = config.quotaPollIntervalMs || 300_000;
|
|
31
|
+
config.maxConcurrentRequestsPerAccount = config.maxConcurrentRequestsPerAccount ?? 1;
|
|
32
|
+
config.protectivePauseMs = config.protectivePauseMs ?? 6 * 60 * 60 * 1000;
|
|
33
|
+
config.useRequestCountRotationWhenQuotaUnknownOnly =
|
|
34
|
+
config.useRequestCountRotationWhenQuotaUnknownOnly ?? true;
|
|
31
35
|
|
|
32
36
|
return config;
|
|
33
37
|
} catch (err) {
|
|
@@ -44,6 +48,8 @@ export function main(): void {
|
|
|
44
48
|
console.log(`Loaded ${config.accounts.length} accounts`);
|
|
45
49
|
console.log(`Rotation: ${config.requestsPerRotation} requests / ${config.rotateOnQuotaDrop}% quota drop`);
|
|
46
50
|
console.log(`Quota poll: every ${Math.round((config.quotaPollIntervalMs || 300000) / 1000)}s`);
|
|
51
|
+
console.log(`Concurrency cap: ${config.maxConcurrentRequestsPerAccount} request/account`);
|
|
52
|
+
console.log(`Protective pause: ${Math.round((config.protectivePauseMs || 0) / 3600000)}h after serious flag`);
|
|
47
53
|
console.log();
|
|
48
54
|
|
|
49
55
|
for (const account of config.accounts) {
|
package/src/login.ts
CHANGED
|
@@ -138,6 +138,9 @@ interface AccountsConfig {
|
|
|
138
138
|
requestsPerRotation: number;
|
|
139
139
|
rotateOnQuotaDrop: number;
|
|
140
140
|
quotaPollIntervalMs: number;
|
|
141
|
+
maxConcurrentRequestsPerAccount: number;
|
|
142
|
+
protectivePauseMs: number;
|
|
143
|
+
useRequestCountRotationWhenQuotaUnknownOnly: boolean;
|
|
141
144
|
accounts: AccountEntry[];
|
|
142
145
|
}
|
|
143
146
|
|
|
@@ -154,6 +157,9 @@ function loadOrCreateAccountsConfig(): AccountsConfig {
|
|
|
154
157
|
requestsPerRotation: 5,
|
|
155
158
|
rotateOnQuotaDrop: 20,
|
|
156
159
|
quotaPollIntervalMs: 300000,
|
|
160
|
+
maxConcurrentRequestsPerAccount: 1,
|
|
161
|
+
protectivePauseMs: 21600000,
|
|
162
|
+
useRequestCountRotationWhenQuotaUnknownOnly: true,
|
|
157
163
|
accounts: [],
|
|
158
164
|
};
|
|
159
165
|
}
|