offrouter-core 0.0.0 → 0.2.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/usage.ts CHANGED
@@ -104,6 +104,42 @@ export interface RecordUsageResult {
104
104
  previousHealth: ProviderHealth;
105
105
  }
106
106
 
107
+ export const DEFAULT_NEAR_LIMIT_THRESHOLD = 0.2;
108
+
109
+ /**
110
+ * Snapshot of usage for a specific account within a provider.
111
+ */
112
+ export interface AccountUsageSnapshot {
113
+ providerId: string;
114
+ accountId: string;
115
+ used: number;
116
+ limit?: number;
117
+ remaining?: number;
118
+ percentRemaining?: number;
119
+ nearLimit: boolean;
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;
140
+ updatedAt: string;
141
+ }
142
+
107
143
  /**
108
144
  * Why paid API-key capacity was or was not selected given current usage state.
109
145
  * Consumed by doctor / status / route explain surfaces.
@@ -154,6 +190,35 @@ export interface UsageStore {
154
190
  options?: { hasApiKeyCandidate?: boolean },
155
191
  ): Promise<PaidFallbackExplanation>;
156
192
 
193
+ /**
194
+ * Record usage for a specific account within a provider.
195
+ */
196
+ recordUsage(
197
+ providerId: string,
198
+ accountId: string,
199
+ input: { used?: number; limit?: number; at?: Date },
200
+ ): Promise<AccountUsageSnapshot>;
201
+
202
+ /**
203
+ * Check whether a specific account is near its quota limit.
204
+ */
205
+ isNearLimit(providerId: string, accountId: string, threshold?: number): Promise<boolean>;
206
+
207
+ /**
208
+ * Get all accounts whose usage is near their quota limit.
209
+ */
210
+ getNearLimitAccounts(threshold?: number): Promise<AccountUsageSnapshot[]>;
211
+
212
+ /**
213
+ * Get the usage snapshot for a specific account.
214
+ */
215
+ getAccountUsage(providerId: string, accountId: string): Promise<AccountUsageSnapshot | undefined>;
216
+
217
+ /**
218
+ * List all account usage snapshots.
219
+ */
220
+ listAccountUsage(): Promise<AccountUsageSnapshot[]>;
221
+
157
222
  /**
158
223
  * JSON-safe diagnostic view. No secrets.
159
224
  */
@@ -184,6 +249,14 @@ interface InternalProviderState {
184
249
  updatedAt: string;
185
250
  }
186
251
 
252
+ interface AccountQuotaState {
253
+ providerId: string;
254
+ accountId: string;
255
+ used: number;
256
+ limit?: number;
257
+ updatedAt: string;
258
+ }
259
+
187
260
  const DEFAULT_WINDOW_MS = 60_000;
188
261
  const DEFAULT_HEALTHY_MAX_ERRORS = 2;
189
262
  const DEFAULT_DEAD_MIN_ERRORS = 5;
