pi-antigravity-rotator 2.1.6 → 2.2.2

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.
@@ -0,0 +1,203 @@
1
+ // Persistent in-memory store for OpenAI Responses API (Codex) chain state.
2
+ //
3
+ // The store maps `response_id` to the conversation state needed to continue
4
+ // a previous turn via `previous_response_id`. It is persisted to disk so a
5
+ // rotator restart does not break active Codex sessions.
6
+ //
7
+ // Writes are debounced to avoid fsync storms under load. The store is also
8
+ // flushed synchronously on SIGTERM (see index.ts) to minimise data loss.
9
+
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
+ import { promises as fs } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ export interface StoredResponseEntry {
16
+ response: Record<string, unknown>;
17
+ inputItems: Array<Record<string, unknown>>;
18
+ conversationMessages: Array<Record<string, unknown>>;
19
+ // Maps call_id -> function name. Serialized as plain object on disk
20
+ // because Map cannot be JSON.stringified directly. Convert to Map at use sites.
21
+ callIdToName: Record<string, string>;
22
+ expiresAt: number;
23
+ }
24
+
25
+ interface PersistedStore {
26
+ version: 1;
27
+ entries: Array<[string, StoredResponseEntry]>;
28
+ }
29
+
30
+ const FLUSH_DEBOUNCE_MS = 1_500;
31
+ const MAX_ENTRIES = 500;
32
+ const ENTRY_TTL_MS = 6 * 60 * 60 * 1000;
33
+
34
+ function getStorePath(): string {
35
+ return join(process.env.PI_ROTATOR_DIR ?? join(homedir(), ".pi-antigravity-rotator"), "responses.json");
36
+ }
37
+
38
+ function now(): number {
39
+ return Date.now();
40
+ }
41
+
42
+ /**
43
+ * Persistent in-memory store for OpenAI Responses API (Codex) chain state.
44
+ *
45
+ * Maps `response_id` to the conversation state needed to continue a previous
46
+ * turn via `previous_response_id`. Persists to <configDir>/responses.json
47
+ * with atomic writes (temp + rename). Writes are debounced to 1.5s and
48
+ * coalesced if a flush is already in flight. Stale entries (older than
49
+ * the 6h TTL) and entries over the 500-entry cap are pruned automatically.
50
+ *
51
+ * Corrupt files are moved aside to .corrupt-<ts>.bak on load().
52
+ */
53
+ export class ResponsesStore {
54
+ private cache = new Map<string, StoredResponseEntry>();
55
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
56
+ private flushing: Promise<void> | null = null;
57
+ private pendingFlush = false;
58
+ private readonly path: string;
59
+ private dirty = false;
60
+
61
+ constructor(path: string = getStorePath()) {
62
+ this.path = path;
63
+ }
64
+
65
+ /**
66
+ * Load the persisted store from disk. Missing or corrupt files are treated
67
+ * as an empty store. Stale entries (older than TTL) are pruned.
68
+ */
69
+ async load(): Promise<void> {
70
+ if (!existsSync(this.path)) return;
71
+ try {
72
+ const raw = readFileSync(this.path, "utf-8");
73
+ const parsed = JSON.parse(raw) as PersistedStore;
74
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) return;
75
+ const cutoff = now() - ENTRY_TTL_MS;
76
+ for (const [id, entry] of parsed.entries) {
77
+ if (!entry || typeof entry !== "object") continue;
78
+ if (typeof entry.expiresAt !== "number" || entry.expiresAt < cutoff) continue;
79
+ this.cache.set(id, entry);
80
+ }
81
+ } catch {
82
+ // Corrupt store — start fresh; rename old file aside for inspection.
83
+ try {
84
+ const backup = `${this.path}.corrupt-${now()}.bak`;
85
+ await fs.rename(this.path, backup).catch(() => undefined);
86
+ } catch {
87
+ // ignore
88
+ }
89
+ }
90
+ }
91
+
92
+ get(id: string): StoredResponseEntry | null {
93
+ const entry = this.cache.get(id);
94
+ if (!entry) return null;
95
+ if (entry.expiresAt <= now()) {
96
+ this.cache.delete(id);
97
+ this.scheduleFlush();
98
+ return null;
99
+ }
100
+ return entry;
101
+ }
102
+
103
+ set(id: string, entry: StoredResponseEntry): void {
104
+ this.cache.set(id, entry);
105
+ this.pruneIfNeeded();
106
+ this.scheduleFlush();
107
+ }
108
+
109
+ delete(id: string): boolean {
110
+ const existed = this.cache.delete(id);
111
+ if (existed) this.scheduleFlush();
112
+ return existed;
113
+ }
114
+
115
+ clear(): void {
116
+ this.cache.clear();
117
+ this.scheduleFlush();
118
+ }
119
+
120
+ size(): number {
121
+ return this.cache.size;
122
+ }
123
+
124
+ private pruneIfNeeded(): void {
125
+ const cutoff = now() - ENTRY_TTL_MS;
126
+ for (const [id, entry] of this.cache.entries()) {
127
+ if (entry.expiresAt <= cutoff) this.cache.delete(id);
128
+ }
129
+ while (this.cache.size > MAX_ENTRIES) {
130
+ const oldest = this.cache.keys().next();
131
+ if (oldest.done) break;
132
+ this.cache.delete(oldest.value);
133
+ }
134
+ }
135
+
136
+ private scheduleFlush(): void {
137
+ this.dirty = true;
138
+ if (this.flushTimer) return;
139
+ this.flushTimer = setTimeout(() => {
140
+ this.flushTimer = null;
141
+ void this.flush();
142
+ }, FLUSH_DEBOUNCE_MS);
143
+ if (this.flushTimer.unref) this.flushTimer.unref();
144
+ }
145
+
146
+ /**
147
+ * Flush pending writes to disk. Safe to call multiple times concurrently;
148
+ * callers will see the same in-flight promise.
149
+ */
150
+ async flush(): Promise<void> {
151
+ if (this.flushing) {
152
+ this.pendingFlush = true;
153
+ return this.flushing;
154
+ }
155
+ this.flushing = (async () => {
156
+ try {
157
+ if (!this.dirty) return;
158
+ const data: PersistedStore = {
159
+ version: 1,
160
+ entries: Array.from(this.cache.entries()),
161
+ };
162
+ const dir = join(this.path, "..");
163
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
164
+ const tmp = `${this.path}.tmp`;
165
+ writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
166
+ await fs.rename(tmp, this.path);
167
+ this.dirty = false;
168
+ } catch {
169
+ // Best effort — will retry on next schedule.
170
+ } finally {
171
+ this.flushing = null;
172
+ if (this.pendingFlush) {
173
+ this.pendingFlush = false;
174
+ void this.flush();
175
+ }
176
+ }
177
+ })();
178
+ return this.flushing;
179
+ }
180
+
181
+ /**
182
+ * Synchronous flush for use in shutdown handlers. Falls back to no-op on
183
+ * platforms where sync fs is not available; the next start will read what
184
+ * made it to disk.
185
+ */
186
+ flushSync(): void {
187
+ if (!this.dirty) return;
188
+ try {
189
+ const data: PersistedStore = {
190
+ version: 1,
191
+ entries: Array.from(this.cache.entries()),
192
+ };
193
+ const dir = join(this.path, "..");
194
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
195
+ const tmp = `${this.path}.tmp`;
196
+ writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
197
+ renameSync(tmp, this.path);
198
+ this.dirty = false;
199
+ } catch {
200
+ // Best effort
201
+ }
202
+ }
203
+ }
package/src/rotator.ts CHANGED
@@ -46,6 +46,11 @@ function currentUtcDay(now = Date.now()): string {
46
46
  return new Date(now).toISOString().slice(0, 10);
47
47
  }
