pi-antigravity-rotator 1.3.2 → 1.3.4
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 +81 -12
- package/package.json +2 -2
- package/src/account-store.ts +105 -0
- package/src/dashboard.ts +315 -119
- package/src/login.ts +15 -271
- package/src/oauth.ts +171 -0
- package/src/onboarding.ts +237 -0
- package/src/proxy.ts +42 -0
- package/src/rotator.ts +213 -41
- package/src/types.ts +8 -0
package/README.md
CHANGED
|
@@ -6,11 +6,12 @@ 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
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
|
|
14
15
|
- **Protective pause** -- Pauses all routing for several hours after serious ToS/abuse-style flags so the rest of the pool is not burned
|
|
15
16
|
- **Token auto-refresh** -- Tokens are refreshed automatically before expiry; no manual management
|
|
16
17
|
- **Endpoint cascade** -- Tries daily, autopush, and prod API endpoints for resilience
|
|
@@ -62,6 +63,31 @@ The tool automatically:
|
|
|
62
63
|
|
|
63
64
|
Re-running with the same email updates the existing entry.
|
|
64
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
|
+
|
|
65
91
|
## Dashboard
|
|
66
92
|
|
|
67
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`).
|
|
@@ -72,8 +98,9 @@ The dashboard shows:
|
|
|
72
98
|
- **Account cards** sorted by total quota (highest first), flagged/disabled last:
|
|
73
99
|
- Status badge: `active`, `ready`, `cooldown`, `flagged`, `disabled`, or `error`
|
|
74
100
|
- Model badges: which models this account is currently serving
|
|
75
|
-
- Per-model quota bars with timer type (`
|
|
101
|
+
- Per-model quota bars with timer type (`idle`/`7d`/`5h`) and reset countdown
|
|
76
102
|
- Request counts, last used time, token status
|
|
103
|
+
- Fresh-window policy status plus a per-account override button
|
|
77
104
|
- Error messages for flagged/errored accounts
|
|
78
105
|
- Re-enable button for disabled accounts
|
|
79
106
|
|
|
@@ -103,11 +130,17 @@ Each model maintains its own active account. When the proxy needs to rotate a mo
|
|
|
103
130
|
|
|
104
131
|
| Priority | Badge | Condition | Rationale |
|
|
105
132
|
|----------|-------|-----------|-----------|
|
|
106
|
-
| 1 (first) | `
|
|
107
|
-
| 2 | `7d` |
|
|
108
|
-
| 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 |
|
|
109
136
|
|
|
110
|
-
Within the same priority tier, the account with the most remaining quota for that model wins.
|
|
137
|
+
Within the same priority tier, the account with the most remaining quota for that model wins. If multiple accounts tie on priority and quota, rotation advances circularly from the current account so equal candidates share traffic instead of always favoring the first configured match.
|
|
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.
|
|
111
144
|
|
|
112
145
|
### Rotation Triggers
|
|
113
146
|
|
|
@@ -115,32 +148,56 @@ Three mechanisms trigger rotation, scoped to the specific model:
|
|
|
115
148
|
|
|
116
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.
|
|
117
150
|
|
|
118
|
-
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.
|
|
152
|
+
|
|
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:
|
|
119
171
|
|
|
120
|
-
|
|
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
|
|
121
175
|
|
|
122
176
|
### Account Protection
|
|
123
177
|
|
|
124
178
|
The proxy detects blocked/suspended accounts at three levels:
|
|
125
179
|
|
|
126
|
-
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.
|
|
127
181
|
|
|
128
182
|
2. **API 401** (on request) -- If the prod endpoint rejects the token with `401 UNAUTHENTICATED`, the account is flagged.
|
|
129
183
|
|
|
130
|
-
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.
|
|
131
185
|
|
|
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.
|
|
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.
|
|
133
187
|
|
|
134
188
|
### Cooldown Management
|
|
135
189
|
|
|
136
190
|
- Cooldowns are capped at **30 minutes** max
|
|
137
191
|
- Stale cooldowns from previous sessions are capped on startup
|
|
192
|
+
- When every non-flagged account is cooling down, the routing state becomes `cooldown_wait`
|
|
138
193
|
- The dashboard shows why routing is waiting, how long until the next retry window, and which accounts are cooling down
|
|
139
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
|
|
140
195
|
|
|
141
196
|
### Error Handling
|
|
142
197
|
|
|
143
198
|
- **429** (rate limit) -- account is marked exhausted with cooldown, rotates to next
|
|
199
|
+
- **401** -- account is flagged and excluded from routing
|
|
200
|
+
- **403** with enforcement keywords -- account is flagged and may trigger protective pause
|
|
144
201
|
- **503** (no capacity) -- returned directly to the agent when all healthy accounts are cooling down, busy, flagged, or disabled
|
|
145
202
|
- **5xx** (other server errors) -- account error counter incremented, rotates to next
|
|
146
203
|
|
|
@@ -152,6 +209,7 @@ The dashboard is intended to replace day-to-day `journalctl` digging for normal
|
|
|
152
209
|
- The exact stop or wait reason
|
|
153
210
|
- The next retry window when cooldowns are active
|
|
154
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
|
|
155
213
|
- Pool counts for available, ready, active, cooldown, busy, flagged, disabled, and error accounts
|
|
156
214
|
- An `Attention Needed` section summarizing flagged, cooling, disabled, and error accounts
|
|
157
215
|
- A recent event feed with the latest rotator/proxy incidents that led to the current state
|
|
@@ -176,6 +234,9 @@ pi-antigravity-rotator start --config-dir /path/to/config
|
|
|
176
234
|
"requestsPerRotation": 5,
|
|
177
235
|
"rotateOnQuotaDrop": 20,
|
|
178
236
|
"quotaPollIntervalMs": 300000,
|
|
237
|
+
"maxConcurrentRequestsPerAccount": 1,
|
|
238
|
+
"protectivePauseMs": 21600000,
|
|
239
|
+
"useRequestCountRotationWhenQuotaUnknownOnly": true,
|
|
179
240
|
"accounts": [
|
|
180
241
|
{
|
|
181
242
|
"email": "user@gmail.com",
|
|
@@ -195,6 +256,9 @@ pi-antigravity-rotator start --config-dir /path/to/config
|
|
|
195
256
|
| `requestsPerRotation` | `5` | Max requests before rotating (fallback trigger) |
|
|
196
257
|
| `rotateOnQuotaDrop` | `20` | Rotate when a model's quota drops this many %. Set to `0` to disable |
|
|
197
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 |
|
|
198
262
|
|
|
199
263
|
### Account Fields
|
|
200
264
|
|
|
@@ -210,8 +274,13 @@ pi-antigravity-rotator start --config-dir /path/to/config
|
|
|
210
274
|
| Method | Path | Description |
|
|
211
275
|
|--------|------|-------------|
|
|
212
276
|
| `GET` | `/dashboard` | Web dashboard |
|
|
277
|
+
| `GET` | `/login` | Hosted account-link landing page |
|
|
213
278
|
| `GET` | `/api/status` | JSON status: accounts, quotas, model routing, flags |
|
|
214
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 |
|
|
215
284
|
| `POST` | `/v1internal:streamGenerateContent` | Proxy endpoint (used by pi) |
|
|
216
285
|
|
|
217
286
|
## Running as a Service
|
|
@@ -239,7 +308,7 @@ WantedBy=multi-user.target
|
|
|
239
308
|
## Troubleshooting
|
|
240
309
|
|
|
241
310
|
**Account shows `flagged` status**
|
|
242
|
-
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.
|
|
243
312
|
|
|
244
313
|
**Account keeps getting disabled after 5 errors**
|
|
245
314
|
Check the error message. Common causes: revoked OAuth consent, expired refresh token (re-run `npm run login`), or Google account suspension.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antigravity-rotator",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4",
|
|
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
|
+
}
|