@@ -200,6 +273,7 @@ const HARD_ERROR_KINDS: ReadonlySet<UsageEventKind> = new Set([
200
273
  */
201
274
  export class InMemoryUsageStore implements UsageStore {
202
275
  readonly #providers = new Map<string, InternalProviderState>();
276
+ readonly #accounts = new Map<string, AccountQuotaState>();
203
277
  readonly #windowMs: number;
204
278
  readonly #healthyMaxErrors: number;
205
279
  readonly #deadMinErrors: number;
@@ -505,6 +579,78 @@ export class InMemoryUsageStore implements UsageStore {
505
579
  };
506
580
  }
507
581
 
582
+ async recordUsage(
583
+ providerId: string,
584
+ accountId: string,
585
+ input: { used?: number; limit?: number; at?: Date },
586
+ ): Promise<AccountUsageSnapshot> {
587
+ return this.#exclusive(() => {
588
+ const at = input.at ?? this.#now();
589
+ const key = `${providerId}:${accountId}`;
590
+ let state = this.#accounts.get(key);
591
+ if (!state) {
592
+ state = { providerId, accountId, used: 0, updatedAt: at.toISOString() };
593
+ this.#accounts.set(key, state);
594
+ }
595
+ if (input.used !== undefined) state.used = input.used;
596
+ if (input.limit !== undefined) state.limit = input.limit;
597
+ state.updatedAt = at.toISOString();
598
+ return computeAccountSnapshot(state);
599
+ });
600
+ }
601
+
602
+ async isNearLimit(
603
+ providerId: string,
604
+ accountId: string,
605
+ threshold?: number,
606
+ ): Promise<boolean> {
607
+ return this.#exclusive(() => {
608
+ const key = `${providerId}:${accountId}`;
609
+ const state = this.#accounts.get(key);
610
+ if (!state) return false;
611
+ return computeAccountSnapshot(state, threshold).nearLimit;
612
+ });
613
+ }
614
+
615
+ async getNearLimitAccounts(threshold?: number): Promise<AccountUsageSnapshot[]> {
616
+ return this.#exclusive(() => {
617
+ const out: AccountUsageSnapshot[] = [];
618
+ for (const state of this.#accounts.values()) {
619
+ const snap = computeAccountSnapshot(state, threshold);
620
+ if (snap.nearLimit) out.push(snap);
621
+ }
622
+ return out.sort((a, b) => {
623
+ if (a.providerId !== b.providerId) return a.providerId.localeCompare(b.providerId);
624
+ return a.accountId.localeCompare(b.accountId);
625
+ });
626
+ });
627
+ }
628
+
629
+ async getAccountUsage(
630
+ providerId: string,
631
+ accountId: string,
632
+ ): Promise<AccountUsageSnapshot | undefined> {
633
+ return this.#exclusive(() => {
634
+ const key = `${providerId}:${accountId}`;
635
+ const state = this.#accounts.get(key);
636
+ if (!state) return undefined;
637
+ return computeAccountSnapshot(state);
638
+ });
639
+ }
640
+
641
+ async listAccountUsage(): Promise<AccountUsageSnapshot[]> {
642
+ return this.#exclusive(() => {
643
+ const out: AccountUsageSnapshot[] = [];
644
+ for (const state of this.#accounts.values()) {
645
+ out.push(computeAccountSnapshot(state));
646
+ }
647
+ return out.sort((a, b) => {
648
+ if (a.providerId !== b.providerId) return a.providerId.localeCompare(b.providerId);
649
+ return a.accountId.localeCompare(b.accountId);
650
+ });
651
+ });
652
+ }
653
+
508
654
  toJSON(): Record<string, unknown> {
509
655
  const providers: Record<string, unknown> = {};
510
656
  for (const [id, state] of this.#providers) {
@@ -523,6 +669,7 @@ export class InMemoryUsageStore implements UsageStore {
523
669
  return {
524
670
  kind: "in-memory-usage",
525
671
  providers,
672
+ accounts: Object.fromEntries(this.#accounts),
526
673
  };
527
674
  }
528
675
 
@@ -635,6 +782,49 @@ function toSnapshot(state: InternalProviderState): ProviderUsageSnapshot {
635
782
  };
636
783
  }
637
784
 
785
+ export function computeAccountSnapshot(
786
+ doc: AccountUsageDoc,
787
+ threshold: number = DEFAULT_NEAR_LIMIT_THRESHOLD,
788
+ ): AccountUsageSnapshot {
789
+ const limit = doc.limit;
790
+ const used = doc.used;
791
+ const remaining =
792
+ limit === undefined ? undefined : Math.max(0, limit - used);
793
+ let status: SubscriptionStatus;
794
+ let nearLimit = false;
795
+ let health: ProviderHealth;
796
+ if (limit === undefined) {
797
+ status = "unknown";
798
+ health = "healthy";
799
+ } else if (used >= limit) {
800
+ status = "exhausted";
801
+ health = "dead";
802
+ } else if ((limit - used) / limit < threshold) {
803
+ status = "near-limit";
804
+ nearLimit = true;
805
+ health = "degraded";
806
+ } else {
807
+ status = "active";
808
+ health = "healthy";
809
+ }
810
+ const percentRemaining =
811
+ limit === undefined
812
+ ? undefined
813
+ : Math.round(((limit - used) / limit) * 100);
814
+ return {
815
+ providerId: doc.providerId,
816
+ accountId: doc.accountId,
817
+ used,
818
+ limit,
819
+ remaining,
820
+ percentRemaining,
821
+ nearLimit,
822
+ subscriptionStatus: status,
823
+ health,
824
+ updatedAt: doc.updatedAt,
825
+ };
826
+ }
827
+
638
828
  /**
639
829
  * Composite facade for callers that want one injectable "state" handle.
640
830
  * Writes stay on the specialized stores; this is a thin convenience.