pi-antigravity-rotator 1.7.0 → 1.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.2] - 2026-04-29
4
+
5
+ ### Fixed
6
+ - **Token Usage Overcounting**: `getTokenUsage()` was summing `minutes + hours + days + months` buckets, which are hierarchical rollups of the same data — causing every token to be counted ~4×. Now reads only the raw `minutes` buckets (source of truth). Telemetry payload corrected accordingly. Historical JSONL data on the receiver was inflated; divide by ~4 for real estimates.
7
+ - **Telemetry Dashboard Filters**: Added server-side filtering to `/v1/stats` by `installId`, `version`, `os`, `model`, `from`, `to` query params.
8
+ - **Telemetry Web Dashboard**: Added interactive filter bar with auto-populated dropdowns for all filter dimensions. Active filter indicator shows current scope. Auto-refresh respects active filters.
9
+ - **Telemetry Endpoint**: Updated to `http://telemetry.dragont.ec:3800/v1/events` (port explicit until reverse proxy is configured).
10
+
11
+ ## [1.8.0] - 2026-04-29
12
+
13
+ ### Added
14
+ - **Anonymous Telemetry**: Opt-out telemetry to help understand real-world usage and, crucially, to improve the anti-flag algorithm that protects your accounts. Collects pool metrics, error patterns, and flag triggers without capturing any PII or emails.
15
+ - **Telemetry Receiver**: Included a standalone Node.js server (`tools/telemetry-receiver/`) for deploying your own instance to collect telemetry securely via JSONL files.
16
+ - **Server-Side Savings Calculation**: The receiver now computes estimated USD savings directly from raw per-model token usage reports, ensuring pricing updates don't require client updates.
17
+ - **Star Reminder**: A one-time, non-intrusive terminal prompt shown after 24 hours of successful use, encouraging a GitHub star.
3
18
 
4
19
  ## [1.7.0] - 2026-04-29
5
20
 
package/README.md CHANGED
@@ -320,6 +320,58 @@ The proxy now returns `503` and waits for cooldown or manual recovery. It does n
320
320
  **Multiple agents on different models**
321
321
  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.
322
322
 
323
+ ## Telemetry
324
+
325
+ pi-antigravity-rotator collects **anonymous usage telemetry** to help understand how the tool is used and — most importantly — to improve the anti-flag algorithm that protects your accounts.
326
+
327
+ ### What is collected
328
+
329
+ **Heartbeat** (at boot, every 6h, at shutdown):
330
+ - Random install ID (UUID — not tied to any account or person)
331
+ - Rotator version, Node.js version, OS, architecture
332
+ - Account count, models in use, total request count, uptime
333
+ - Routing health state (healthy/paused/stopped)
334
+ - Flagged/disabled/pro/free account counts
335
+ - Per-model token usage (input/output tokens + request count per model)
336
+ - Feature usage flags (dashboard opened, login used, etc. — booleans only)
337
+
338
+ **Flag events** (sent immediately when Google flags an account):
339
+ - HTTP status that triggered the flag (401 or 403)
340
+ - Which known patterns matched (e.g. `infring`, `abus`, `suspend` — from a fixed allowlist)
341
+ - Model being requested, quota timer type, quota percentage
342
+ - Account request velocity (requests/hour), concurrent requests, lifetime requests
343
+ - Pool state: size, healthy count, whether protective pause triggered
344
+ - Time since previous flag
345
+
346
+ Flag data is the most valuable signal. It lets us study what behavior patterns lead to flags and improve the rotation algorithm to avoid them — benefiting everyone.
347
+
348
+ ### What is NEVER collected
349
+
350
+ - ❌ Email addresses
351
+ - ❌ OAuth tokens or API keys
352
+ - ❌ IP addresses
353
+ - ❌ Google project IDs
354
+ - ❌ Request/response bodies
355
+ - ❌ Error message text (only which known keywords matched)
356
+
357
+ ### Endpoint
358
+
359
+ Telemetry posts to:
360
+
361
+ ```bash
362
+ http://telemetry.dragont.ec:3800/v1/events
363
+ ```
364
+
365
+ ### Opt out
366
+
367
+ Set the environment variable before starting:
368
+
369
+ ```bash
370
+ export PI_ROTATOR_TELEMETRY=off
371
+ ```
372
+
373
+ Or use any of: `PI_ROTATOR_TELEMETRY=false`, `PI_ROTATOR_TELEMETRY=0`.
374
+
323
375
  ## Support Me
324
376
 
