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 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 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
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 (`fresh`/`7d`/`5h`) and reset countdown
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) | `fresh` | No active timer for this model | Start the 7-day clock ASAP so it resets sooner |
107
- | 2 | `7d` | 7-day timer running for this model | Already ticking, keep using it |
108
- | 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 |
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), 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.
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
- 3. **429 failover** (reactive) -- On rate limit or 5xx, the account is marked exhausted with a cooldown and the affected model immediately switches.
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** (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.
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 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.
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. 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.
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.2",
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
+ }