offrouter-core 0.1.0 → 0.2.1

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,435 @@
1
+ /**
2
+ * File-backed usage store for OffRouter.
3
+ *
4
+ * Wraps an in-memory provider-health/quota engine and adds JSON persistence for
5
+ * per-account quota usage so near-limit detection survives across CLI
6
+ * invocations and MCP server restarts.
7
+ *
8
+ * On-disk layout (`$OFFROUTER_HOME/state/usage.json`):
9
+ * { version: 1, accounts: Record<`${providerId}:${accountId}`, PersistedAccount> }
10
+ *
11
+ * Safety guarantees mirror FileSecretStore:
12
+ * - atomic write via temp file + rename
13
+ * - chmod 0600 on POSIX after every write
14
+ * - missing or corrupt file => graceful fallback to empty state (never throws)
15
+ *
16
+ * Provider health/quota math (sliding-window error budget, cooldown) stays
17
+ * in-memory and process-local; only account quota usage is persisted, which is
18
+ * what near-limit detection and quota reporting depend on across sessions.
19
+ */
20
+ import { readFileSync } from "node:fs";
21
+ import {
22
+ chmod,
23
+ mkdir,
24
+ readFile,
25
+ rename,
26
+ unlink,
27
+ writeFile,
28
+ } from "node:fs/promises";
29
+ import { dirname } from "node:path";
30
+ import { randomBytes } from "node:crypto";
31
+ import {
32
+ computeAccountSnapshot,
33
+ InMemoryUsageStore,
34
+ type AccountUsageDoc,
35
+ type AccountUsageSnapshot,
36
+ type PaidFallbackExplanation,
37
+ type ProviderUsageSnapshot,
38
+ type RecordUsageResult,
39
+ type UsageEvent,
40
+ type UsageStore,
41
+ } from "./usage.js";
42
+ import type { ProviderHealth } from "./types.js";
43
+
44
+ export { DEFAULT_NEAR_LIMIT_THRESHOLD } from "./usage.js";
45
+ export { InMemoryUsageStore } from "./usage.js";
46
+ export type { UsageStore } from "./usage.js";
47
+
48
+ export interface FileUsageStoreFileSystem {
49
+ mkdir: typeof mkdir;
50
+ readFile: typeof readFile;
51
+ rename: typeof rename;
52
+ unlink: typeof unlink;
53
+ writeFile: typeof writeFile;
54
+ chmod: typeof chmod;
55
+ }
56
+
57
+ export interface FileUsageStoreOptions {
58
+ /** Absolute path to usage.json (typically `$OFFROUTER_HOME/state/usage.json`). */
59
+ filePath: string;
60
+ /** Injectable file operations for failure-path tests. Defaults to node fs. */
61
+ fileSystem?: Partial<FileUsageStoreFileSystem>;
62
+ /** Injectable clock for tests. */
63
+ now?: () => Date;
64
+ /**
65
+ * In-memory store that owns provider health/quota logic. Account usage is
66
+ * persisted separately and mirrored onto this delegate. Useful for tests.
67
+ */
68
+ delegate?: UsageStore;
69
+ }
70
+
71
+ /** On-disk schema for FileUsageStore. */
72
+ interface PersistedAccount {
73
+ providerId: string;
74
+ accountId: string;
75
+ used: number;
76
+ limit?: number;
77
+ lastUpdated: string;
78
+ health?: ProviderHealth;
79
+ cooldownUntil?: number;
80
+ recentErrors?: number[];
81
+ }
82
+
83
+ interface PersistedUsageDocument {
84
+ version: 1;
85
+ accounts: Record<string, PersistedAccount>;
86
+ }
87
+
88
+ const EMPTY_DOC: PersistedUsageDocument = { version: 1, accounts: {} };
89
+
90
+ function accountKey(providerId: string, accountId: string): string {
91
+ return `${providerId}:${accountId}`;
92
+ }
93
+
94
+ /**
95
+ * Parse a persisted usage document leniently. A missing or corrupt file is
96
+ * treated as empty state rather than a hard failure so a bad write can never
97
+ * wedge the CLI.
98
+ */
99
+ function parseUsageDocument(raw: string): PersistedUsageDocument {
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = JSON.parse(raw) as unknown;
103
+ } catch {
104
+ // Corrupt JSON: fall back to empty instead of throwing.
105
+ return { ...EMPTY_DOC, accounts: {} };
106
+ }
107
+
108
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
109
+ return { ...EMPTY_DOC, accounts: {} };
110
+ }
111
+
112
+ const record = parsed as Record<string, unknown>;
113
+ const accountsRaw = record.accounts;
114
+ if (
115
+ typeof accountsRaw !== "object" ||
116
+ accountsRaw === null ||
117
+ Array.isArray(accountsRaw)
118
+ ) {
119
+ return { ...EMPTY_DOC, accounts: {} };
120
+ }
121
+
122
+ const accounts: Record<string, PersistedAccount> = {};
123
+ for (const [key, value] of Object.entries(
124
+ accountsRaw as Record<string, unknown>,
125
+ )) {
126
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
127
+ continue;
128
+ }
129
+ const entry = value as Record<string, unknown>;
130
+ if (
131
+ typeof entry.providerId !== "string" ||
132
+ typeof entry.accountId !== "string" ||
133
+ typeof entry.used !== "number"
134
+ ) {
135
+ continue;
136
+ }
137
+ const doc: PersistedAccount = {
138
+ providerId: entry.providerId,
139
+ accountId: entry.accountId,
140
+ used: entry.used,
141
+ lastUpdated:
142
+ typeof entry.lastUpdated === "string" ? entry.lastUpdated : "",
143
+ };
144
+ if (typeof entry.limit === "number") {
145
+ doc.limit = entry.limit;
146
+ }
147
+ accounts[key] = doc;
148
+ }
149
+
150
+ return { version: 1, accounts };
151
+ }
152
+
153
+ /**
154
+ * File-backed UsageStore. Persists per-account quota usage atomically and
155
+ * delegates provider health/quota logic to an in-memory engine.
156
+ */
157
+ export class FileUsageStore implements UsageStore {
158
+ readonly filePath: string;
159
+ readonly #fs: FileUsageStoreFileSystem;
160
+ readonly #now: () => Date;
161
+ readonly #delegate: UsageStore;
162
+ /** In-memory mirror of persisted account docs, hydrated lazily. */
163
+ #accounts: Map<string, AccountUsageDoc> | null = null;
164
+ /** Serializes mutations so concurrent writes never interleave. */
165
+ #chain: Promise<unknown> = Promise.resolve();
166
+
167
+ constructor(options: FileUsageStoreOptions) {
168
+ if (!options?.filePath || typeof options.filePath !== "string") {
169
+ throw new TypeError("FileUsageStore requires options.filePath");
170
+ }
171
+ this.filePath = options.filePath;
172
+ this.#now = options.now ?? (() => new Date());
173
+ this.#delegate =
174
+ options.delegate ?? new InMemoryUsageStore({ now: this.#now });
175
+ this.#fs = {
176
+ mkdir,
177
+ readFile,
178
+ rename,
179
+ unlink,
180
+ writeFile,
181
+ chmod,
182
+ ...options.fileSystem,
183
+ };
184
+ }
185
+
186
+ #exclusive<T>(fn: () => T | Promise<T>): Promise<T> {
187
+ const run = this.#chain.then(fn, fn);
188
+ this.#chain = run.then(
189
+ () => undefined,
190
+ () => undefined,
191
+ );
192
+ return run;
193
+ }
194
+
195
+ /** Load the file once into the in-memory account map. Never throws. */
196
+ async #load(): Promise<Map<string, AccountUsageDoc>> {
197
+ if (this.#accounts) {
198
+ return this.#accounts;
199
+ }
200
+ let doc: PersistedUsageDocument;
201
+ try {
202
+ const raw = await this.#fs.readFile(this.filePath, "utf8");
203
+ doc = parseUsageDocument(raw);
204
+ } catch (err) {
205
+ if (
206
+ err &&
207
+ typeof err === "object" &&
208
+ "code" in err &&
209
+ (err as { code?: string }).code === "ENOENT"
210
+ ) {
211
+ doc = { ...EMPTY_DOC, accounts: {} };
212
+ } else {
213
+ // Any read failure (including corruption surfaced as a throw) is
214
+ // treated as empty state so the CLI keeps working.
215
+ doc = { ...EMPTY_DOC, accounts: {} };
216
+ }
217
+ }
218
+ const map = new Map<string, AccountUsageDoc>();
219
+ for (const [key, entry] of Object.entries(doc.accounts)) {
220
+ map.set(key, {
221
+ providerId: entry.providerId,
222
+ accountId: entry.accountId,
223
+ used: entry.used,
224
+ limit: entry.limit,
225
+ updatedAt: entry.lastUpdated,
226
+ });
227
+ }
228
+ this.#accounts = map;
229
+ return map;
230
+ }
231
+
232
+ /** Synchronous load for toJSON diagnostics; never throws. */
233
+ #loadKnownSync(): Map<string, AccountUsageDoc> {
234
+ if (this.#accounts) {
235
+ return this.#accounts;
236
+ }
237
+ let map = new Map<string, AccountUsageDoc>();
238
+ try {
239
+ const raw = readFileSync(this.filePath, "utf8");
240
+ const doc = parseUsageDocument(raw);
241
+ map = new Map();
242
+ for (const [key, entry] of Object.entries(doc.accounts)) {
243
+ map.set(key, {
244
+ providerId: entry.providerId,
245
+ accountId: entry.accountId,
246
+ used: entry.used,
247
+ limit: entry.limit,
248
+ updatedAt: entry.lastUpdated,
249
+ });
250
+ }
251
+ } catch {
252
+ map = new Map();
253
+ }
254
+ return map;
255
+ }
256
+
257
+ async #persist(accounts: Map<string, AccountUsageDoc>): Promise<void> {
258
+ const persistedAccounts: Record<string, PersistedAccount> = {};
259
+ for (const [key, doc] of accounts) {
260
+ const snap = computeAccountSnapshot(doc);
261
+ const entry: PersistedAccount = {
262
+ providerId: doc.providerId,
263
+ accountId: doc.accountId,
264
+ used: doc.used,
265
+ lastUpdated: doc.updatedAt,
266
+ health: snap.health,
267
+ recentErrors: [],
268
+ };
269
+ if (doc.limit !== undefined) {
270
+ entry.limit = doc.limit;
271
+ }
272
+ persistedAccounts[key] = entry;
273
+ }
274
+ const payload = `${JSON.stringify(
275
+ { version: 1 as const, accounts: persistedAccounts },
276
+ null,
277
+ 2,
278
+ )}\n`;
279
+
280
+ await this.#fs.mkdir(dirname(this.filePath), { recursive: true });
281
+ const tmp = `${this.filePath}.${randomBytes(8).toString("hex")}.tmp`;
282
+ try {
283
+ await this.#fs.writeFile(tmp, payload, {
284
+ encoding: "utf8",
285
+ mode: 0o600,
286
+ });
287
+ await this.#fs.chmod(tmp, 0o600);
288
+ await this.#fs.rename(tmp, this.filePath);
289
+ await this.#fs.chmod(this.filePath, 0o600);
290
+ } catch (err) {
291
+ await this.#fs.unlink(tmp).catch((unlinkErr: unknown) => {
292
+ if (
293
+ unlinkErr &&
294
+ typeof unlinkErr === "object" &&
295
+ "code" in unlinkErr &&
296
+ (unlinkErr as { code?: string }).code === "ENOENT"
297
+ ) {
298
+ return;
299
+ }
300
+ throw unlinkErr;
301
+ });
302
+ throw err;
303
+ }
304
+ }
305
+
306
+ // ---- Provider health/quota: delegated to the in-memory engine ----
307
+
308
+ record(event: UsageEvent): Promise<RecordUsageResult> {
309
+ return this.#delegate.record(event);
310
+ }
311
+
312
+ getProvider(providerId: string): Promise<ProviderUsageSnapshot | undefined> {
313
+ return this.#delegate.getProvider(providerId);
314
+ }
315
+
316
+ listProviders(): Promise<ProviderUsageSnapshot[]> {
317
+ return this.#delegate.listProviders();
318
+ }
319
+
320
+ setSubscriptionQuota(
321
+ providerId: string,
322
+ input: {
323
+ limit?: number;
324
+ used?: number;
325
+ status?: import("./types.js").SubscriptionStatus;
326
+ modelId?: string;
327
+ at?: Date;
328
+ },
329
+ ): Promise<ProviderUsageSnapshot> {
330
+ return this.#delegate.setSubscriptionQuota(providerId, input);
331
+ }
332
+
333
+ explainPaidFallback(
334
+ providerId: string,
335
+ options?: { hasApiKeyCandidate?: boolean },
336
+ ): Promise<PaidFallbackExplanation> {
337
+ return this.#delegate.explainPaidFallback(providerId, options);
338
+ }
339
+
340
+ // ---- Per-account quota usage: persisted ----
341
+
342
+ async recordUsage(
343
+ providerId: string,
344
+ accountId: string,
345
+ input: { used?: number; limit?: number; at?: Date },
346
+ ): Promise<AccountUsageSnapshot> {
347
+ return this.#exclusive(async () => {
348
+ const accounts = await this.#load();
349
+ const at = input.at ?? this.#now();
350
+ const key = accountKey(providerId, accountId);
351
+ let doc = accounts.get(key);
352
+ if (!doc) {
353
+ doc = {
354
+ providerId,
355
+ accountId,
356
+ used: 0,
357
+ updatedAt: at.toISOString(),
358
+ };
359
+ accounts.set(key, doc);
360
+ }
361
+ if (input.used !== undefined) doc.used = input.used;
362
+ if (input.limit !== undefined) doc.limit = input.limit;
363
+ doc.updatedAt = at.toISOString();
364
+ await this.#persist(accounts);
365
+ return computeAccountSnapshot(doc);
366
+ });
367
+ }
368
+
369
+ async isNearLimit(
370
+ providerId: string,
371
+ accountId: string,
372
+ threshold?: number,
373
+ ): Promise<boolean> {
374
+ return this.#exclusive(async () => {
375
+ const accounts = await this.#load();
376
+ const doc = accounts.get(accountKey(providerId, accountId));
377
+ if (!doc) return false;
378
+ return computeAccountSnapshot(doc, threshold).nearLimit;
379
+ });
380
+ }
381
+
382
+ async getNearLimitAccounts(
383
+ threshold?: number,
384
+ ): Promise<AccountUsageSnapshot[]> {
385
+ return this.#exclusive(async () => {
386
+ const accounts = await this.#load();
387
+ const out: AccountUsageSnapshot[] = [];
388
+ for (const doc of accounts.values()) {
389
+ const snap = computeAccountSnapshot(doc, threshold);
390
+ if (snap.nearLimit) out.push(snap);
391
+ }
392
+ return out.sort((a, b) => {
393
+ if (a.providerId !== b.providerId)
394
+ return a.providerId.localeCompare(b.providerId);
395
+ return a.accountId.localeCompare(b.accountId);
396
+ });
397
+ });
398
+ }
399
+
400
+ async getAccountUsage(
401
+ providerId: string,
402
+ accountId: string,
403
+ ): Promise<AccountUsageSnapshot | undefined> {
404
+ return this.#exclusive(async () => {
405
+ const accounts = await this.#load();
406
+ const doc = accounts.get(accountKey(providerId, accountId));
407
+ if (!doc) return undefined;
408
+ return computeAccountSnapshot(doc);
409
+ });
410
+ }
411
+
412
+ async listAccountUsage(): Promise<AccountUsageSnapshot[]> {
413
+ return this.#exclusive(async () => {
414
+ const accounts = await this.#load();
415
+ const out: AccountUsageSnapshot[] = [];
416
+ for (const doc of accounts.values()) {
417
+ out.push(computeAccountSnapshot(doc));
418
+ }
419
+ return out.sort((a, b) => {
420
+ if (a.providerId !== b.providerId)
421
+ return a.providerId.localeCompare(b.providerId);
422
+ return a.accountId.localeCompare(b.accountId);
423
+ });
424
+ });
425
+ }
426
+
427
+ toJSON(): Record<string, unknown> {
428
+ const accounts = this.#loadKnownSync();
429
+ return {
430
+ kind: "file-usage",
431
+ filePath: this.filePath,
432
+ accountCount: accounts.size,
433
+ };
434
+ }
435
+ }
package/src/usage.ts CHANGED
@@ -118,6 +118,25 @@ export interface AccountUsageSnapshot {
118
118
  percentRemaining?: number;
119
119
  nearLimit: boolean;
120
120
  subscriptionStatus: SubscriptionStatus;
121
+ /**
122
+ * Derived account health from quota usage: "dead" when exhausted,
123
+ * "degraded" when near-limit, otherwise "healthy". Useful for
124
+ * persisted stores that need a stable, restorable health signal.
125
+ */
126
+ health?: ProviderHealth;
127
+ updatedAt: string;
128
+ }
129
+
130
+ /**
131
+ * Minimal persisted document for a single account's quota usage.
132
+ * Shared between the in-memory and file-backed usage stores so snapshot
133
+ * math stays in one place.
134
+ */
135
+ export interface AccountUsageDoc {
136
+ providerId: string;
137
+ accountId: string;
138
+ used: number;
139
+ limit?: number;
121
140
  updatedAt: string;
122
141
  }
