pi-antigravity-rotator 2.3.0 → 2.3.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.3.1] - 2026-06-30
6
+
7
+ ### Improved
8
+ - **Accurate Quota Forecast**: Replaced the simple arithmetic average of pool quota with a weighted calculation based on each account's Tier capacity (Ultra/Pro/Plus/Free). The Dashboard's time remaining and percentage estimates are now mathematically accurate for mixed-tier deployments. (PR [#14](https://github.com/tuxevil/pi-antigravity-rotator/pull/14) by [@josenicomaia](https://github.com/josenicomaia))
9
+
5
10
  ## [2.3.0] - 2026-06-22
6
11
 
7
12
  ### Added
package/README.md CHANGED
@@ -161,7 +161,7 @@ The dashboard shows:
161
161
  - **Token Usage & Savings** -- Interactive chart (`1h`, `2h`, `4h`, `8h`, `12h`, `1d`, `7d`, `1m`) showing token consumption by model, with estimated USD savings and `CSV`/`JSON` export options.
162
162
  - **Activity Heatmap** -- 60-day responsive GitHub-style contribution grid showing request intensity hour by hour.
163
163
  - **Latency (p50/p95)** -- Real-time median and 95th percentile tracking for Time-to-First-Byte (TTFB) and Total Duration per model.
164
- - **Quota Forecast** -- Predictive modeling showing when each model's quota will run out based on the current requests/hour burn rate.
164
+ - **Quota Forecast** -- Predictive modeling showing when each model's quota will run out, using **tier-weighted capacity** algorithms for pinpoint accuracy across mixed-tier pools.
165
165
  - **Searchable Request Log** -- Live feed of the last 200 requests with exact timestamps, models, masked accounts, status codes, and latency.
166
166
  - **Account Cards** -- Sorted by total quota. Shows status (`active`, `ready`, `cooldown`, `flagged`, `disabled`), quota bars with timers, and precise error messages.
167
167
  - **Web-based Account Management** -- Add, remove, and manage account credentials and tier configurations directly from the UI without touching JSON files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
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",
@@ -11,7 +11,7 @@
11
11
  "start": "tsx src/cli.ts start",
12
12
  "login": "tsx src/cli.ts login",
13
13
  "typecheck": "tsc --noEmit",
14
- "test": "node --import tsx/esm --test \"test/**/*.test.ts\"",
14
+ "test": "PI_ROTATOR_DIR=/tmp/pi-rotator-test node --import tsx/esm --test \"test/**/*.test.ts\"",
15
15
  "lint": "eslint src test",
16
16
  "coverage": "c8 --reporter=text --reporter=html npm test",
17
17
  "check": "npm run typecheck && npm test && npm run lint"
package/src/dashboard.ts CHANGED
@@ -197,6 +197,41 @@ export function serveClearBreakerApi(
197
197
  res.end(JSON.stringify({ ok: true }));
198
198
  }
199
199
 
200
+ export function serveKickstartApi(
201
+ res: ServerResponse,
202
+ rotator: AccountRotator,
203
+ email: string,
204
+ modelKey?: string,
205
+ ): void {
206
+ if (modelKey) {
207
+ rotator.kickstartTimerForAccount(email, modelKey).then((result) => {
208
+ res.writeHead(200, { "Content-Type": "application/json" });
209
+ res.end(JSON.stringify(result));
210
+ }).catch((err) => {
211
+ res.writeHead(500, { "Content-Type": "application/json" });
212
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
213
+ });
214
+ } else {
215
+ rotator.kickstartAllFreshTimers(email).then((result) => {
216
+ res.writeHead(200, { "Content-Type": "application/json" });
217
+ res.end(JSON.stringify(result));
218
+ }).catch((err) => {
219
+ res.writeHead(500, { "Content-Type": "application/json" });
220
+ res.end(JSON.stringify({ ok: false, error: String(err), results: [] }));
221
+ });
222
+ }
223
+ }
224
+
225
+ export function serveAutoWarmupApi(
226
+ res: ServerResponse,
227
+ rotator: AccountRotator,
228
+ enabled: boolean,
229
+ ): void {
230
+ const changed = rotator.setAutoWarmup(enabled);
231
+ res.writeHead(200, { "Content-Type": "application/json" });
232
+ res.end(JSON.stringify({ ok: true, changed, autoWarmupEnabled: enabled }));
233
+ }
234
+
200
235
  const DASHBOARD_HTML = `<!DOCTYPE html>
201
236
  <html lang="en">
202
237
  <head>
package/src/proxy.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  serveAccountFreshWindowStartsApi,
34
34
  serveClearInFlightApi,
35
35
  serveClearBreakerApi,
36
+ serveKickstartApi,
37
+ serveAutoWarmupApi,
36
38
  serveStaticCss,
37
39
  serveStaticJs,
38
40
  } from "./dashboard.js";
@@ -1728,6 +1730,33 @@ export function startProxy(
1728
1730
  return;
1729
1731
  }
1730
1732
 
1733
+ if (method === "POST" && url.startsWith("/api/kickstart/")) {
1734
+ if (!requireAdmin(req, res)) return;
1735
+ const rest = url.slice("/api/kickstart/".length);
1736
+ const firstSlash = rest.indexOf("/");
1737
+ if (firstSlash >= 0) {
1738
+ // /api/kickstart/:email/:modelKey
1739
+ const email = decodeURIComponent(rest.slice(0, firstSlash));
1740
+ const modelKey = decodeURIComponent(rest.slice(firstSlash + 1));
1741
+ serveKickstartApi(res, rotator, email, modelKey);
1742
+ } else {
1743
+ // /api/kickstart/:email — kickstart all fresh timers
1744
+ const email = decodeURIComponent(rest);
1745
+ serveKickstartApi(res, rotator, email);
1746
+ }
1747
+ return;
1748
+ }
1749
+
1750
+ if (
1751
+ method === "POST" &&
1752
+ (url === "/api/settings/auto-warmup/on" ||
1753
+ url === "/api/settings/auto-warmup/off")
1754
+ ) {
1755
+ if (!requireAdmin(req, res)) return;
1756
+ serveAutoWarmupApi(res, rotator, url.endsWith("/on"));
1757
+ return;
1758
+ }
1759
+
1731
1760
  if (method === "POST" && pathname === "/api/self-update") {
1732
1761
  if (!requireAdmin(req, res)) return;
1733
1762
  trackFeature("selfUpdate");
package/src/rotator.ts CHANGED
@@ -21,6 +21,9 @@ import {
21
21
  TOKEN_URL,
22
22
  QUOTA_API_URL,
23
23
  QUOTA_USER_AGENT,
24
+ REQUEST_GOOG_API_CLIENT,
25
+ REQUEST_CLIENT_METADATA,
26
+ ANTIGRAVITY_ENDPOINTS,
24
27
  QUOTA_MODEL_KEYS,
25
28
  resolveQuotaModelKey,
26
29
  resolveDisplayModelKey,
@@ -67,6 +70,20 @@ function projectModelKey(projectId: string, modelKey: string): string {
67
70
  return `${projectId}::${modelKey}`;
68
71
  }
69
72
 
73
+ // Maps each quota pool key to the cheapest upstream model to use for kickstart warmup requests.
74
+ // gemini-3.5-flash and gemini-3.1-pro share the same upstream pool, so both map to gemini-3-flash.
75
+ const KICKSTART_MODEL_FOR_QUOTA_POOL: Record<string, string> = {
76
+ "claude-opus-4-6-thinking": "gpt-oss-120b-medium",
77
+ "gemini-3.5-flash": "gemini-3-flash",
78
+ "gemini-3.1-pro": "gemini-3-flash",
79
+ };
80
+
81
+ // Reverse map: upstream model → the quota pool key it primarily represents (for deduplication).
82
+ const QUOTA_POOL_FOR_KICKSTART_MODEL: Record<string, string> = {
83
+ "gpt-oss-120b-medium": "claude-opus-4-6-thinking",
84
+ "gemini-3-flash": "gemini-3.5-flash",
85
+ };
86
+
70
87
  export class AccountRotator {
71
88
  private accounts: AccountRuntime[] = [];
72
89
  // Per-model active account tracking
@@ -102,6 +119,10 @@ export class AccountRotator {
102
119
  account: string;
103
120
  }> = [];
104
121
  private routingDiagnostics: Record<string, RoutingModelDiagnostics> = {};
122
+ private autoWarmupEnabled = false;
123
+ // Tracks upstream models already warmed-up this poll cycle per account (email → Set<upstreamModel>).
124
+ // Cleared at the start of each pollAllQuotas() to enforce the one-per-cycle guarantee.
125
+ private warmupSentThisCycle = new Map<string, Set<string>>();
105
126
  // Debounced state writer: batches multiple saveState() calls within a 1s window
106
127
  // to a single disk write. Hot paths (markError, recordRequest, etc.) call
107
128
  // scheduleStateSave() instead of saveState() to avoid blocking the event loop.
@@ -267,6 +288,7 @@ export class AccountRotator {
267
288
  this.protectivePauseUntil = state.protectivePauseUntil ?? 0;
268
289
  this.protectivePauseReason = state.protectivePauseReason ?? null;
269
290
  this.allowFreshWindowStarts = state.allowFreshWindowStarts ?? true;
291
+ this.autoWarmupEnabled = state.autoWarmupEnabled ?? false;
270
292
  this.safetyDay = state.safety?.day ?? currentUtcDay();
271
293
  this.projectRequests = state.safety?.projectRequests ?? {};
272
294
  this.projectModelBreakers = state.safety?.projectModelBreakers ?? {};
@@ -372,6 +394,7 @@ export class AccountRotator {
372
394
  protectivePauseUntil: this.protectivePauseUntil,
373
395
  protectivePauseReason: this.protectivePauseReason,
374
396
  allowFreshWindowStarts: this.allowFreshWindowStarts,
397
+ autoWarmupEnabled: this.autoWarmupEnabled,
375
398
  safety: {
376
399
  day: this.safetyDay,
377
400
  projectRequests: { ...this.projectRequests },
@@ -474,6 +497,9 @@ export class AccountRotator {
474
497
  }
475
498
 
476
499
  private async pollAllQuotas(): Promise<void> {
500
+ // Reset per-cycle warmup tracking so each poll cycle allows at most one warmup per upstream model per account.
501
+ this.warmupSentThisCycle.clear();
502
+
477
503
  const available = this.accounts.filter((a) => !a.disabled && !a.flagged);
478
504
  for (const account of available) {
479
505
  try {
@@ -482,6 +508,38 @@ export class AccountRotator {
482
508
  } catch {
483
509
  // Token refresh or quota fetch failed, skip this account
484
510
  }
511
+
512
+ // Auto-warmup: send minimal kickstart requests for idle pools on accounts that have opted
513
+ // in via allowFreshWindowStartsOverride, but only if the operator has enabled the global
514
+ // auto-warmup toggle. "Idle" includes both fresh pools (no active timer) and rolling idle
515
+ // pools (100% quota with resetTime very close to a full 5h or 7d window — timer exists but
516
+ // untouched). Deduplicates by upstream model within this cycle.
517
+ if (this.autoWarmupEnabled && account.allowFreshWindowStartsOverride) {
518
+ const idlePools = account.quota.filter((q) => this.isQuotaIdleForKickstart(q));
519
+ if (idlePools.length > 0) {
520
+ const alreadySent =
521
+ this.warmupSentThisCycle.get(account.config.email) ?? new Set<string>();
522
+ // Build deduplicated upstream model list
523
+ const upstreamToQuotaKey = new Map<string, string>();
524
+ for (const q of idlePools) {
525
+ const upstream = KICKSTART_MODEL_FOR_QUOTA_POOL[q.modelKey] ?? q.modelKey;
526
+ if (!upstreamToQuotaKey.has(upstream)) {
527
+ const primaryKey = QUOTA_POOL_FOR_KICKSTART_MODEL[upstream] ?? q.modelKey;
528
+ upstreamToQuotaKey.set(upstream, primaryKey);
529
+ }
530
+ }
531
+ for (const [upstream, quotaKey] of upstreamToQuotaKey) {
532
+ if (!alreadySent.has(upstream)) {
533
+ alreadySent.add(upstream);
534
+ // Fire-and-forget — errors are handled internally by kickstartTimerForAccount
535
+ void this.kickstartTimerForAccount(account.config.email, quotaKey).catch(
536
+ () => {},
537
+ );
538
+ }
539
+ }
540
+ this.warmupSentThisCycle.set(account.config.email, alreadySent);
541
+ }
542
+ }
485
543
  }
486
544
 
487
545
  if (this.isProtectivePauseActive(Date.now())) {
@@ -683,6 +741,19 @@ export class AccountRotator {
683
741
  return q?.timerType ?? "fresh";
684
742
  }
685
743
 
744
+ // A pool is "idle for kickstart" when it has a fresh pool (no active timer)
745
+ // OR a rolling idle timer (100% quota with resetTime very close to a full 5h or 7d
746
+ // window — the timer exists but the quota is completely untouched). Sending a
747
+ // minimal kickstart request against an idle pool starts the consumption clock.
748
+ private isQuotaIdleForKickstart(q: ModelQuota): boolean {
749
+ if (q.timerType === "fresh") return true;
750
+ if (!q.resetTime || q.percentRemaining !== 100) return false;
751
+ const remaining = new Date(q.resetTime).getTime() - Date.now();
752
+ if (remaining <= 0) return false;
753
+ const within = (target: number) => Math.abs(remaining - target) < 600_000;
754
+ return within(5 * 3600 * 1000) || within(7 * 24 * 3600 * 1000);
755
+ }
756
+
686
757
  // Timer priority for a specific model:
687
758
  // 1 (highest) = 5h timer -> only class with hard quota loss if underused before reset
688
759
  // 2 = 7d timer -> already ticking, keep those long windows moving
@@ -2228,6 +2299,19 @@ export class AccountRotator {
2228
2299
  return true;
2229
2300
  }
2230
2301
 
2302
+ setAutoWarmup(enabled: boolean): boolean {
2303
+ if (this.autoWarmupEnabled === enabled) return false;
2304
+ this.autoWarmupEnabled = enabled;
2305
+ this.saveState();
2306
+ this.log(
2307
+ enabled
2308
+ ? "Operator enabled auto-warmup; accounts with fresh-window override will automatically receive minimal kickstart requests on each quota poll"
2309
+ : "Operator disabled auto-warmup; no automatic kickstart requests will be sent",
2310
+ "warn",
2311
+ );
2312
+ return true;
2313
+ }
2314
+
2231
2315
  clearModelBreaker(modelKey: string): boolean {
2232
2316
  const now = Date.now();
2233
2317
  const hasModelBreaker = (this.modelBreakers[modelKey] ?? 0) > now;
@@ -2588,6 +2672,7 @@ export class AccountRotator {
2588
2672
  : null,
2589
2673
  operatorControls: {
2590
2674
  allowFreshWindowStarts: this.allowFreshWindowStarts,
2675
+ autoWarmupEnabled: this.autoWarmupEnabled,
2591
2676
  },
2592
2677
  security: {
2593
2678
  adminTokenConfigured: !!getConfiguredAdminToken(),
@@ -3061,4 +3146,180 @@ export class AccountRotator {
3061
3146
  errorCount,
3062
3147
  };
3063
3148
  }
3149
+
3150
+ /**
3151
+ * Send a minimal single-token request to the upstream Antigravity endpoint for a specific
3152
+ * quota pool key on a given account. Uses the cheapest model in that pool to minimise cost.
3153
+ * Applies normal error handling (markExhausted, markFlagged, markError) so the account state
3154
+ * stays consistent with regular traffic.
3155
+ */
3156
+ async kickstartTimerForAccount(
3157
+ email: string,
3158
+ quotaModelKey: string,
3159
+ ): Promise<{ ok: boolean; status: number; upstreamModel: string; error?: string }> {
3160
+ const account = this.accounts.find((a) => a.config.email === email);
3161
+ if (!account) {
3162
+ return { ok: false, status: 404, upstreamModel: "", error: "account not found" };
3163
+ }
3164
+ if (account.disabled || account.flagged) {
3165
+ return {
3166
+ ok: false,
3167
+ status: 409,
3168
+ upstreamModel: "",
3169
+ error: account.disabled ? "account disabled" : "account flagged",
3170
+ };
3171
+ }
3172
+
3173
+ try {
3174
+ await this.ensureValidToken(account);
3175
+ } catch (err) {
3176
+ return {
3177
+ ok: false,
3178
+ status: 401,
3179
+ upstreamModel: "",
3180
+ error: `token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
3181
+ };
3182
+ }
3183
+
3184
+ if (!account.accessToken) {
3185
+ return { ok: false, status: 401, upstreamModel: "", error: "no access token" };
3186
+ }
3187
+
3188
+ const upstreamModel =
3189
+ KICKSTART_MODEL_FOR_QUOTA_POOL[quotaModelKey] ?? quotaModelKey;
3190
+
3191
+ const body = JSON.stringify({
3192
+ project: account.config.projectId,
3193
+ model: upstreamModel,
3194
+ request: {
3195
+ contents: [{ role: "user", parts: [{ text: "." }] }],
3196
+ generationConfig: { maxOutputTokens: 1 },
3197
+ },
3198
+ });
3199
+
3200
+ const endpoint = ANTIGRAVITY_ENDPOINTS[ANTIGRAVITY_ENDPOINTS.length - 1];
3201
+ const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
3202
+
3203
+ const controller = new AbortController();
3204
+ const timeout = setTimeout(() => controller.abort(), 15_000);
3205
+
3206
+ let response: Response;
3207
+ try {
3208
+ response = await fetch(url, {
3209
+ method: "POST",
3210
+ headers: {
3211
+ "Content-Type": "application/json",
3212
+ Authorization: `Bearer ${account.accessToken}`,
3213
+ "User-Agent": QUOTA_USER_AGENT,
3214
+ "X-Goog-Api-Client": REQUEST_GOOG_API_CLIENT,
3215
+ "Client-Metadata": REQUEST_CLIENT_METADATA,
3216
+ },
3217
+ body,
3218
+ signal: controller.signal,
3219
+ });
3220
+ } catch (err) {
3221
+ clearTimeout(timeout);
3222
+ const msg = `kickstart network error: ${err instanceof Error ? err.message : String(err)}`;
3223
+ this.markError(account, msg);
3224
+ return { ok: false, status: 0, upstreamModel, error: msg };
3225
+ }
3226
+ clearTimeout(timeout);
3227
+
3228
+ // Consume and discard the response body to free the connection
3229
+ try {
3230
+ await response.body?.cancel();
3231
+ } catch {
3232
+ // ignore
3233
+ }
3234
+
3235
+ const label = account.config.label || account.config.email;
3236
+
3237
+ if (response.status === 429) {
3238
+ const cooldownMs = 60_000;
3239
+ this.markExhausted(account, quotaModelKey, cooldownMs, "kickstart 429");
3240
+ this.recordProvider429(account, quotaModelKey, cooldownMs);
3241
+ this.log(
3242
+ `${label} [${quotaModelKey}]: kickstart 429 — cooldown ${cooldownMs / 1000}s`,
3243
+ "warn",
3244
+ );
3245
+ return { ok: false, status: 429, upstreamModel };
3246
+ }
3247
+
3248
+ if (response.status === 401 || response.status === 403) {
3249
+ this.markFlagged(
3250
+ account,
3251
+ `kickstart ${response.status} on ${upstreamModel}`,
3252
+ { triggerProtectivePause: false },
3253
+ );
3254
+ return { ok: false, status: response.status, upstreamModel };
3255
+ }
3256
+
3257
+ if (response.status >= 500) {
3258
+ this.markError(account, `kickstart ${response.status} on ${upstreamModel}`);
3259
+ return { ok: false, status: response.status, upstreamModel };
3260
+ }
3261
+
3262
+ if (response.ok) {
3263
+ this.recordRequest(account, quotaModelKey);
3264
+ this.log(
3265
+ `${label} [${quotaModelKey}]: kickstart sent via ${upstreamModel} — timer should start within the next quota poll`,
3266
+ );
3267
+ }
3268
+
3269
+ return { ok: response.ok, status: response.status, upstreamModel };
3270
+ }
3271
+
3272
+ /**
3273
+ * Kickstart all quota pools that are currently idle (no active timer, or rolling
3274
+ * idle with 100% quota and a fresh resetTime) for a given account. Deduplicates by
3275
+ * upstream model so that pools sharing the same upstream (e.g. gemini-3.5-flash and
3276
+ * gemini-3.1-pro → gemini-3-flash) only receive one request.
3277
+ */
3278
+ async kickstartAllFreshTimers(email: string): Promise<{
3279
+ ok: boolean;
3280
+ error?: string;
3281
+ results: Array<{
3282
+ quotaPools: string[];
3283
+ upstreamModel: string;
3284
+ ok: boolean;
3285
+ status: number;
3286
+ }>;
3287
+ }> {
3288
+ const account = this.accounts.find((a) => a.config.email === email);
3289
+ if (!account) {
3290
+ return { ok: false, error: "account not found", results: [] };
3291
+ }
3292
+
3293
+ const idlePools = account.quota.filter((q) => this.isQuotaIdleForKickstart(q));
3294
+ if (idlePools.length === 0) {
3295
+ return { ok: true, results: [] };
3296
+ }
3297
+
3298
+ // Deduplicate: group quota pool keys by their upstream model
3299
+ const upstreamToQuotaPools = new Map<string, string[]>();
3300
+ for (const q of idlePools) {
3301
+ const upstream = KICKSTART_MODEL_FOR_QUOTA_POOL[q.modelKey] ?? q.modelKey;
3302
+ const list = upstreamToQuotaPools.get(upstream) ?? [];
3303
+ list.push(q.modelKey);
3304
+ upstreamToQuotaPools.set(upstream, list);
3305
+ }
3306
+
3307
+ const results: Array<{
3308
+ quotaPools: string[];
3309
+ upstreamModel: string;
3310
+ ok: boolean;
3311
+ status: number;
3312
+ }> = [];
3313
+
3314
+ for (const [upstreamModel, quotaPools] of upstreamToQuotaPools) {
3315
+ // Use the primary quota pool key for this upstream (for recordRequest/markExhausted)
3316
+ const primaryQuotaKey =
3317
+ QUOTA_POOL_FOR_KICKSTART_MODEL[upstreamModel] ?? quotaPools[0];
3318
+ const result = await this.kickstartTimerForAccount(email, primaryQuotaKey);
3319
+ results.push({ quotaPools, upstreamModel, ok: result.ok, status: result.status });
3320
+ }
3321
+
3322
+ const allOk = results.every((r) => r.ok);
3323
+ return { ok: allOk, results };
3324
+ }
3064
3325
  }