pi-antigravity-rotator 1.3.1 → 1.3.3
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 +99 -17
- package/package.json +2 -2
- package/src/account-store.ts +105 -0
- package/src/dashboard.ts +662 -107
- package/src/index.ts +6 -0
- package/src/login.ts +15 -265
- package/src/oauth.ts +171 -0
- package/src/onboarding.ts +237 -0
- package/src/proxy.ts +129 -49
- package/src/rotator.ts +455 -112
- package/src/types.ts +42 -0
package/README.md
CHANGED
|
@@ -6,10 +6,13 @@ Multi-account rotation proxy for Google Antigravity. Distributes API usage acros
|
|
|
6
6
|
|
|
7
7
|
- **Per-model routing** -- Each model (Gemini Pro, Flash, Claude) routes to its own active account independently. Multiple agents using different models won't interfere with each other.
|
|
8
8
|
- **Real-time quota monitoring** -- Polls Google's quota API every 5 minutes to track remaining usage per model per account
|
|
9
|
-
- **Per-model timer tracking** -- Timer
|
|
9
|
+
- **Per-model timer tracking** -- Timer classification (`fresh`/`7d`/`5h`) is evaluated per model using each model's actual `resetTime` from the quota API, not a per-account estimate
|
|
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
|
+
- **Operator fresh-window controls** -- You can block new `fresh` window starts globally, then selectively allow specific accounts to override that policy
|
|
15
|
+
- **Protective pause** -- Pauses all routing for several hours after serious ToS/abuse-style flags so the rest of the pool is not burned
|
|
13
16
|
- **Token auto-refresh** -- Tokens are refreshed automatically before expiry; no manual management
|
|
14
17
|
- **Endpoint cascade** -- Tries daily, autopush, and prod API endpoints for resilience
|
|
15
18
|
- **Web dashboard** -- Real-time view of model routing table, per-account quota bars with per-model timers, and flagged account alerts
|
|
@@ -60,6 +63,31 @@ The tool automatically:
|
|
|
60
63
|
|
|
61
64
|
Re-running with the same email updates the existing entry.
|
|
62
65
|
|
|
66
|
+
## Hosted Login Page
|
|
67
|
+
|
|
68
|
+
This fork can also host a family-facing login page so people can connect their own account without copying a `localhost` URL by hand.
|
|
69
|
+
|
|
70
|
+
Public routes:
|
|
71
|
+
|
|
72
|
+
- `GET /login` -- landing page for account linking
|
|
73
|
+
- `GET /auth/antigravity/start` -- starts the Google OAuth flow
|
|
74
|
+
- `GET /auth/antigravity/callback` -- receives the OAuth callback and adds the account to the running rotator
|
|
75
|
+
|
|
76
|
+
Required environment variables for hosted mode:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
export ANTIGRAVITY_CLIENT_ID="your-oauth-client-id"
|
|
80
|
+
export ANTIGRAVITY_CLIENT_SECRET="your-oauth-client-secret"
|
|
81
|
+
export ANTIGRAVITY_REDIRECT_URI="https://your-domain.example.com/auth/antigravity/callback"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Notes:
|
|
85
|
+
|
|
86
|
+
- The redirect URI must be registered on the OAuth client, or Google will reject the flow.
|
|
87
|
+
- The hosted page does not modify `~/.pi/agent/*`; it only adds the account to the rotator config.
|
|
88
|
+
- If those env vars are not set, `/login` still loads but explains that hosted OAuth is not configured yet.
|
|
89
|
+
- Refresh token handling during normal runtime uses the same configured client ID and secret, so the hosted sign-in and later token refreshes stay aligned.
|
|
90
|
+
|
|
63
91
|
## Dashboard
|
|
64
92
|
|
|
65
93
|
After starting the proxy, open `http://localhost:51200/dashboard` or `http://<your-server-ip>:51200/dashboard` from any machine on the same network (the proxy binds to `0.0.0.0`).
|
|
@@ -70,10 +98,11 @@ The dashboard shows:
|
|
|
70
98
|
- **Account cards** sorted by total quota (highest first), flagged/disabled last:
|
|
71
99
|
- Status badge: `active`, `ready`, `cooldown`, `flagged`, `disabled`, or `error`
|
|
72
100
|
- Model badges: which models this account is currently serving
|
|
73
|
-
- Per-model quota bars with timer type (`
|
|
101
|
+
- Per-model quota bars with timer type (`idle`/`7d`/`5h`) and reset countdown
|
|
74
102
|
- Request counts, last used time, token status
|
|
103
|
+
- Fresh-window policy status plus a per-account override button
|
|
75
104
|
- Error messages for flagged/errored accounts
|
|
76
|
-
- Re-enable button for
|
|
105
|
+
- Re-enable button for disabled accounts
|
|
77
106
|
|
|
78
107
|

|
|
79
108
|
|
|
@@ -101,47 +130,90 @@ Each model maintains its own active account. When the proxy needs to rotate a mo
|
|
|
101
130
|
|
|
102
131
|
| Priority | Badge | Condition | Rationale |
|
|
103
132
|
|----------|-------|-----------|-----------|
|
|
104
|
-
| 1 (first) | `
|
|
105
|
-
| 2 | `7d` |
|
|
106
|
-
| 3 (last) | `
|
|
133
|
+
| 1 (first) | `5h` | Short reset window is already active for this model | Drain short-window quota before it recharges |
|
|
134
|
+
| 2 | `7d` | Long reset window is already active for this model | Already ticking, so it is still worth using |
|
|
135
|
+
| 3 (last) | `fresh` | No active reset window is known for this model yet | Save untouched quota for later if other timed pools exist |
|
|
107
136
|
|
|
108
137
|
Within the same priority tier, the account with the most remaining quota for that model wins.
|
|
109
138
|
|
|
139
|
+
Timer meanings:
|
|
140
|
+
|
|
141
|
+
- `fresh` -- no future `resetTime` is currently reported for that model on that account. In practice, this means no active reset window is visible in quota polling yet. The dashboard labels this as `idle` to avoid implying that it is automatically safe to start.
|
|
142
|
+
- `5h` -- `resetTime` is less than 6 hours away.
|
|
143
|
+
- `7d` -- `resetTime` is 6 hours or more away.
|
|
144
|
+
|
|
110
145
|
### Rotation Triggers
|
|
111
146
|
|
|
112
147
|
Three mechanisms trigger rotation, scoped to the specific model:
|
|
113
148
|
|
|
114
149
|
1. **Quota-based** (primary) -- Polls the Google quota API every 5 minutes. When a model's remaining quota drops by `rotateOnQuotaDrop` percentage points (default: 20%), that model rotates to the next account. Other models stay on their current accounts.
|
|
115
150
|
|
|
116
|
-
2. **Request-count** (fallback) -- After `requestsPerRotation` requests (default: 5),
|
|
151
|
+
2. **Request-count** (fallback) -- After `requestsPerRotation` successful requests (default: 5), the rotator asks for a rotation on the model that served that request. By default this fallback is only used when quota data for that model is still unknown.
|
|
117
152
|
|
|
118
|
-
3. **429 failover** (reactive) -- On rate limit
|
|
153
|
+
3. **429 failover** (reactive) -- On rate limit, the account is marked exhausted with a parsed retry cooldown and the affected model immediately switches.
|
|
154
|
+
|
|
155
|
+
### Fresh Windows
|
|
156
|
+
|
|
157
|
+
The quota polling API only exposes one visible `quotaInfo` block per model. If a model has no visible `resetTime`, the rotator classifies it as `fresh` internally and the dashboard shows it as `idle`.
|
|
158
|
+
|
|
159
|
+
Operationally, `idle` means:
|
|
160
|
+
|
|
161
|
+
- no timer window is currently visible for that model in quota polling
|
|
162
|
+
- starting that account may open a new quota window
|
|
163
|
+
- because the provider does not expose all parallel buckets explicitly, the rotator cannot guarantee ahead of time whether that new visible window will behave like a short `5h` opportunity or a longer `7d` runway
|
|
164
|
+
|
|
165
|
+
For that reason, the rotator has two operator controls:
|
|
166
|
+
|
|
167
|
+
- a **global fresh-window toggle** that blocks opening new `idle` windows by default
|
|
168
|
+
- a **per-account override** that allows specific accounts to ignore the global block when you intentionally want them available
|
|
169
|
+
|
|
170
|
+
When fresh-window starts are blocked:
|
|
171
|
+
|
|
172
|
+
- visible `5h` timers still have highest priority
|
|
173
|
+
- visible `7d` timers are still used normally
|
|
174
|
+
- `idle` accounts are held back unless you explicitly enable their per-account override
|
|
119
175
|
|
|
120
176
|
### Account Protection
|
|
121
177
|
|
|
122
178
|
The proxy detects blocked/suspended accounts at three levels:
|
|
123
179
|
|
|
124
|
-
1. **Quota API check** (
|
|
180
|
+
1. **Quota API check** (initial poll + every poll) -- If the quota API returns `401` or `403`, the account is immediately flagged.
|
|
125
181
|
|
|
126
182
|
2. **API 401** (on request) -- If the prod endpoint rejects the token with `401 UNAUTHENTICATED`, the account is flagged.
|
|
127
183
|
|
|
128
|
-
3. **API 403** (on request) -- If the response body contains
|
|
184
|
+
3. **API 403** (on request) -- If the response body contains enforcement keywords such as `infring`, `suspend`, `abus`, `terminat`, `violat`, `banned`, `policy`, `forbidden`, or `verif`, the account is flagged.
|
|
129
185
|
|
|
130
|
-
Flagged accounts are **immediately excluded** from all model routing. The dashboard shows a red `FLAGGED` badge with the error message.
|
|
186
|
+
Flagged accounts are **immediately excluded** from all model routing. If the reason looks serious enough (for example ToS, abuse, infringement, suspension, or ban language), the rotator also enables a global **protective pause** that stops all routing for `protectivePauseMs` (default: 6 hours). 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
187
|
|
|
132
188
|
### Cooldown Management
|
|
133
189
|
|
|
134
190
|
- Cooldowns are capped at **30 minutes** max
|
|
135
191
|
- Stale cooldowns from previous sessions are capped on startup
|
|
136
|
-
-
|
|
192
|
+
- When every non-flagged account is cooling down, the routing state becomes `cooldown_wait`
|
|
193
|
+
- The dashboard shows why routing is waiting, how long until the next retry window, and which accounts are cooling down
|
|
137
194
|
- 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
195
|
|
|
139
196
|
### Error Handling
|
|
140
197
|
|
|
141
198
|
- **429** (rate limit) -- account is marked exhausted with cooldown, rotates to next
|
|
142
|
-
- **
|
|
199
|
+
- **401** -- account is flagged and excluded from routing
|
|
200
|
+
- **403** with enforcement keywords -- account is flagged and may trigger protective pause
|
|
201
|
+
- **503** (no capacity) -- returned directly to the agent when all healthy accounts are cooling down, busy, flagged, or disabled
|
|
143
202
|
- **5xx** (other server errors) -- account error counter incremented, rotates to next
|
|
144
203
|
|
|
204
|
+
### Dashboard Visibility
|
|
205
|
+
|
|
206
|
+
The dashboard is intended to replace day-to-day `journalctl` digging for normal operations. The top status panel shows:
|
|
207
|
+
|
|
208
|
+
- The current routing state (`healthy`, `cooldown_wait`, `busy`, `paused`, `stopped`)
|
|
209
|
+
- The exact stop or wait reason
|
|
210
|
+
- The next retry window when cooldowns are active
|
|
211
|
+
- Protective pause remaining time and the provider signal that triggered it
|
|
212
|
+
- The global fresh-window policy and a button to block or allow new `idle` window starts
|
|
213
|
+
- Pool counts for available, ready, active, cooldown, busy, flagged, disabled, and error accounts
|
|
214
|
+
- An `Attention Needed` section summarizing flagged, cooling, disabled, and error accounts
|
|
215
|
+
- A recent event feed with the latest rotator/proxy incidents that led to the current state
|
|
216
|
+
|
|
145
217
|
## Configuration
|
|
146
218
|
|
|
147
219
|
Config files (`accounts.json`, `state.json`) are stored in `~/.pi-antigravity-rotator/` by default. Override with:
|
|
@@ -162,6 +234,9 @@ pi-antigravity-rotator start --config-dir /path/to/config
|
|
|
162
234
|
"requestsPerRotation": 5,
|
|
163
235
|
"rotateOnQuotaDrop": 20,
|
|
164
236
|
"quotaPollIntervalMs": 300000,
|
|
237
|
+
"maxConcurrentRequestsPerAccount": 1,
|
|
238
|
+
"protectivePauseMs": 21600000,
|
|
239
|
+
"useRequestCountRotationWhenQuotaUnknownOnly": true,
|
|
165
240
|
"accounts": [
|
|
166
241
|
{
|
|
167
242
|
"email": "user@gmail.com",
|
|
@@ -181,6 +256,9 @@ pi-antigravity-rotator start --config-dir /path/to/config
|
|
|
181
256
|
| `requestsPerRotation` | `5` | Max requests before rotating (fallback trigger) |
|
|
182
257
|
| `rotateOnQuotaDrop` | `20` | Rotate when a model's quota drops this many %. Set to `0` to disable |
|
|
183
258
|
| `quotaPollIntervalMs` | `300000` | Quota poll interval in ms (5 minutes) |
|
|
259
|
+
| `maxConcurrentRequestsPerAccount` | `1` | Max simultaneous requests allowed per account |
|
|
260
|
+
| `protectivePauseMs` | `21600000` | Global routing pause after a serious provider enforcement signal |
|
|
261
|
+
| `useRequestCountRotationWhenQuotaUnknownOnly` | `true` | Use request-count rotation only until quota telemetry exists for the request's model |
|
|
184
262
|
|
|
185
263
|
### Account Fields
|
|
186
264
|
|
|
@@ -196,9 +274,13 @@ pi-antigravity-rotator start --config-dir /path/to/config
|
|
|
196
274
|
| Method | Path | Description |
|
|
197
275
|
|--------|------|-------------|
|
|
198
276
|
| `GET` | `/dashboard` | Web dashboard |
|
|
277
|
+
| `GET` | `/login` | Hosted account-link landing page |
|
|
199
278
|
| `GET` | `/api/status` | JSON status: accounts, quotas, model routing, flags |
|
|
200
|
-
| `POST` | `/api/enable/<email>` |
|
|
201
|
-
| `POST` | `/api/
|
|
279
|
+
| `POST` | `/api/enable/<email>` | Re-enable a disabled account after its underlying issue is fixed |
|
|
280
|
+
| `POST` | `/api/settings/fresh-window-starts/on` | Allow opening new `idle`/fresh windows globally |
|
|
281
|
+
| `POST` | `/api/settings/fresh-window-starts/off` | Block opening new `idle`/fresh windows globally |
|
|
282
|
+
| `POST` | `/api/account-fresh-window-starts/<email>/on` | Allow one account to override the global fresh-window block |
|
|
283
|
+
| `POST` | `/api/account-fresh-window-starts/<email>/off` | Return one account to the global fresh-window policy |
|
|
202
284
|
| `POST` | `/v1internal:streamGenerateContent` | Proxy endpoint (used by pi) |
|
|
203
285
|
|
|
204
286
|
## Running as a Service
|
|
@@ -226,7 +308,7 @@ WantedBy=multi-user.target
|
|
|
226
308
|
## Troubleshooting
|
|
227
309
|
|
|
228
310
|
**Account shows `flagged` status**
|
|
229
|
-
Google detected potential abuse. Review the error message on the dashboard.
|
|
311
|
+
Google detected potential abuse or enforcement. Review the error message on the dashboard and resolve the provider-side block first. Flagged accounts are quarantined and are not re-enabled through `/api/enable/<email>` until the underlying provider issue is cleared by replacing or restoring the account.
|
|
230
312
|
|
|
231
313
|
**Account keeps getting disabled after 5 errors**
|
|
232
314
|
Check the error message. Common causes: revoked OAuth consent, expired refresh token (re-run `npm run login`), or Google account suspension.
|
|
@@ -235,7 +317,7 @@ Check the error message. Common causes: revoked OAuth consent, expired refresh t
|
|
|
235
317
|
Quota data appears after the first poll cycle (up to 5 minutes). Ensure accounts have valid tokens.
|
|
236
318
|
|
|
237
319
|
**All accounts exhausted**
|
|
238
|
-
The proxy
|
|
320
|
+
The proxy now returns `503` and waits for cooldown or manual recovery. It does not reuse cooling-down accounts.
|
|
239
321
|
|
|
240
322
|
**Multiple agents on different models**
|
|
241
323
|
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.3",
|
|
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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
],
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
34
|
-
"url": "https://github.com/tuxevil/pi-antigravity-rotator.git"
|
|
34
|
+
"url": "git+https://github.com/tuxevil/pi-antigravity-rotator.git"
|
|
35
35
|
},
|
|
36
36
|
"author": "Sebastián Real (tuxevil)",
|
|
37
37
|
"dependencies": {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getAccountsPath } from "./paths.js";
|
|
5
|
+
import type { AccountConfig, Config } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const ACCOUNTS_FILE = getAccountsPath();
|
|
8
|
+
const PI_DIR = join(homedir(), ".pi", "agent");
|
|
9
|
+
const PI_MODELS_FILE = join(PI_DIR, "models.json");
|
|
10
|
+
const PI_AUTH_FILE = join(PI_DIR, "auth.json");
|
|
11
|
+
|
|
12
|
+
export function loadOrCreateAccountsConfig(): Config {
|
|
13
|
+
if (existsSync(ACCOUNTS_FILE)) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(ACCOUNTS_FILE, "utf-8")) as Config;
|
|
16
|
+
} catch {
|
|
17
|
+
// Corrupted, start fresh
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
proxyPort: 51200,
|
|
22
|
+
requestsPerRotation: 5,
|
|
23
|
+
rotateOnQuotaDrop: 20,
|
|
24
|
+
quotaPollIntervalMs: 300000,
|
|
25
|
+
maxConcurrentRequestsPerAccount: 1,
|
|
26
|
+
protectivePauseMs: 21600000,
|
|
27
|
+
useRequestCountRotationWhenQuotaUnknownOnly: true,
|
|
28
|
+
accounts: [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveAccountsConfig(config: Config): void {
|
|
33
|
+
writeFileSync(ACCOUNTS_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function addAccountToConfig(entry: AccountConfig): { isNew: boolean } {
|
|
37
|
+
const config = loadOrCreateAccountsConfig();
|
|
38
|
+
const existing = config.accounts.findIndex((a) => a.email === entry.email);
|
|
39
|
+
|
|
40
|
+
if (existing >= 0) {
|
|
41
|
+
config.accounts[existing] = { ...config.accounts[existing], ...entry };
|
|
42
|
+
saveAccountsConfig(config);
|
|
43
|
+
return { isNew: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
config.accounts.push(entry);
|
|
47
|
+
saveAccountsConfig(config);
|
|
48
|
+
return { isNew: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ensurePiModelsConfig(): void {
|
|
52
|
+
mkdirSync(PI_DIR, { recursive: true });
|
|
53
|
+
|
|
54
|
+
let models: Record<string, unknown> = {};
|
|
55
|
+
if (existsSync(PI_MODELS_FILE)) {
|
|
56
|
+
try {
|
|
57
|
+
models = JSON.parse(readFileSync(PI_MODELS_FILE, "utf-8"));
|
|
58
|
+
} catch {
|
|
59
|
+
// Corrupted, will overwrite
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const providers = (models.providers || {}) as Record<string, Record<string, unknown>>;
|
|
64
|
+
const antigravity = providers["google-antigravity"] || {};
|
|
65
|
+
|
|
66
|
+
if (antigravity.baseUrl === "http://localhost:51200") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
antigravity.baseUrl = "http://localhost:51200";
|
|
71
|
+
providers["google-antigravity"] = antigravity;
|
|
72
|
+
models.providers = providers;
|
|
73
|
+
|
|
74
|
+
writeFileSync(PI_MODELS_FILE, JSON.stringify(models, null, 2) + "\n", "utf-8");
|
|
75
|
+
console.log(` Updated ${PI_MODELS_FILE}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function ensurePiAuthConfig(): void {
|
|
79
|
+
mkdirSync(PI_DIR, { recursive: true });
|
|
80
|
+
|
|
81
|
+
let auth: Record<string, unknown> = {};
|
|
82
|
+
if (existsSync(PI_AUTH_FILE)) {
|
|
83
|
+
try {
|
|
84
|
+
auth = JSON.parse(readFileSync(PI_AUTH_FILE, "utf-8"));
|
|
85
|
+
} catch {
|
|
86
|
+
// Corrupted, will overwrite
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const existing = auth["google-antigravity"] as Record<string, unknown> | undefined;
|
|
91
|
+
if (existing?.type === "oauth" && existing?.refresh === "proxy-managed") {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
auth["google-antigravity"] = {
|
|
96
|
+
type: "oauth",
|
|
97
|
+
refresh: "proxy-managed",
|
|
98
|
+
access: "proxy-managed",
|
|
99
|
+
expires: 32503680000000,
|
|
100
|
+
projectId: "proxy-managed",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
writeFileSync(PI_AUTH_FILE, JSON.stringify(auth, null, 2) + "\n", "utf-8");
|
|
104
|
+
console.log(` Updated ${PI_AUTH_FILE}`);
|
|
105
|
+
}
|