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.
- package/CHANGELOG.md +49 -0
- package/README.md +5 -4
- package/package.json +11 -3
- package/src/account-store.ts +30 -0
- package/src/admin-auth.ts +84 -1
- package/src/compat/cache.ts +42 -0
- package/src/compat/model-specs.ts +78 -0
- package/src/compat/schema-sanitizer.ts +277 -0
- package/src/compat/translators.ts +1538 -0
- package/src/compat.ts +1537 -2357
- package/src/dashboard.ts +11 -11
- package/src/index.ts +41 -1
- package/src/logger.ts +6 -1
- package/src/notification-poller.ts +4 -1
- package/src/oauth.ts +31 -0
- package/src/onboarding.ts +19 -0
- package/src/paths.ts +23 -3
- package/src/proxy.ts +375 -171
- package/src/responses-store.ts +203 -0
- package/src/rotator.ts +226 -16
- package/src/telemetry.ts +31 -1
- package/src/types.ts +70 -0
- package/src/validators.ts +36 -0
- package/src/antigravity-prompt.ts +0 -80
|
@@ -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
|
-
|
|
666
|
-
if (
|
|
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
|
|
685
|
-
if (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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|