325
377
  If this tool has helped you optimize your API usage and save costs, consider supporting its development!
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "1.7.0",
3
+ "version": "1.8.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",
@@ -17,6 +17,7 @@
17
17
  "files": [
18
18
  "bin/",
19
19
  "src/",
20
+ "tools/telemetry-receiver/",
20
21
  "CHANGELOG.md",
21
22
  "README.md",
22
23
  "LICENSE"
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  // Entry point - loads config and starts the proxy
2
2
 
3
- import { readFileSync, existsSync } from "node:fs";
3
+ import { readFileSync, existsSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
4
5
  import type { Config } from "./types.js";
5
6
  import { AccountRotator } from "./rotator.js";
6
7
  import { startProxy } from "./proxy.js";
7
- import { getAccountsPath } from "./paths.js";
8
+ import { getAccountsPath, getConfigDir } from "./paths.js";
8
9
  import { formatValidationErrors, validateConfig } from "./validators.js";
10
+ import { TelemetryReporter, setActiveReporter } from "./telemetry.js";
9
11
 
10
12
  function loadConfig(): Config {
11
13
  const configPath = getAccountsPath();
@@ -47,6 +49,46 @@ function loadConfig(): Config {
47
49
  }
48
50
  }
49
51
 
52
+ const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
53
+
54
+ /**
55
+ * Show a one-time, non-intrusive star reminder after 24h since first install.
56
+ * Creates .first-boot on first run, shows prompt once after 24h,
57
+ * then writes .star-prompted so it never appears again.
58
+ */
59
+ function maybeShowStarNudge(): void {
60
+ const dir = getConfigDir();
61
+ const promptedPath = join(dir, ".star-prompted");
62
+ if (existsSync(promptedPath)) return; // already shown, done forever
63
+
64
+ const firstBootPath = join(dir, ".first-boot");
65
+ let firstBootMs: number;
66
+
67
+ if (existsSync(firstBootPath)) {
68
+ try {
69
+ firstBootMs = parseInt(readFileSync(firstBootPath, "utf-8").trim(), 10);
70
+ if (Number.isNaN(firstBootMs)) return;
71
+ } catch { return; }
72
+ } else {
73
+ // First ever boot — record timestamp
74
+ firstBootMs = Date.now();
75
+ try { writeFileSync(firstBootPath, String(firstBootMs), "utf-8"); } catch { /* best effort */ }
76
+ return; // too early, come back after 24h
77
+ }
78
+
79
+ if (Date.now() - firstBootMs < TWENTY_FOUR_HOURS_MS) return; // not yet
80
+
81
+ // Show it once
82
+ console.log(" ╭──────────────────────────────────────────────────────────╮");
83
+ console.log(" │ ⭐ Enjoying pi-antigravity-rotator? │");
84
+ console.log(" │ github.com/tuxevil/pi-antigravity-rotator │");
85
+ console.log(" │ A star helps others find it. Thanks! │");
86
+ console.log(" ╰──────────────────────────────────────────────────────────╯");
87
+ console.log();
88
+
89
+ try { writeFileSync(promptedPath, String(Date.now()), "utf-8"); } catch { /* best effort */ }
90
+ }
91
+
50
92
  export function main(): void {
51
93
  console.log("=== Pi Antigravity Rotator ===");
52
94
  console.log();
@@ -64,7 +106,52 @@ export function main(): void {
64
106
  }
65
107
  console.log();
66
108
 
109
+ maybeShowStarNudge();
110
+
67
111
  const rotator = new AccountRotator(config);
112
+
113
+ // ── Telemetry (anonymous, opt-out via PI_ROTATOR_TELEMETRY=off) ──
114
+ const telemetry = new TelemetryReporter(() => {
115
+ const status = rotator.getStatus();
116
+
117
+ // Use getTokenUsage() which correctly reads only the raw minutes buckets
118
+ const tu = rotator.getTokenUsage();
119
+ const tokensByModel: Record<string, { input: number; output: number; requests: number }> = {};
120
+ for (const b of tu.minutes) {
121
+ for (const [model, data] of Object.entries(b.byModel)) {
122
+ if (!tokensByModel[model]) tokensByModel[model] = { input: 0, output: 0, requests: 0 };
123
+ tokensByModel[model].input += data.inputTokens;
124
+ tokensByModel[model].output += data.outputTokens;
125
+ tokensByModel[model].requests += data.requests;
126
+ }
127
+ }
128
+
129
+ return {
130
+ accountCount: status.accounts.length,
131
+ modelsUsed: Object.keys(status.activeAccounts),
132
+ totalRequests: status.totalRequestsAllAccounts,
133
+ uptimeSeconds: Math.round(status.uptime / 1000),
134
+ routingHealthState: status.routingHealth.state,
135
+ flaggedCount: status.routingHealth.flaggedCount,
136
+ disabledCount: status.routingHealth.disabledCount,
137
+ proCount: status.accounts.filter(a => a.proDetected).length,
138
+ freeCount: status.accounts.filter(a => !a.proDetected).length,
139
+ tokensByModel,
140
+ };
141
+ });
142
+ setActiveReporter(telemetry);
143
+ void telemetry.start();
144
+
145
+ // ── Graceful shutdown ──
146
+ const shutdown = async (): Promise<void> => {
147
+ console.log("\nShutting down...");
148
+ await telemetry.shutdown();
149
+ rotator.stopQuotaPolling();
150
+ process.exit(0);
151
+ };
152
+ process.on("SIGINT", () => void shutdown());
153
+ process.on("SIGTERM", () => void shutdown());
154
+
68
155
  startProxy(rotator, config.proxyPort);
69
156
  }
70
157
 
package/src/proxy.ts CHANGED
@@ -18,6 +18,8 @@ import { requireAdmin } from "./admin-auth.js";
18
18
  import { PayloadTooLargeError, readLimitedBody } from "./body-limit.js";
19
19
  import { validateProxyRequestBody } from "./validators.js";
20
20
  import { logger } from "./logger.js";
21
+ import { trackFeature, reportFlagEvent, FLAG_PATTERNS, type FlagPattern } from "./telemetry.js";
22
+ import type { FlagEventData } from "./telemetry.js";
21
23
 
22
24
  const proxyLogger = logger.child("proxy");
23
25
 
@@ -415,6 +417,28 @@ async function handleProxyRequest(
415
417
  if (response.status === 401) {
416
418
  const errorText = await response.text().catch(() => "");
417
419
  proxyLog(`[${label}] BLOCKED (401): ${errorText.slice(0, 200)}`, "error");
420
+
421
+ // Telemetry: report flag event BEFORE markFlagged (which may trigger protective pause)
422
+ const lower401 = errorText.toLowerCase();
423
+ const matched401 = FLAG_PATTERNS.filter(p => lower401.includes(p));
424
+ const ctx401 = rotator.getFlagContext(account, modelKey);
425
+ reportFlagEvent({
426
+ flagHttpStatus: 401,
427
+ flagPatternsMatched: matched401.length > 0 ? matched401 : ["blocked_401" as FlagPattern],
428
+ model: modelKey,
429
+ timerType: ctx401.timerType as FlagEventData["timerType"],
430
+ accountQuotaPercent: ctx401.accountQuotaPercent,
431
+ wasProAccount: ctx401.wasProAccount,
432
+ accountTotalRequests: account.totalRequests,
433
+ accountRequestsLastHour: ctx401.accountRequestsLastHour,
434
+ accountConcurrentAtFlag: account.inFlightRequests,
435
+ poolSize: ctx401.poolSize,
436
+ poolHealthyCount: ctx401.poolHealthyCount,
437
+ protectivePauseTriggered: false, // not yet — markFlagged decides
438
+ uptimeSeconds: ctx401.uptimeSeconds,
439
+ timeSinceLastFlagSeconds: -1, // filled by reporter
440
+ });
441
+
418
442
  rotator.markFlagged(account, `Account blocked (401): ${errorText.slice(0, 300)}`);
419
443
  logRequestEnd(401);
420
444
  const nextAccount = await rotateAndRelease();
@@ -428,13 +452,34 @@ async function handleProxyRequest(
428
452
  if (response.status === 403) {
429
453
  const errorText = await response.text().catch(() => "");
430
454
  const lower = errorText.toLowerCase();
431
- const flagPatterns = ["infring", "suspend", "abus", "terminat", "violat", "banned", "policy", "forbidden", "verif"];
432
- const isFlagged = flagPatterns.some((p) => lower.includes(p));
455
+ const flagPatternsLocal = ["infring", "suspend", "abus", "terminat", "violat", "banned", "policy", "forbidden", "verif"];
456
+ const isFlagged = flagPatternsLocal.some((p) => lower.includes(p));
433
457
 
434
458
  if (isFlagged) {
435
459
  proxyLog(`[${label}] FLAGGED: ${errorText.slice(0, 200)}`, "error");
436
460
  recordOutcome(403);
437
461
  logRequestEnd(403);
462
+
463
+ // Telemetry: report flag event with full anonymous context
464
+ const matchedPatterns = FLAG_PATTERNS.filter(p => lower.includes(p));
465
+ const ctx403 = rotator.getFlagContext(account, modelKey);
466
+ reportFlagEvent({
467
+ flagHttpStatus: 403,
468
+ flagPatternsMatched: matchedPatterns,
469
+ model: modelKey,
470
+ timerType: ctx403.timerType as FlagEventData["timerType"],
471
+ accountQuotaPercent: ctx403.accountQuotaPercent,
472
+ wasProAccount: ctx403.wasProAccount,
473
+ accountTotalRequests: account.totalRequests,
474
+ accountRequestsLastHour: ctx403.accountRequestsLastHour,
475
+ accountConcurrentAtFlag: account.inFlightRequests,
476
+ poolSize: ctx403.poolSize,
477
+ poolHealthyCount: ctx403.poolHealthyCount,
478
+ protectivePauseTriggered: false, // not yet
479
+ uptimeSeconds: ctx403.uptimeSeconds,
480
+ timeSinceLastFlagSeconds: -1, // filled by reporter
481
+ });
482
+
438
483
  rotator.markFlagged(account, errorText.slice(0, 300));
439
484
  const nextAccount = await rotateAndRelease();
440
485
  if (!nextAccount) {
@@ -586,12 +631,14 @@ export function startProxy(rotator: AccountRotator, port: number): void {
586
631
 
587
632
  if (method === "GET" && (pathname === "/" || pathname === "/dashboard")) {
588
633
  if (!requireAdmin(req, res)) return;
634
+ trackFeature("dashboard");
589
635
  serveDashboard(res);
590
636
  return;
591
637
  }
592
638
 
593
639
  if (method === "GET" && pathname === "/login") {
594
640
  if (!requireAdmin(req, res)) return;
641
+ trackFeature("hostedLogin");
595
642
  serveLoginLanding(res);
596
643
  return;
597
644
  }
@@ -674,6 +721,7 @@ export function startProxy(rotator: AccountRotator, port: number): void {
674
721
 
675
722
  if (method === "POST" && (url === "/api/settings/fresh-window-starts/on" || url === "/api/settings/fresh-window-starts/off")) {
676
723
  if (!requireAdmin(req, res)) return;
724
+ trackFeature("freshWindowToggle");
677
725
  serveFreshWindowStartsApi(res, rotator, url.endsWith("/on"));
678
726
  return;
679
727
  }
package/src/rotator.ts CHANGED
@@ -924,7 +924,10 @@ export class AccountRotator {
924
924
  }
925
925
 
926
926
  getTokenUsage(): TokenUsageData {
927
- const all = [...this.tokenBuckets.minutes, ...this.tokenBuckets.hours, ...this.tokenBuckets.days, ...this.tokenBuckets.months];
927
+ // Use only the raw minutes buckets they are the source of truth.
928
+ // Hours/days/months are consolidated rollups of the same data and
929
+ // must NOT be summed together with minutes (that would 4x every count).
930
+ const all = this.tokenBuckets.minutes;
928
931
  let totalInputTokens = 0;
929
932
  let totalOutputTokens = 0;
930
933
  let totalRequests = 0;
@@ -1244,6 +1247,46 @@ export class AccountRotator {
1244
1247
  return this.accounts.length;
1245
1248
  }
1246
1249
 
1250
+ /**
1251
+ * Get contextual data for telemetry flag reporting.
1252
+ * Returns anonymous pool state — no emails or PII.
1253
+ */
1254
+ getFlagContext(account: AccountRuntime, modelKey: string): {
1255
+ wasProAccount: boolean;
1256
+ accountQuotaPercent: number;
1257
+ timerType: string;
1258
+ poolSize: number;
1259
+ poolHealthyCount: number;
1260
+ protectivePauseTriggered: boolean;
1261
+ accountRequestsLastHour: number;
1262
+ uptimeSeconds: number;
1263
+ } {
1264
+ const now = Date.now();
1265
+ const quota = this.getModelQuota(account, modelKey);
1266
+ const timerType = this.getModelTimerType(account, modelKey);
1267
+ const healthyCount = this.accounts.filter(a =>
1268
+ !a.disabled && !a.flagged && this.isAvailable(a, now),
1269
+ ).length;
1270
+
1271
+ // Count requests in the last hour from request log
1272
+ const oneHourAgo = now - 3600_000;
1273
+ const label = account.config.label || account.config.email;
1274
+ const requestsLastHour = this.requestLog.filter(e =>
1275
+ e.timestamp >= oneHourAgo && e.account === label,
1276
+ ).length;
1277
+
1278
+ return {
1279
+ wasProAccount: this.isProAccount(account),
1280
+ accountQuotaPercent: quota,
1281
+ timerType,
1282
+ poolSize: this.accounts.length,
1283
+ poolHealthyCount: healthyCount,
1284
+ protectivePauseTriggered: this.protectivePauseUntil > now,
1285
+ accountRequestsLastHour: requestsLastHour,
1286
+ uptimeSeconds: Math.round((now - this.startTime) / 1000),
1287
+ };
1288
+ }
1289
+
1247
1290
  addOrUpdateAccount(accountConfig: AccountConfig): void {
1248
1291
  const existingIndex = this.accounts.findIndex((account) => account.config.email === accountConfig.email);
1249
1292
  if (existingIndex >= 0) {