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 +15 -0
- package/README.md +52 -0
- package/package.json +2 -1
- package/src/index.ts +89 -2
- package/src/proxy.ts +50 -2
- package/src/rotator.ts +44 -1
- package/src/telemetry.ts +331 -0
- package/tools/telemetry-receiver/README.md +120 -0
- package/tools/telemetry-receiver/package.json +13 -0
- package/tools/telemetry-receiver/receiver.js +800 -0
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.
|
|
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
|
|
432
|
-
const isFlagged =
|
|
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
|
-
|
|
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) {
|