pi-antigravity-rotator 2.2.2 → 2.3.0

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/src/index.ts CHANGED
@@ -7,35 +7,40 @@ import { AccountRotator } from "./rotator.js";
7
7
  import { startProxy } from "./proxy.js";
8
8
  import { getConfigDir } from "./paths.js";
9
9
  import { TelemetryReporter, setActiveReporter } from "./telemetry.js";
10
- import { loadConfigFromDisk } from "./account-store.js";
11
- import { ensureAdminToken, getConfiguredAdminToken, setPersistedAdminToken } from "./admin-auth.js";
10
+ import { loadConfig as loadConfigFromStore } from "./account-store.js";
11
+ import {
12
+ ensureAdminToken,
13
+ getConfiguredAdminToken,
14
+ setPersistedAdminToken,
15
+ } from "./admin-auth.js";
12
16
  import { warnIfUsingFallbackOAuthCreds } from "./oauth.js";
13
17
  import { warnIfInsecureTelemetryEndpoint } from "./telemetry.js";
14
- import { setModelSpecsOverride, loadResponsesStore, flushResponsesStoreSync } from "./compat.js";
18
+ import {
19
+ setModelSpecsOverride,
20
+ loadResponsesStore,
21
+ flushResponsesStoreSync,
22
+ } from "./compat.js";
15
23
  import { setModelAliasesOverride } from "./types.js";
16
24
  import { writeTextFileAtomic } from "./storage.js";
25
+ import { initDb } from "./db-store.js";
26
+ import { stopPendingSessionReaper } from "./onboarding.js";
17
27
 
18
28
  function loadConfig(): Config {
19
- const configPath = join(getConfigDir(), "accounts.json");
20
- if (!existsSync(configPath)) {
21
- console.error(`Config not found: ${configPath}`);
22
- console.error("Run 'pi-antigravity-rotator login' to add your first account.");
23
- process.exit(1);
24
- }
25
-
26
- try {
27
- const config = loadConfigFromDisk();
28
-
29
- if (!config.accounts || config.accounts.length === 0) {
30
- console.error("No accounts configured. Run 'pi-antigravity-rotator login' to add one.");
31
- process.exit(1);
32
- }
33
-
34
- return config;
35
- } catch (err) {
36
- console.error(`Failed to parse ${configPath}: ${err}`);
37
- process.exit(1);
38
- }
29
+ try {
30
+ const config = loadConfigFromStore();
31
+
32
+ if (!config.accounts || config.accounts.length === 0) {
33
+ console.error(
34
+ "No accounts configured. Run 'pi-antigravity-rotator login' to add one.",
35
+ );
36
+ process.exit(1);
37
+ }
38
+
39
+ return config;
40
+ } catch (err) {
41
+ console.error(`Failed to load config: ${err}`);
42
+ process.exit(1);
43
+ }
39
44
  }
40
45
 
41
46
  const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
@@ -46,145 +51,191 @@ const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
46
51
  * then writes .star-prompted so it never appears again.
47
52
  */