123
142
 
@@ -576,7 +595,7 @@ export class InMemoryUsageStore implements UsageStore {
576
595
  if (input.used !== undefined) state.used = input.used;
577
596
  if (input.limit !== undefined) state.limit = input.limit;
578
597
  state.updatedAt = at.toISOString();
579
- return toAccountSnapshot(state);
598
+ return computeAccountSnapshot(state);
580
599
  });
581
600
  }
582
601
 
@@ -589,7 +608,7 @@ export class InMemoryUsageStore implements UsageStore {
589
608
  const key = `${providerId}:${accountId}`;
590
609
  const state = this.#accounts.get(key);
591
610
  if (!state) return false;
592
- return toAccountSnapshot(state, threshold).nearLimit;
611
+ return computeAccountSnapshot(state, threshold).nearLimit;
593
612
  });
594
613
  }
595
614
 
@@ -597,7 +616,7 @@ export class InMemoryUsageStore implements UsageStore {
597
616
  return this.#exclusive(() => {
598
617
  const out: AccountUsageSnapshot[] = [];
599
618
  for (const state of this.#accounts.values()) {
600
- const snap = toAccountSnapshot(state, threshold);
619
+ const snap = computeAccountSnapshot(state, threshold);
601
620
  if (snap.nearLimit) out.push(snap);
602
621
  }
603
622
  return out.sort((a, b) => {
@@ -615,7 +634,7 @@ export class InMemoryUsageStore implements UsageStore {
615
634
  const key = `${providerId}:${accountId}`;
616
635
  const state = this.#accounts.get(key);
617
636
  if (!state) return undefined;
618
- return toAccountSnapshot(state);
637
+ return computeAccountSnapshot(state);
619
638
  });
620
639
  }
621
640
 
@@ -623,7 +642,7 @@ export class InMemoryUsageStore implements UsageStore {
623
642
  return this.#exclusive(() => {
624
643
  const out: AccountUsageSnapshot[] = [];
625
644
  for (const state of this.#accounts.values()) {
626
- out.push(toAccountSnapshot(state));
645
+ out.push(computeAccountSnapshot(state));
627
646
  }
628
647
  return out.sort((a, b) => {
629
648
  if (a.providerId !== b.providerId) return a.providerId.localeCompare(b.providerId);
@@ -763,38 +782,46 @@ function toSnapshot(state: InternalProviderState): ProviderUsageSnapshot {
763
782
  };
764
783
  }
765
784
 
766
- function toAccountSnapshot(
767
- state: AccountQuotaState,
768
- threshold?: number,
785
+ export function computeAccountSnapshot(
786
+ doc: AccountUsageDoc,
787
+ threshold: number = DEFAULT_NEAR_LIMIT_THRESHOLD,
769
788
  ): AccountUsageSnapshot {
770
- const t = threshold ?? DEFAULT_NEAR_LIMIT_THRESHOLD;
771
- const limit = state.limit;
772
- const used = state.used;
773
- const remaining = limit === undefined ? undefined : Math.max(0, limit - used);
789
+ const limit = doc.limit;
790
+ const used = doc.used;
791
+ const remaining =
792
+ limit === undefined ? undefined : Math.max(0, limit - used);
774
793
  let status: SubscriptionStatus;
775
794
  let nearLimit = false;
795
+ let health: ProviderHealth;
776
796
  if (limit === undefined) {
777
797
  status = "unknown";
798
+ health = "healthy";
778
799
  } else if (used >= limit) {
779
800
  status = "exhausted";
780
- } else if ((limit - used) / limit < t) {
801
+ health = "dead";
802
+ } else if ((limit - used) / limit < threshold) {
781
803
  status = "near-limit";
782
804
  nearLimit = true;
805
+ health = "degraded";
783
806
  } else {
784
807
  status = "active";
808
+ health = "healthy";
785
809
  }
786
810
  const percentRemaining =
787
- limit === undefined ? undefined : Math.round(((limit - used) / limit) * 100);
811
+ limit === undefined
812
+ ? undefined
813
+ : Math.round(((limit - used) / limit) * 100);
788
814
  return {
789
- providerId: state.providerId,
790
- accountId: state.accountId,
815
+ providerId: doc.providerId,
816
+ accountId: doc.accountId,
791
817
  used,
792
818
  limit,
793
819
  remaining,
794
820
  percentRemaining,
795
821
  nearLimit,
796
822
  subscriptionStatus: status,
797
- updatedAt: state.updatedAt,
823
+ health,
824
+ updatedAt: doc.updatedAt,
798
825
  };
799
826
  }
800
827