48
48
 
49
+ function nextUtcDayStartMs(now = Date.now()): number {
50
+ const date = new Date(now);
51
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 1);
52
+ }
53
+
49
54
  function projectModelKey(projectId: string, modelKey: string): string {
50
55
  return `${projectId}::${modelKey}`;
51
56
  }
@@ -74,6 +79,20 @@ export class AccountRotator {
74
79
  private modelBreakers: Record<string, number> = {};
75
80
  private provider429Events: Array<{ ts: number; projectId: string; modelKey: string; account: string }> = [];
76
81
  private routingDiagnostics: Record<string, RoutingModelDiagnostics> = {};
82
+ // Debounced state writer: batches multiple saveState() calls within a 1s window
83
+ // to a single disk write. Hot paths (markError, recordRequest, etc.) call
84
+ // scheduleStateSave() instead of saveState() to avoid blocking the event loop.
85
+ private stateSaveTimer: ReturnType<typeof setTimeout> | null = null;
86
+ private static readonly STATE_SAVE_DEBOUNCE_MS = 1_000;
87
+ private stateSaveInflight = false;
88
+ private stateSavePending = false;
89
+ // Debounced token-usage writer: same pattern as state. Debounce window is
90
+ // longer (2s) because token-usage writes include the minute/hour/day
91
+ // consolidation pass and the JSON can be tens of KB.
92
+ private tokenUsageSaveTimer: ReturnType<typeof setTimeout> | null = null;
93
+ private static readonly TOKEN_USAGE_SAVE_DEBOUNCE_MS = 2_000;
94
+ private tokenUsageSaveInflight = false;
95
+ private tokenUsageSavePending = false;
77
96
 
78
97
  constructor(private config: Config) {
79
98
  this.config = applyConfigDefaults(config);
@@ -309,6 +328,58 @@ export class AccountRotator {
309
328
  }
310
329
  }
311
330
 
331
+ /**
332
+ * Schedule a debounced state save. Multiple calls within STATE_SAVE_DEBOUNCE_MS
333
+ * are coalesced into a single saveState() invocation. Hot paths (recordRequest,
334
+ * markError, etc.) should use this instead of saveState() to avoid blocking
335
+ * the event loop on every request.
336
+ *
337
+ * If a write is already in-flight when the timer fires, the next write is
338
+ * scheduled for after it completes.
339
+ */
340
+ scheduleStateSave(): void {
341
+ if (this.stateSaveTimer) return;
342
+ this.stateSaveTimer = setTimeout(() => {
343
+ this.stateSaveTimer = null;
344
+ void this.runScheduledStateSave();
345
+ }, AccountRotator.STATE_SAVE_DEBOUNCE_MS);
346
+ if (this.stateSaveTimer.unref) this.stateSaveTimer.unref();
347
+ }
348
+
349
+ private async runScheduledStateSave(): Promise<void> {
350
+ if (this.stateSaveInflight) {
351
+ // A write is already running. Re-schedule ourselves to run after it.
352
+ this.stateSavePending = true;
353
+ return;
354
+ }
355
+ this.stateSaveInflight = true;
356
+ try {
357
+ this.saveState();
358
+ } finally {
359
+ this.stateSaveInflight = false;
360
+ if (this.stateSavePending) {
361
+ this.stateSavePending = false;
362
+ this.scheduleStateSave();
363
+ }
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Force-flush any pending state writes. Called by SIGTERM/SIGINT handlers
369
+ * in index.ts to minimise data loss on shutdown.
370
+ */
371
+ flushPendingStateSaveSync(): void {
372
+ if (this.stateSaveTimer) {
373
+ clearTimeout(this.stateSaveTimer);
374
+ this.stateSaveTimer = null;
375
+ }
376
+ if (this.stateSaveInflight || this.stateSavePending) {
377
+ // Best effort — the in-flight write may not have hit disk yet.
378
+ // We do a final sync write to capture the most recent state.
379
+ }
380
+ this.saveState();
381
+ }
382
+
312
383
  // =========================================================================
313
384
  // Quota Polling
314
385
  // =========================================================================
@@ -634,6 +705,36 @@ export class AccountRotator {
634
705
  return this.projectRequests[projectId] ?? 0;
635
706
  }
636
707
 
708
+ private getDailySafetyRejection(account: AccountRuntime, now: number): {
709
+ reason: "daily-account-stop" | "daily-project-stop";
710
+ detail: string;
711
+ } | null {
712
+ const resetIso = new Date(nextUtcDayStartMs(now)).toISOString();
713
+ const accountCount = this.getAccountDailyCount(account, now);
714
+ const accountLimit = this.config.dailyAccountStopRequests ?? 350;
715
+ if (accountCount >= accountLimit) {
716
+ return {
717
+ reason: "daily-account-stop",
718
+ detail: `daily account budget exhausted (${accountCount}/${accountLimit} upstream attempts; resets at ${resetIso})`,
719
+ };
720
+ }
721
+
722
+ const projectCount = this.getProjectDailyCount(account.config.projectId, now);
723
+ const projectLimit = this.config.dailyProjectStopRequests ?? 1200;
724
+ if (projectCount >= projectLimit) {
725
+ return {
726
+ reason: "daily-project-stop",
727
+ detail: `daily project budget exhausted (${projectCount}/${projectLimit} upstream attempts; resets at ${resetIso})`,
728
+ };
729
+ }
730
+
731
+ return null;
732
+ }
733
+
734
+ private isDailySafetyStopped(account: AccountRuntime, now: number): boolean {
735
+ return this.getDailySafetyRejection(account, now) !== null;
736
+ }
737
+
637
738
  private getProjectInFlight(modelKey: string, projectId: string): number {
638
739
  return this.accounts
639
740
  .filter((account) => account.config.projectId === projectId)
@@ -662,8 +763,8 @@ export class AccountRotator {
662
763
  if (this.isModelBreakerActive(modelKey, now)) return "model circuit breaker active";
663
764
  if (this.isProjectModelBreakerActive(account.config.projectId, modelKey, now)) return "project circuit breaker active";
664
765
  if (this.getProjectInFlight(modelKey, account.config.projectId) >= (this.config.maxConcurrentRequestsPerProjectModel ?? 1)) return "project concurrency limit reached";
665
- if (this.getAccountDailyCount(account, now) >= (this.config.dailyAccountStopRequests ?? 350)) return "daily account budget exhausted";
666
- if (this.getProjectDailyCount(account.config.projectId, now) >= (this.config.dailyProjectStopRequests ?? 1200)) return "daily project budget exhausted";
766
+ const dailySafety = this.getDailySafetyRejection(account, now);
767
+ if (dailySafety) return dailySafety.detail;
667
768
  return null;
668
769
  }
669
770
 
@@ -673,6 +774,7 @@ export class AccountRotator {
673
774
  if (account.disabled) return "disabled";
674
775
  if (account.consecutiveErrors > 0 && !account.disabled) return "error";
675
776
  if (inCooldownModels.length > 0) return "cooldown";
777
+ if (this.isDailySafetyStopped(account, now)) return "exhausted";
676
778
  if (activeForModels.length > 0) return "active";
677
779
  return "ready";
678
780
  }
@@ -681,8 +783,8 @@ export class AccountRotator {
681
783
  if (reason === "model circuit breaker active") return { reason: "model-breaker", detail: reason };
682
784
  if (reason === "project circuit breaker active") return { reason: "project-breaker", detail: reason };
683
785
  if (reason === "project concurrency limit reached") return { reason: "project-concurrency", detail: reason };
684
- if (reason === "daily account budget exhausted") return { reason: "daily-account-stop", detail: reason };
685
- if (reason === "daily project budget exhausted") return { reason: "daily-project-stop", detail: reason };
786
+ if (reason.startsWith("daily account budget exhausted")) return { reason: "daily-account-stop", detail: reason };
787
+ if (reason.startsWith("daily project budget exhausted")) return { reason: "daily-project-stop", detail: reason };
686
788
  return { reason: "cooldown", detail: reason };
687
789
  }
688
790
 
@@ -714,6 +816,47 @@ export class AccountRotator {
714
816
  return null;
715
817
  }
716
818
 
819
+ private summarizeRoutingRejections(diagnostics: RoutingAccountDiagnostic[]): string[] {
820
+ const priority: RoutingRejectionReason[] = [
821
+ "model-breaker",
822
+ "project-breaker",
823
+ "daily-account-stop",
824
+ "daily-project-stop",
825
+ "cooldown",
826
+ "account-concurrency",
827
+ "project-concurrency",
828
+ "token-bucket-empty",
829
+ "fresh-window-blocked",
830
+ "quota-zero",
831
+ "flagged",
832
+ "disabled",
833
+ ];
834
+ const grouped = new Map<RoutingRejectionReason, Map<string, number>>();
835
+
836
+ for (const entry of diagnostics) {
837
+ if (!entry.rejectedReason) continue;
838
+ const details = grouped.get(entry.rejectedReason) ?? new Map<string, number>();
839
+ const detail = entry.rejectedDetail || entry.rejectedReason;
840
+ details.set(detail, (details.get(detail) ?? 0) + 1);
841
+ grouped.set(entry.rejectedReason, details);
842
+ }
843
+
844
+ const orderedReasons = [
845
+ ...priority.filter((reason) => grouped.has(reason)),
846
+ ...Array.from(grouped.keys()).filter((reason) => !priority.includes(reason)),
847
+ ];
848
+ const summaries: string[] = [];
849
+ for (const reason of orderedReasons) {
850
+ const details = grouped.get(reason);
851
+ if (!details) continue;
852
+ for (const [detail, count] of details.entries()) {
853
+ summaries.push(`${count} account${count === 1 ? "" : "s"}: ${detail}`);
854
+ }
855
+ }
856
+
857
+ return summaries;
858
+ }
859
+
717
860
  private compareRoutingCandidate(
718
861
  candidate: { priority: number; quota: number; tier: number; health: number; distance: number; tokenRatio: number; hybridScore: number },
719
862
  best: { priority: number; quota: number; tier: number; health: number; distance: number; tokenRatio: number; hybridScore: number },
@@ -828,10 +971,7 @@ export class AccountRotator {
828
971
  ? `Best route is ${selectedEmail} using ${policy}.`
829
972
  : "No routable account is available for this model.";
830
973
  if (!selectedEmail) {
831
- const reasons = diagnostics
832
- .filter((entry) => entry.rejectedReason)
833
- .map((entry) => entry.rejectedDetail || entry.rejectedReason)
834
- .slice(0, 3);
974
+ const reasons = this.summarizeRoutingRejections(diagnostics).slice(0, 5);
835
975
  if (reasons.length > 0) reason += ` ${reasons.join("; ")}.`;
836
976
  } else if (policy === "hybrid") {
837
977
  reason = `Best route is ${selectedEmail} using hybrid score ${selectedScore.toFixed(1)}.`;
@@ -987,7 +1127,9 @@ export class AccountRotator {
987
1127
  `[${modelKey}] All accounts exhausted. Waiting ${Math.ceil(shortestCooldown / 1000)}s for cooldown`,
988
1128
  );
989
1129
  } else {
990
- this.log(`[${modelKey}] All accounts disabled or unavailable`);
1130
+ const diagnostics = this.buildRoutingDiagnostics(modelKey, now);
1131
+ this.routingDiagnostics[modelKey] = diagnostics;
1132
+ this.log(`[${modelKey}] All accounts disabled or unavailable: ${diagnostics.reason}`, "warn");
991
1133
  }
992
1134
  return null;
993
1135
  }
@@ -1061,7 +1203,7 @@ export class AccountRotator {
1061
1203
  this.shouldUseRequestCountRotation(account, modelKey) &&
1062
1204
  state.requestsOnActiveAccount >= this.config.requestsPerRotation;
1063
1205
 
1064
- this.saveState();
1206
+ this.scheduleStateSave();
1065
1207
  if (shouldRotate) {
1066
1208
  this.log(
1067
1209
  `${account.config.label || account.config.email} [${modelKey}]: hit rotation threshold (${state.requestsOnActiveAccount}/${this.config.requestsPerRotation})`,
@@ -1094,7 +1236,7 @@ export class AccountRotator {
1094
1236
 
1095
1237
  // Lazy consolidation
1096
1238
  this.consolidateTokenBuckets(now);
1097
- this.saveTokenUsage();
1239
+ this.scheduleTokenUsageSave();
1098
1240
  }
1099
1241
 
1100
1242
  recordLatency(model: string | undefined, ttfbMs: number, totalMs: number): void {
@@ -1261,6 +1403,50 @@ export class AccountRotator {
1261
1403
  } catch { /* best effort */ }
1262
1404
  }
1263
1405
 
1406
+ /**
1407
+ * Schedule a debounced token-usage save. Same coalescing pattern as
1408
+ * scheduleStateSave: multiple calls within TOKEN_USAGE_SAVE_DEBOUNCE_MS
1409
+ * collapse into a single write. recordTokenUsage() calls this instead of
1410
+ * saveTokenUsage() to avoid blocking the event loop on every request.
1411
+ */
1412
+ scheduleTokenUsageSave(): void {
1413
+ if (this.tokenUsageSaveTimer) return;
1414
+ this.tokenUsageSaveTimer = setTimeout(() => {
1415
+ this.tokenUsageSaveTimer = null;
1416
+ void this.runScheduledTokenUsageSave();
1417
+ }, AccountRotator.TOKEN_USAGE_SAVE_DEBOUNCE_MS);
1418
+ if (this.tokenUsageSaveTimer.unref) this.tokenUsageSaveTimer.unref();
1419
+ }
1420
+
1421
+ private async runScheduledTokenUsageSave(): Promise<void> {
1422
+ if (this.tokenUsageSaveInflight) {
1423
+ this.tokenUsageSavePending = true;
1424
+ return;
1425
+ }
1426
+ this.tokenUsageSaveInflight = true;
1427
+ try {
1428
+ this.saveTokenUsage();
1429
+ } finally {
1430
+ this.tokenUsageSaveInflight = false;
1431
+ if (this.tokenUsageSavePending) {
1432
+ this.tokenUsageSavePending = false;
1433
+ this.scheduleTokenUsageSave();
1434
+ }
1435
+ }
1436
+ }
1437
+
1438
+ /**
1439
+ * Force-flush any pending token-usage write. Called by SIGTERM/SIGINT in
1440
+ * index.ts to minimise data loss on shutdown.
1441
+ */
1442
+ flushPendingTokenUsageSaveSync(): void {
1443
+ if (this.tokenUsageSaveTimer) {
1444
+ clearTimeout(this.tokenUsageSaveTimer);
1445
+ this.tokenUsageSaveTimer = null;
1446
+ }
1447
+ this.saveTokenUsage();
1448
+ }
1449
+
1264
1450
  getTokenUsage(): TokenUsageData {
1265
1451
  // Buckets are hierarchical rollups: minutes → hours → days → months.
1266
1452
  // A minute period that has already been rolled into an hour bucket must
@@ -1343,7 +1529,7 @@ export class AccountRotator {
1343
1529
  `${account.config.label || account.config.email} [${modelKey}]: EXHAUSTED, cooldown ${Math.ceil(cooldownMs / 1000)}s${errorDetail}`,
1344
1530
  "warn",
1345
1531
  );
1346
- this.saveState();
1532
+ this.scheduleStateSave();
1347
1533
  }
1348
1534
 
1349
1535
  recordProvider429(account: AccountRuntime, model: string | undefined, cooldownMs: number): void {
@@ -1417,7 +1603,7 @@ export class AccountRotator {
1417
1603
  account.disabled = true;
1418
1604
  this.log(`${account.config.email}: DISABLED after ${account.consecutiveErrors} consecutive errors`, "error");
1419
1605
  }
1420
- this.saveState();
1606
+ this.scheduleStateSave();
1421
1607
  }
1422
1608
 
1423
1609
  enableAccount(email: string): boolean {
@@ -1596,7 +1782,7 @@ export class AccountRotator {
1596
1782
  }
1597
1783
  const msg = `Token refresh error: ${err instanceof Error ? err.message : String(err)}`;
1598
1784
  this.markError(account, msg);
1599
- throw new Error(msg);
1785
+ throw new Error(msg, { cause: err });
1600
1786
  }
1601
1787
  }
1602
1788
 
@@ -1633,7 +1819,7 @@ export class AccountRotator {
1633
1819
  "warn",
1634
1820
  );
1635
1821
  }
1636
- this.saveState();
1822
+ this.scheduleStateSave();
1637
1823
  }
1638
1824
 
1639
1825
  startRequest(account: AccountRuntime, modelKey?: string): void {
@@ -1670,10 +1856,12 @@ export class AccountRotator {
1670
1856
  const retryTimes: number[] = [];
1671
1857
  if (this.protectivePauseUntil > now) retryTimes.push(this.protectivePauseUntil);
1672
1858
  const modelKey = model ? (resolveQuotaModelKey(model) ?? "__default__") : "__default__";
1859
+ const dailyResetAt = nextUtcDayStartMs(now);
1673
1860
  const modelBreaker = this.modelBreakers[modelKey] ?? 0;
1674
1861
  if (modelBreaker > now) retryTimes.push(modelBreaker);
1675
1862
  for (const account of this.accounts) {
1676
1863
  if (account.disabled || account.flagged) continue;
1864
+ if (this.isDailySafetyStopped(account, now)) retryTimes.push(dailyResetAt);
1677
1865
  const cooldown = Math.max(account.cooldownsByModel[modelKey] ?? 0, account.cooldownsByModel.__default__ ?? 0);
1678
1866
  if (cooldown > now) retryTimes.push(cooldown);
1679
1867
  const projectBreaker = this.projectModelBreakers[projectModelKey(account.config.projectId, modelKey)] ?? 0;
@@ -1720,6 +1908,10 @@ export class AccountRotator {
1720
1908
  activeForModels,
1721
1909
  requestsSinceRotation: a.requestsSinceRotation,
1722
1910
  totalRequests: a.totalRequests,
1911
+ dailyRequestCount: this.getAccountDailyCount(a, now),
1912
+ dailyAccountStopRequests: this.config.dailyAccountStopRequests ?? 350,
1913
+ dailyProjectRequestCount: this.getProjectDailyCount(a.config.projectId, now),
1914
+ dailyProjectStopRequests: this.config.dailyProjectStopRequests ?? 1200,
1723
1915
  cooldownsByModel: a.cooldownsByModel,
1724
1916
  lastUsed: a.lastUsed,
1725
1917
  lastError: a.lastError,
@@ -2000,6 +2192,7 @@ export class AccountRotator {
2000
2192
  private getRoutingHealth(now: number, accounts: AccountStatus[]): StatusResponse["routingHealth"] {
2001
2193
  const activeCount = accounts.filter((a) => a.status === "active").length;
2002
2194
  const readyCount = accounts.filter((a) => a.status === "ready").length;
2195
+ const exhaustedCount = accounts.filter((a) => a.status === "exhausted").length;
2003
2196
  const cooldownCount = accounts.filter((a) => a.status === "cooldown").length;
2004
2197
  const flaggedCount = accounts.filter((a) => a.status === "flagged").length;
2005
2198
  const disabledCount = accounts.filter((a) => a.status === "disabled").length;
@@ -2007,9 +2200,10 @@ export class AccountRotator {
2007
2200
  const busyCount = accounts.filter(
2008
2201
  (a) => a.status !== "disabled" && a.status !== "flagged" && a.inFlightRequests > 0,
2009
2202
  ).length;
2010
- const rawAvailableCount = this.accounts.filter((a) => this.isAvailable(a, now)).length;
2203
+ const rawAvailableCount = this.accounts.filter((a) => this.isAvailable(a, now) && !this.isDailySafetyStopped(a, now)).length;
2011
2204
  const timedAvailableCount = this.accounts.filter((account) => {
2012
2205
  if (!this.isAvailable(account, now)) return false;
2206
+ if (this.isDailySafetyStopped(account, now)) return false;
2013
2207
  const hasTimedQuota = account.quota.some((q) => q.percentRemaining !== 0 && q.timerType !== "fresh");
2014
2208
  return hasTimedQuota || account.allowFreshWindowStartsOverride;
2015
2209
  }).length;
@@ -2104,6 +2298,22 @@ export class AccountRotator {
2104
2298
  };
2105
2299
  }
2106
2300
 
2301
+ if (exhaustedCount > 0) {
2302
+ return {
2303
+ state: "stopped",
2304
+ reason: "All otherwise available accounts are stopped by local daily safety budgets until the next UTC day.",
2305
+ nextRetryIn: Math.max(0, nextUtcDayStartMs(now) - now),
2306
+ availableCount,
2307
+ readyCount,
2308
+ activeCount,
2309
+ cooldownCount,
2310
+ busyCount,
2311
+ flaggedCount,
2312
+ disabledCount,
2313
+ errorCount,
2314
+ };
2315
+ }
2316
+
2107
2317
  return {
2108
2318
  state: "stopped",
2109
2319
  reason: !this.allowFreshWindowStarts
package/src/telemetry.ts CHANGED
@@ -25,7 +25,10 @@ const telemetryLogger = logger.child("telemetry");
25
25
 
26
26
  // ── Public telemetry endpoint (not a secret — anonymous data only) ───
27
27
  // Update this URL to your VPS before publishing to npm.
28
- const TELEMETRY_ENDPOINT = "http://telemetry.dragont.ec:3800/v1/events";
28
+ // Can be overridden via PI_ROTATOR_TELEMETRY_URL.
29
+ // HTTPS is preferred to avoid leaking the operator's IP in plaintext.
30
+ const DEFAULT_TELEMETRY_ENDPOINT = "http://telemetry.dragont.ec:3800/v1/events";
31
+ const TELEMETRY_ENDPOINT = process.env.PI_ROTATOR_TELEMETRY_URL?.trim() || DEFAULT_TELEMETRY_ENDPOINT;
29
32
 
30
33
  const HEARTBEAT_INTERVAL_MS = 1 * 60 * 60 * 1000; // 1 hour
31
34
  const SEND_TIMEOUT_MS = 5000;
@@ -57,6 +60,33 @@ export function isTelemetryEnabled(): boolean {
57
60
  return env !== "off" && env !== "false" && env !== "0";
58
61
  }
59
62
 
63
+ // ── Insecure-endpoint warning ───────────────────────────────────────
64
+ let warnedAboutInsecureTelemetry = false;
65
+
66
+ /**
67
+ * Emit a one-time warning if the telemetry endpoint is plain HTTP, since that
68
+ * leaks the operator's IP and network metadata to any on-path observer.
69
+ * The warning can be silenced with PI_ROTATOR_TELEMETRY_INSECURE_OK=1.
70
+ *
71
+ * The INSECURE_OK check runs before the once-flag so the operator's explicit
72
+ * acknowledgement is always honored, even after a previous run warned.
73
+ */
74
+ export function warnIfInsecureTelemetryEndpoint(
75
+ endpoint: string = TELEMETRY_ENDPOINT,
76
+ env: NodeJS.ProcessEnv = process.env,
77
+ ): boolean {
78
+ if (!/^http:\/\//i.test(endpoint)) return false;
79
+ if (env.PI_ROTATOR_TELEMETRY_INSECURE_OK === "1") return false;
80
+ if (warnedAboutInsecureTelemetry) return true;
81
+ warnedAboutInsecureTelemetry = true;
82
+ telemetryLogger.log(
83
+ "warn",
84
+ `Telemetry endpoint uses plain HTTP (${endpoint}). This leaks the operator's IP on every heartbeat. ` +
85
+ `Set PI_ROTATOR_TELEMETRY_URL to an https:// endpoint, or PI_ROTATOR_TELEMETRY_INSECURE_OK=1 to silence this warning.`,
86
+ );
87
+ return true;
88
+ }
89
+
60
90
  // ── Feature tracking (set by other modules) ──────────────────────────
61
91
  const _featuresUsed = new Set<string>();
62
92