48
53
  function maybeShowStarNudge(): void {
49
- const dir = getConfigDir();
50
- const promptedPath = join(dir, ".star-prompted");
51
- if (existsSync(promptedPath)) return; // already shown, done forever
52
-
53
- const firstBootPath = join(dir, ".first-boot");
54
- let firstBootMs: number;
55
-
56
- if (existsSync(firstBootPath)) {
57
- try {
58
- firstBootMs = parseInt(readFileSync(firstBootPath, "utf-8").trim(), 10);
59
- if (Number.isNaN(firstBootMs)) return;
60
- } catch { return; }
61
- } else {
62
- // First ever boot — record timestamp
63
- firstBootMs = Date.now();
64
- try { writeTextFileAtomic(firstBootPath, String(firstBootMs)); } catch { /* best effort */ }
65
- return; // too early, come back after 24h
66
- }
67
-
68
- if (Date.now() - firstBootMs < TWENTY_FOUR_HOURS_MS) return; // not yet
69
-
70
- // Show it once
71
- console.log(" ╭──────────────────────────────────────────────────────────╮");
72
- console.log(" │ ⭐ Enjoying pi-antigravity-rotator? │");
73
- console.log(" │ github.com/tuxevil/pi-antigravity-rotator │");
74
- console.log(" │ A star helps others find it. Thanks! │");
75
- console.log(" ╰──────────────────────────────────────────────────────────╯");
76
- console.log();
77
-
78
- try { writeTextFileAtomic(promptedPath, String(Date.now())); } catch { /* best effort */ }
54
+ const dir = getConfigDir();
55
+ const promptedPath = join(dir, ".star-prompted");
56
+ if (existsSync(promptedPath)) return; // already shown, done forever
57
+
58
+ const firstBootPath = join(dir, ".first-boot");
59
+ let firstBootMs: number;
60
+
61
+ if (existsSync(firstBootPath)) {
62
+ try {
63
+ firstBootMs = parseInt(readFileSync(firstBootPath, "utf-8").trim(), 10);
64
+ if (Number.isNaN(firstBootMs)) return;
65
+ } catch {
66
+ return;
67
+ }
68
+ } else {
69
+ // First ever boot record timestamp
70
+ firstBootMs = Date.now();
71
+ try {
72
+ writeTextFileAtomic(firstBootPath, String(firstBootMs));
73
+ } catch {
74
+ /* best effort */
75
+ }
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 {
90
+ writeTextFileAtomic(promptedPath, String(Date.now()));
91
+ } catch {
92
+ /* best effort */
93
+ }
79
94
  }
80
95
 
81
96
  /**
82
97
  * Resolve the effective admin token at startup. If no PI_ROTATOR_ADMIN_TOKEN
83
- * env var is set and no .admin-token file exists, a new token is generated,
84
- * persisted to .admin-token, and printed to the operator once. This ensures
98
+ * env var is set and no persisted token exists, a new token is generated,
99
+ * saved to the repository, and printed to the operator once. This ensures
85
100
  * admin routes are protected by default on first run.
86
101
  */
87
- function bootstrapAdminToken(configDir: string): void {
88
- const resolved = ensureAdminToken(configDir);
89
- setPersistedAdminToken(resolved.token);
90
- if (resolved.source === "generated") {
91
- console.log();
92
- console.log(" ╭──────────────────────────────────────────────────────────╮");
93
- console.log(" │ Generated admin token (persisted to .admin-token): │");
94
- const tokenPreview = resolved.token.length > 12
95
- ? `${resolved.token.slice(0, 8)}…${resolved.token.slice(-4)}`
96
- : resolved.token;
97
- console.log(` │ ${tokenPreview} │`);
98
- console.log(" │ (full token in .admin-token; cat to view) │");
99
- console.log(" │ │");
100
- console.log(" │ Header: x-rotator-admin-token: <token> │");
101
- console.log(" │ Bearer: Authorization: Bearer <token> │");
102
- console.log("URL: <url>?token=<token> │");
103
- console.log(" ╰──────────────────────────────────────────────────────────╯");
104
- console.log();
105
- }
102
+ function bootstrapAdminToken(): void {
103
+ const resolved = ensureAdminToken();
104
+ setPersistedAdminToken(resolved.token);
105
+ if (resolved.source === "generated") {
106
+ console.log();
107
+ console.log(
108
+ " ╭──────────────────────────────────────────────────────────╮",
109
+ );
110
+ console.log(
111
+ " │ Generated admin token (saved to repository): │",
112
+ );
113
+ const tokenPreview =
114
+ resolved.token.length > 12
115
+ ? `${resolved.token.slice(0, 8)}…${resolved.token.slice(-4)}`
116
+ : resolved.token;
117
+ console.log(`${tokenPreview} │`);
118
+ console.log(
119
+ " │ │",
120
+ );
121
+ console.log(
122
+ " │ Header: x-rotator-admin-token: <token> │",
123
+ );
124
+ console.log(
125
+ " │ Bearer: Authorization: Bearer <token> │",
126
+ );
127
+ console.log(
128
+ " │ URL: <url>?token=<token> │",
129
+ );
130
+ console.log(
131
+ " ╰──────────────────────────────────────────────────────────╯",
132
+ );
133
+ console.log();
134
+ }
106
135
  }
107
136
 
108
137
  function maybeWarnAboutAdminExposure(config: Config): void {
109
- if (getConfiguredAdminToken()) return;
110
- console.warn("WARNING: PI_ROTATOR_ADMIN_TOKEN is not configured.");
111
- console.warn(`WARNING: Dashboard and /api/* routes are open on ${config.bindHost}:${config.proxyPort}.`);
112
- console.warn("WARNING: For local-only use, prefer bindHost=127.0.0.1 or set PI_ROTATOR_ADMIN_TOKEN.");
113
- console.warn();
138
+ if (getConfiguredAdminToken()) return;
139
+ console.warn("WARNING: PI_ROTATOR_ADMIN_TOKEN is not configured.");
140
+ console.warn(
141
+ `WARNING: Dashboard and /api/* routes are open on ${config.bindHost}:${config.proxyPort}.`,
142
+ );
143
+ console.warn(
144
+ "WARNING: For local-only use, prefer bindHost=127.0.0.1 or set PI_ROTATOR_ADMIN_TOKEN.",
145
+ );
146
+ console.warn();
114
147
  }
115
148
 
116
- export function main(): void {
117
- console.log("=== Pi Antigravity Rotator ===");
118
- console.log();
119
-
120
- const config = loadConfig();
121
- console.log(`Loaded ${config.accounts.length} accounts`);
122
- console.log(`Rotation: ${config.requestsPerRotation} requests / ${config.rotateOnQuotaDrop}% quota drop`);
123
- console.log(`Quota poll: every ${Math.round((config.quotaPollIntervalMs || 300000) / 1000)}s`);
124
- console.log(`Concurrency cap: ${config.maxConcurrentRequestsPerAccount} request/account, ${config.maxConcurrentRequestsPerProjectModel} request/project+model`);
125
- console.log(`Bind host: ${config.bindHost}`);
126
- console.log(`Routing policy: ${config.routingPolicy}`);
127
- console.log(`Safety breaker: ${config.projectCircuitBreaker429Threshold} provider 429s / ${Math.round((config.projectCircuitBreakerWindowMs || 0) / 60000)}m pauses project+model for ${Math.round((config.projectCircuitBreakerCooldownMs || 0) / 60000)}m`);
128
- console.log(`Protective pause: ${Math.round((config.protectivePauseMs || 0) / 3600000)}h after serious flag`);
129
- console.log();
130
-
131
- for (const account of config.accounts) {
132
- console.log(` ${account.label || account.email} (${account.email})`);
133
- }
134
- console.log();
135
-
136
- maybeShowStarNudge();
137
- bootstrapAdminToken(getConfigDir());
138
- maybeWarnAboutAdminExposure(config);
139
- warnIfUsingFallbackOAuthCreds();
140
- warnIfInsecureTelemetryEndpoint();
141
- setModelSpecsOverride(config.modelSpecs ?? null);
142
- setModelAliasesOverride(config.modelAliases ?? null);
143
- void loadResponsesStore();
144
-
145
- const rotator = new AccountRotator(config);
146
-
147
- // ── Telemetry (anonymous, opt-out via PI_ROTATOR_TELEMETRY=off) ──
148
- const telemetry = new TelemetryReporter(() => {
149
- const status = rotator.getStatus();
150
-
151
- // getTokenUsage() deduplicates rolled-up buckets and exposes tokensByModel
152
- const tu = rotator.getTokenUsage();
153
- const tokensByModel = tu.tokensByModel;
154
-
155
- return {
156
- accountCount: status.accounts.length,
157
- modelsUsed: Object.keys(status.activeAccounts),
158
- totalRequests: status.totalRequestsAllAccounts,
159
- uptimeSeconds: Math.round(status.uptime / 1000),
160
- routingHealthState: status.routingHealth.state,
161
- flaggedCount: status.routingHealth.flaggedCount,
162
- disabledCount: status.routingHealth.disabledCount,
163
- proCount: status.accounts.filter(a => a.proDetected).length,
164
- freeCount: status.accounts.filter(a => !a.proDetected).length,
165
- tokensByModel,
166
- };
167
- });
168
- setActiveReporter(telemetry);
169
- void telemetry.start();
170
-
171
- // ── Graceful shutdown ──
172
- const shutdown = async (): Promise<void> => {
173
- console.log("\nShutting down...");
174
- flushResponsesStoreSync();
175
- rotator.flushPendingStateSaveSync();
176
- rotator.flushPendingTokenUsageSaveSync();
177
- await telemetry.shutdown();
178
- rotator.stopQuotaPolling();
179
- process.exit(0);
180
- };
181
- process.on("SIGINT", () => void shutdown());
182
- process.on("SIGTERM", () => void shutdown());
183
-
184
- startProxy(rotator, config.proxyPort, config.bindHost || "0.0.0.0");
149
+ export async function main(): Promise<void> {
150
+ console.log("=== Pi Antigravity Rotator ===");
151
+ console.log();
152
+
153
+ await initDb();
154
+
155
+ const config = loadConfig();
156
+ console.log(`Loaded ${config.accounts.length} accounts`);
157
+ console.log(
158
+ `Rotation: ${config.requestsPerRotation} requests / ${config.rotateOnQuotaDrop}% quota drop`,
159
+ );
160
+ console.log(
161
+ `Quota poll: every ${Math.round((config.quotaPollIntervalMs || 300000) / 1000)}s`,
162
+ );
163
+ console.log(
164
+ `Concurrency cap: ${config.maxConcurrentRequestsPerAccount} request/account, ${config.maxConcurrentRequestsPerProjectModel} request/project+model`,
165
+ );
166
+ console.log(`Bind host: ${config.bindHost}`);
167
+ console.log(`Routing policy: ${config.routingPolicy}`);
168
+ console.log(
169
+ `Safety breaker: ${config.projectCircuitBreaker429Threshold} provider 429s / ${Math.round((config.projectCircuitBreakerWindowMs || 0) / 60000)}m pauses project+model for ${Math.round((config.projectCircuitBreakerCooldownMs || 0) / 60000)}m`,
170
+ );
171
+ console.log(
172
+ `Protective pause: ${Math.round((config.protectivePauseMs || 0) / 3600000)}h after serious flag`,
173
+ );
174
+ console.log();
175
+
176
+ for (const account of config.accounts) {
177
+ console.log(` ${account.label || account.email} (${account.email})`);
178
+ }
179
+ console.log();
180
+
181
+ maybeShowStarNudge();
182
+ bootstrapAdminToken();
183
+ maybeWarnAboutAdminExposure(config);
184
+ warnIfUsingFallbackOAuthCreds();
185
+ warnIfInsecureTelemetryEndpoint();
186
+ setModelSpecsOverride(config.modelSpecs ?? null);
187
+ setModelAliasesOverride(config.modelAliases ?? null);
188
+ void loadResponsesStore();
189
+
190
+ const rotator = new AccountRotator(config);
191
+
192
+ // ── Telemetry (anonymous, opt-out via PI_ROTATOR_TELEMETRY=off) ──
193
+ const telemetry = new TelemetryReporter(() => {
194
+ const status = rotator.getStatus();
195
+
196
+ // getTokenUsage() deduplicates rolled-up buckets and exposes tokensByModel
197
+ const tu = rotator.getTokenUsage();
198
+ const tokensByModel = tu.tokensByModel;
199
+
200
+ return {
201
+ accountCount: status.accounts.length,
202
+ modelsUsed: Object.keys(status.activeAccounts),
203
+ totalRequests: status.totalRequestsAllAccounts,
204
+ uptimeSeconds: Math.round(status.uptime / 1000),
205
+ routingHealthState: status.routingHealth.state,
206
+ flaggedCount: status.routingHealth.flaggedCount,
207
+ disabledCount: status.routingHealth.disabledCount,
208
+ proCount: status.accounts.filter((a) => a.proDetected).length,
209
+ freeCount: status.accounts.filter((a) => !a.proDetected).length,
210
+ tokensByModel,
211
+ };
212
+ });
213
+ setActiveReporter(telemetry);
214
+ void telemetry.start();
215
+
216
+ // ── Graceful shutdown ──
217
+ const shutdown = async (): Promise<void> => {
218
+ console.log("\nShutting down...");
219
+ flushResponsesStoreSync();
220
+ rotator.flushPendingStateSaveSync();
221
+ rotator.flushPendingTokenUsageSaveSync();
222
+ await telemetry.shutdown();
223
+ rotator.stopQuotaPolling();
224
+ stopPendingSessionReaper();
225
+ const { closeDb } = await import("./db-store.js");
226
+ await closeDb();
227
+ process.exit(0);
228
+ };
229
+ process.on("SIGINT", () => void shutdown());
230
+ process.on("SIGTERM", () => void shutdown());
231
+
232
+ startProxy(rotator, config.proxyPort, config.bindHost || "0.0.0.0");
185
233
  }
186
234
 
187
235
  // Direct execution
188
236
  if (process.argv[1]?.includes("index")) {
189
- main();
237
+ main().catch((err) => {
238
+ console.error("Fatal startup error:", err);
239
+ process.exit(1);
240
+ });
190
241
  }