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 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 priority (fresh/7d/5h) is evaluated per model using each model's actual `resetTime` from the quota API, not a per-account estimate
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 (`fresh`/`7d`/`5h`) and reset countdown
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 flagged or disabled accounts
105
+ - Re-enable button for disabled accounts
77
106
 
78
107
  ![Dashboard](dashboard.png)
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) | `fresh` | No active timer for this model | Start the 7-day clock ASAP so it resets sooner |
105
- | 2 | `7d` | 7-day timer running for this model | Already ticking, keep using it |
106
- | 3 (last) | `5h` | 5-hour timer running for this model | Short-lived; wasted if not fully consumed |
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), all models on that account rotate. Safety net for when quota data isn't available yet.
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 or 5xx, the account is marked exhausted with a cooldown and the affected model immediately switches.
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** (on startup + every poll) -- If the quota API returns `403 PERMISSION_DENIED` with "violation of Terms of Service", the account is immediately flagged.
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 infringement keywords (`infring`, `suspend`, `abus`, `terminat`, `violat`, `banned`, `policy`, `forbidden`), the account is flagged.
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. Use the Re-enable button or `POST /api/enable/<email>` to clear the flag.
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
- - Use `POST /api/reset-cooldowns` to clear all cooldowns at once
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
- - **503** (no capacity) -- returned directly to the agent for its own retry/backoff
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>` | Clear flagged/disabled state and re-enable an account |
201
- | `POST` | `/api/reset-cooldowns` | Clear all cooldowns on all accounts |
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. After resolving with Google, click Re-enable or `POST /api/enable/<email>`.
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 uses the account with the shortest remaining cooldown. Add more accounts or increase `requestsPerRotation`.
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.1",
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
+ }