@velum-labs/routekit-accounts 0.16.0 → 0.16.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.
@@ -1,10 +1,10 @@
1
1
  import { type ModelReasoningCapabilities, ProviderFailureError } from "@velum-labs/routekit-contracts";
2
2
  import type { SubscriptionMode } from "@velum-labs/routekit-registry";
3
3
  import type { SubscriptionAccountSource } from "./account-source.js";
4
- import { type SubscriptionProvider } from "./provider.js";
5
4
  import { AccountActivityCoordinator } from "./activity.js";
6
- import type { AccountLimits, ResetCreditSnapshot, SubscriptionCredential, SubscriptionAccountSetSnapshot, SubscriptionSelectionStrategy } from "./types.js";
7
5
  import type { ConsumeResetCreditResult } from "./provider.js";
6
+ import { type SubscriptionProvider } from "./provider.js";
7
+ import type { AccountLimits, ResetCreditSnapshot, SubscriptionAccountSetSnapshot, SubscriptionCredential, SubscriptionSelectionStrategy } from "./types.js";
8
8
  export type SubscriptionAccountSetOptions = {
9
9
  mode: SubscriptionMode;
10
10
  activity?: AccountActivityCoordinator;
@@ -29,15 +29,22 @@ export type RedeemResetCreditResult = ConsumeResetCreditResult & {
29
29
  label: string;
30
30
  mode: SubscriptionMode;
31
31
  };
32
+ type CooldownContext = {
33
+ model?: string;
34
+ windows?: string[];
35
+ };
32
36
  export declare class RateLimitTracker {
33
37
  #private;
34
38
  constructor(statePath: string, mode?: SubscriptionMode);
35
39
  limits(memberId: string): AccountLimits | undefined;
36
40
  coolingUntil(memberId: string): number | undefined;
41
+ cooldownRevision(memberId: string): number;
42
+ cooldownContext(memberId: string): CooldownContext | undefined;
37
43
  update(memberId: string, limits: AccountLimits): void;
38
- cool(memberId: string, until: number): void;
39
- clearCooling(memberId: string): void;
40
- resetAfterRefresh(memberId: string): void;
44
+ cool(memberId: string, until: number, context?: CooldownContext): number;
45
+ clearCooling(memberId: string, expectedRevision?: number): boolean;
46
+ reconcileSnapshot(memberId: string, limits: AccountLimits, expectedCooldownRevision: number, recovered: boolean): boolean;
47
+ resetAfterRefresh(memberId: string, expectedCooldownRevision: number): boolean;
41
48
  renameMember(sourceId: string, targetId: string): void;
42
49
  }
43
50
  export declare class SubscriptionAccountSetExhaustedError extends ProviderFailureError {
@@ -74,3 +81,4 @@ export declare class SubscriptionAccountSet {
74
81
  refreshUsage(maxAgeMs?: number, signal?: AbortSignal): Promise<void>;
75
82
  execute(model: string | undefined, operation: (credential: SubscriptionCredential) => Promise<Response>, signal?: AbortSignal, observer?: SubscriptionExecutionObserver): Promise<Response>;
76
83
  }
84
+ export {};
@@ -1,14 +1,14 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import { createHmac, randomBytes, randomUUID } from "node:crypto";
3
- import { join } from "node:path";
4
- import { ProviderFailureError, isRetryableProviderFailure } from "@velum-labs/routekit-contracts";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { isRetryableProviderFailure, ProviderFailureError } from "@velum-labs/routekit-contracts";
5
5
  import { CapacityPool, SseDecoder, SseParseError } from "@velum-labs/routekit-gateway";
6
6
  import { writeFileAtomic } from "@velum-labs/routekit-runtime";
7
- import { hasUsableCredits, isOverSwitchThreshold, isPoolEligible, memberHeadroom } from "./admission.js";
8
7
  import { resolveSubscriptionAccounts } from "./account-source.js";
8
+ import { AccountActivityCoordinator, subscriptionAccountIdentity } from "./activity.js";
9
+ import { hasUsableCredits, isOverSwitchThreshold, isPoolEligible, memberHeadroom, poolReadiness, quotaAdmissionReasons } from "./admission.js";
9
10
  import { subscriptionCredentialLabel } from "./credentials.js";
10
11
  import { canonicalRateLimitWindowKey } from "./provider.js";
11
- import { AccountActivityCoordinator, subscriptionAccountIdentity } from "./activity.js";
12
12
  const DEFAULT_SWITCH_THRESHOLD = 0.9;
13
13
  const DEFAULT_REFRESH_SKEW_SECONDS = 300;
14
14
  const DEFAULT_FALLBACK_COOLDOWN_SECONDS = 300;
@@ -103,11 +103,35 @@ function parsedMemberState(value, mode, migration) {
103
103
  const coolingUntil = typeof value.coolingUntil === "number" && Number.isFinite(value.coolingUntil)
104
104
  ? value.coolingUntil
105
105
  : undefined;
106
- if (limits === undefined && coolingUntil === undefined)
106
+ let cooldownRevision = typeof value.cooldownRevision === "number" &&
107
+ Number.isSafeInteger(value.cooldownRevision) &&
108
+ value.cooldownRevision >= 0
109
+ ? value.cooldownRevision
110
+ : 0;
111
+ if (coolingUntil !== undefined && cooldownRevision === 0) {
112
+ cooldownRevision = 1;
113
+ if (migration !== undefined)
114
+ migration.required = true;
115
+ }
116
+ let cooldownContext;
117
+ if (isRecord(value.cooldownContext)) {
118
+ const windows = Array.isArray(value.cooldownContext.windows)
119
+ ? value.cooldownContext.windows.filter((item) => typeof item === "string")
120
+ : undefined;
121
+ cooldownContext = {
122
+ ...(typeof value.cooldownContext.model === "string"
123
+ ? { model: value.cooldownContext.model }
124
+ : {}),
125
+ ...(windows !== undefined ? { windows } : {})
126
+ };
127
+ }
128
+ if (limits === undefined && coolingUntil === undefined && cooldownRevision === 0)
107
129
  return {};
108
130
  return {
109
131
  ...(limits !== undefined ? { limits } : {}),
110
- ...(coolingUntil !== undefined ? { coolingUntil } : {})
132
+ ...(coolingUntil !== undefined ? { coolingUntil } : {}),
133
+ ...(cooldownRevision > 0 ? { cooldownRevision } : {}),
134
+ ...(cooldownContext !== undefined ? { cooldownContext } : {})
111
135
  };
112
136
  }
113
137
  function readTrackerState(path, mode) {
@@ -146,9 +170,7 @@ function readTrackerState(path, mode) {
146
170
  }
147
171
  function mergeLimits(previous, next, mode) {
148
172
  const windows = Object.create(null);
149
- const sources = next.completeness === "snapshot"
150
- ? [next.windows]
151
- : [previous?.windows, next.windows];
173
+ const sources = next.completeness === "snapshot" ? [next.windows] : [previous?.windows, next.windows];
152
174
  for (const source of sources) {
153
175
  if (source === undefined)
154
176
  continue;
@@ -171,15 +193,64 @@ function mergeLimits(previous, next, mode) {
171
193
  source: next.source
172
194
  };
173
195
  }
196
+ /**
197
+ * Trackers for one state file share mutable state process-wide. A daemon
198
+ * reload runs the candidate router before the previous one drains, so
199
+ * independent maps would let a candidate probe replace the whole file and
200
+ * silently discard a cooldown the draining generation just wrote.
201
+ */
202
+ const sharedTrackerStates = new Map();
203
+ function readStateFileText(path) {
204
+ try {
205
+ return readFileSync(path, "utf8");
206
+ }
207
+ catch {
208
+ return undefined;
209
+ }
210
+ }
174
211
  export class RateLimitTracker {
175
212
  #statePath;
176
213
  #mode;
214
+ #shared;
177
215
  #state;
178
216
  constructor(statePath, mode) {
179
- this.#statePath = statePath;
217
+ this.#statePath = resolve(statePath);
180
218
  this.#mode = mode;
181
- const loaded = readTrackerState(statePath, mode);
219
+ const shared = sharedTrackerStates.get(this.#statePath);
220
+ if (shared !== undefined) {
221
+ if (shared.mode !== mode) {
222
+ throw new Error(`rate-limit tracker mode mismatch for ${this.#statePath}`);
223
+ }
224
+ this.#shared = shared;
225
+ this.#state = shared.members;
226
+ this.#adoptExternalState();
227
+ return;
228
+ }
229
+ const loaded = readTrackerState(this.#statePath, mode);
182
230
  this.#state = loaded.state;
231
+ this.#shared = {
232
+ mode,
233
+ members: this.#state,
234
+ lastPersisted: readStateFileText(this.#statePath)
235
+ };
236
+ sharedTrackerStates.set(this.#statePath, this.#shared);
237
+ if (loaded.migrated)
238
+ this.#persist();
239
+ }
240
+ /**
241
+ * Operators recover accounts by editing `.state.json` and reloading, so a
242
+ * new generation must honor a file this process did not write. Shared
243
+ * members are replaced in place to keep existing trackers consistent.
244
+ */
245
+ #adoptExternalState() {
246
+ const text = readStateFileText(this.#statePath);
247
+ if (text === this.#shared.lastPersisted)
248
+ return;
249
+ const loaded = readTrackerState(this.#statePath, this.#mode);
250
+ this.#state.clear();
251
+ for (const [id, member] of loaded.state)
252
+ this.#state.set(id, member);
253
+ this.#shared.lastPersisted = text;
183
254
  if (loaded.migrated)
184
255
  this.#persist();
185
256
  }
@@ -189,31 +260,70 @@ export class RateLimitTracker {
189
260
  coolingUntil(memberId) {
190
261
  return this.#state.get(memberId)?.coolingUntil;
191
262
  }
263
+ cooldownRevision(memberId) {
264
+ return this.#state.get(memberId)?.cooldownRevision ?? 0;
265
+ }
266
+ cooldownContext(memberId) {
267
+ return this.#state.get(memberId)?.cooldownContext;
268
+ }
192
269
  update(memberId, limits) {
193
270
  const member = this.#state.get(memberId) ?? {};
194
271
  member.limits = mergeLimits(member.limits, limits, this.#mode);
195
272
  this.#state.set(memberId, member);
196
273
  this.#persist();
197
274
  }
198
- cool(memberId, until) {
275
+ cool(memberId, until, context) {
199
276
  const member = this.#state.get(memberId) ?? {};
277
+ member.cooldownRevision = (member.cooldownRevision ?? 0) + 1;
200
278
  member.coolingUntil = until;
279
+ if (context === undefined)
280
+ delete member.cooldownContext;
281
+ else
282
+ member.cooldownContext = context;
201
283
  this.#state.set(memberId, member);
202
284
  this.#persist();
285
+ return member.cooldownRevision;
203
286
  }
204
- clearCooling(memberId) {
287
+ clearCooling(memberId, expectedRevision) {
205
288
  const member = this.#state.get(memberId);
206
- if (member === undefined || member.coolingUntil === undefined)
207
- return;
289
+ if (member === undefined ||
290
+ member.coolingUntil === undefined ||
291
+ (expectedRevision !== undefined && member.cooldownRevision !== expectedRevision))
292
+ return false;
293
+ member.cooldownRevision = (member.cooldownRevision ?? 0) + 1;
208
294
  delete member.coolingUntil;
295
+ delete member.cooldownContext;
209
296
  this.#persist();
297
+ return true;
210
298
  }
211
- resetAfterRefresh(memberId) {
299
+ reconcileSnapshot(memberId, limits, expectedCooldownRevision, recovered) {
300
+ const member = this.#state.get(memberId) ?? {};
301
+ member.limits = mergeLimits(member.limits, limits, this.#mode);
302
+ const cleared = recovered &&
303
+ limits.completeness === "snapshot" &&
304
+ member.coolingUntil !== undefined &&
305
+ (member.cooldownRevision ?? 0) === expectedCooldownRevision;
306
+ if (cleared) {
307
+ member.cooldownRevision = (member.cooldownRevision ?? 0) + 1;
308
+ delete member.coolingUntil;
309
+ delete member.cooldownContext;
310
+ }
311
+ this.#state.set(memberId, member);
312
+ this.#persist();
313
+ return cleared;
314
+ }
315
+ resetAfterRefresh(memberId, expectedCooldownRevision) {
212
316
  const member = this.#state.get(memberId) ?? {};
213
317
  delete member.limits;
214
- delete member.coolingUntil;
318
+ const cleared = (member.cooldownRevision ?? 0) === expectedCooldownRevision;
319
+ if (cleared) {
320
+ member.cooldownRevision = (member.cooldownRevision ?? 0) + 1;
321
+ delete member.coolingUntil;
322
+ delete member.cooldownContext;
323
+ }
215
324
  this.#state.set(memberId, member);
216
325
  this.#persist();
326
+ return cleared;
217
327
  }
218
328
  renameMember(sourceId, targetId) {
219
329
  const source = this.#state.get(sourceId);
@@ -228,7 +338,9 @@ export class RateLimitTracker {
228
338
  const file = {
229
339
  members: [...this.#state].map(([id, member]) => ({ id, ...member }))
230
340
  };
231
- writeFileAtomic(this.#statePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
341
+ const text = `${JSON.stringify(file, null, 2)}\n`;
342
+ writeFileAtomic(this.#statePath, text, { mode: 0o600 });
343
+ this.#shared.lastPersisted = text;
232
344
  }
233
345
  }
234
346
  export class SubscriptionAccountSetExhaustedError extends ProviderFailureError {
@@ -295,6 +407,7 @@ export class SubscriptionAccountSet {
295
407
  ...(tracker.coolingUntil(id) !== undefined
296
408
  ? { coolingUntil: tracker.coolingUntil(id) }
297
409
  : {}),
410
+ cooldownRevision: tracker.cooldownRevision(id),
298
411
  lastUsed: 0,
299
412
  inFlight: 0,
300
413
  switchedAt: 0
@@ -334,14 +447,30 @@ export class SubscriptionAccountSet {
334
447
  (member.credential.expiresAt === undefined ||
335
448
  member.credential.expiresAt > Date.now() / 1000 ||
336
449
  (member.credential.refreshToken?.length ?? 0) > 0);
337
- const poolEligible = this.#eligible(member, undefined, now);
450
+ const readiness = poolReadiness({
451
+ limits: this.#tracker.limits(member.id),
452
+ switchThreshold: this.#options.switchThreshold,
453
+ ...(member.coolingUntil !== undefined ? { coolingUntil: member.coolingUntil } : {}),
454
+ ...(member.credential.expiresAt !== undefined
455
+ ? { credentialExpiresAt: member.credential.expiresAt }
456
+ : {}),
457
+ hasRefreshToken: member.credential.refreshToken !== undefined,
458
+ catalogReady: this.#catalogReady,
459
+ models: [...member.models],
460
+ now,
461
+ isWindowRelevant: (key, limitName) => this.#windowRelevant(key, limitName, undefined)
462
+ });
463
+ const readinessReasons = credentialValid
464
+ ? readiness.reasons
465
+ : member.credential.expiresAt !== undefined && member.credential.expiresAt <= now
466
+ ? readiness.reasons
467
+ : [{ code: "credential_invalid" }, ...readiness.reasons];
338
468
  return {
339
469
  ...status,
340
470
  credentialValid,
341
- poolEligible,
342
- relayReady: credentialValid &&
343
- poolEligible &&
344
- (member.coolingUntil === undefined || member.coolingUntil <= now)
471
+ poolEligible: readiness.eligible,
472
+ relayReady: credentialValid && readiness.eligible,
473
+ readinessReasons
345
474
  };
346
475
  })
347
476
  };
@@ -405,9 +534,14 @@ export class SubscriptionAccountSet {
405
534
  async probe(signal) {
406
535
  await Promise.allSettled(this.#members.map(async (member) => {
407
536
  await this.#ensureFresh(member, signal);
537
+ const cooldownRevision = member.cooldownRevision;
538
+ const cooldownContext = this.#tracker.cooldownContext(member.id);
408
539
  const limits = await this.#provider.fetchUsage(member.credential, signal);
409
540
  const withResets = await this.#attachResetCredits(member, limits, signal);
410
- this.#tracker.update(member.id, withResets);
541
+ const recovered = this.#snapshotRecovered(withResets, cooldownContext);
542
+ this.#tracker.reconcileSnapshot(member.id, withResets, cooldownRevision, recovered);
543
+ member.coolingUntil = this.#tracker.coolingUntil(member.id);
544
+ member.cooldownRevision = this.#tracker.cooldownRevision(member.id);
411
545
  }));
412
546
  }
413
547
  /**
@@ -429,6 +563,7 @@ export class SubscriptionAccountSet {
429
563
  }
430
564
  const member = this.#requireMember(input.label);
431
565
  await this.#ensureFresh(member, signal);
566
+ const expectedCooldownRevision = this.#tracker.cooldownRevision(member.id);
432
567
  const redeemRequestId = input.redeemRequestId !== undefined && input.redeemRequestId.trim().length > 0
433
568
  ? input.redeemRequestId.trim()
434
569
  : randomUUID();
@@ -471,8 +606,13 @@ export class SubscriptionAccountSet {
471
606
  catch {
472
607
  // Consume already succeeded; clear cooling even if refresh fails.
473
608
  }
474
- delete member.coolingUntil;
475
- this.#tracker.clearCooling(member.id);
609
+ if (this.#tracker.clearCooling(member.id, expectedCooldownRevision)) {
610
+ delete member.coolingUntil;
611
+ }
612
+ else {
613
+ member.coolingUntil = this.#tracker.coolingUntil(member.id);
614
+ }
615
+ member.cooldownRevision = this.#tracker.cooldownRevision(member.id);
476
616
  }
477
617
  return {
478
618
  ...result,
@@ -494,15 +634,13 @@ export class SubscriptionAccountSet {
494
634
  const now = Date.now();
495
635
  const allFresh = this.#members.every((member) => {
496
636
  const limits = this.#tracker.limits(member.id);
497
- return (limits?.completeness === "snapshot" &&
498
- now - limits.observedAt * 1000 < maxAgeMs);
637
+ return limits?.completeness === "snapshot" && now - limits.observedAt * 1000 < maxAgeMs;
499
638
  });
500
639
  if (allFresh)
501
640
  return;
502
641
  if (this.#usageProbe !== undefined)
503
642
  return await this.#usageProbe;
504
- if (this.#lastUsageProbeAt !== undefined &&
505
- now - this.#lastUsageProbeAt < maxAgeMs) {
643
+ if (this.#lastUsageProbeAt !== undefined && now - this.#lastUsageProbeAt < maxAgeMs) {
506
644
  return;
507
645
  }
508
646
  this.#lastUsageProbeAt = now;
@@ -594,9 +732,8 @@ export class SubscriptionAccountSet {
594
732
  // Only an actual spent quota window rotates accounts. Authentication,
595
733
  // context, and unknown failures were already returned above.
596
734
  const until = failure.resetsAt ??
597
- Date.now() / 1000 +
598
- (failure.retryAfter ?? this.#options.fallbackCooldownSeconds);
599
- this.#penalize(member, until);
735
+ Date.now() / 1000 + (failure.retryAfter ?? this.#options.fallbackCooldownSeconds);
736
+ this.#penalize(member, until, model);
600
737
  excluded.add(member.id);
601
738
  }
602
739
  finally {
@@ -662,8 +799,14 @@ export class SubscriptionAccountSet {
662
799
  const now = Date.now() / 1000;
663
800
  for (const member of this.#members) {
664
801
  if (member.coolingUntil !== undefined && member.coolingUntil <= now) {
665
- delete member.coolingUntil;
666
- this.#tracker.clearCooling(member.id);
802
+ if (this.#tracker.clearCooling(member.id, member.cooldownRevision)) {
803
+ delete member.coolingUntil;
804
+ member.cooldownRevision = this.#tracker.cooldownRevision(member.id);
805
+ }
806
+ else {
807
+ member.coolingUntil = this.#tracker.coolingUntil(member.id);
808
+ member.cooldownRevision = this.#tracker.cooldownRevision(member.id);
809
+ }
667
810
  }
668
811
  }
669
812
  const eligible = this.#members.filter((member) => !excluded.has(member.id) && this.#eligible(member, model, now));
@@ -768,12 +911,38 @@ export class SubscriptionAccountSet {
768
911
  }
769
912
  return resets.length > 0 ? Math.min(...resets) : undefined;
770
913
  }
771
- #penalize(member, until) {
914
+ #penalize(member, until, model) {
772
915
  member.coolingUntil = until;
773
- this.#tracker.cool(member.id, until);
916
+ const limits = this.#tracker.limits(member.id);
917
+ const windows = limits === undefined
918
+ ? undefined
919
+ : Object.entries(limits.windows)
920
+ .filter(([key, window]) => this.#windowRelevant(key, window.limitName, model))
921
+ .map(([key]) => key);
922
+ member.cooldownRevision = this.#tracker.cool(member.id, until, {
923
+ ...(model !== undefined ? { model } : {}),
924
+ ...(windows !== undefined && windows.length > 0 ? { windows } : {})
925
+ });
774
926
  if (this.#activeId === member.id)
775
927
  this.#activeId = undefined;
776
928
  }
929
+ #snapshotRecovered(limits, context) {
930
+ if (limits.completeness !== "snapshot")
931
+ return false;
932
+ const relevant = (key, limitName) => {
933
+ if (context?.windows !== undefined && context.windows.length > 0) {
934
+ return context.windows.includes(key);
935
+ }
936
+ if (context?.model === undefined)
937
+ return true;
938
+ return this.#windowRelevant(key, limitName, context.model);
939
+ };
940
+ return (quotaAdmissionReasons({
941
+ limits,
942
+ switchThreshold: this.#options.switchThreshold,
943
+ isWindowRelevant: relevant
944
+ }).length === 0);
945
+ }
777
946
  /**
778
947
  * A member keeps its previous catalog when discovery fails, because an empty
779
948
  * model set makes it pool-ineligible: a provider blip would otherwise take
@@ -813,10 +982,16 @@ export class SubscriptionAccountSet {
813
982
  return false;
814
983
  }
815
984
  member.forcedRefreshAt = now;
985
+ const expectedCooldownRevision = this.#tracker.cooldownRevision(member.id);
816
986
  const refresh = (async () => {
817
987
  member.credential = await this.#provider.refresh(member.credential, signal);
818
- this.#tracker.resetAfterRefresh(member.id);
819
- delete member.coolingUntil;
988
+ if (this.#tracker.resetAfterRefresh(member.id, expectedCooldownRevision)) {
989
+ delete member.coolingUntil;
990
+ }
991
+ else {
992
+ member.coolingUntil = this.#tracker.coolingUntil(member.id);
993
+ }
994
+ member.cooldownRevision = this.#tracker.cooldownRevision(member.id);
820
995
  })().finally(() => this.#refreshes.delete(member.id));
821
996
  this.#refreshes.set(member.id, refresh);
822
997
  try {
@@ -836,10 +1011,16 @@ export class SubscriptionAccountSet {
836
1011
  const existing = this.#refreshes.get(member.id);
837
1012
  if (existing !== undefined)
838
1013
  return existing;
1014
+ const expectedCooldownRevision = this.#tracker.cooldownRevision(member.id);
839
1015
  const refresh = (async () => {
840
1016
  member.credential = await this.#provider.refresh(member.credential, signal);
841
- this.#tracker.resetAfterRefresh(member.id);
842
- delete member.coolingUntil;
1017
+ if (this.#tracker.resetAfterRefresh(member.id, expectedCooldownRevision)) {
1018
+ delete member.coolingUntil;
1019
+ }
1020
+ else {
1021
+ member.coolingUntil = this.#tracker.coolingUntil(member.id);
1022
+ }
1023
+ member.cooldownRevision = this.#tracker.cooldownRevision(member.id);
843
1024
  })().finally(() => this.#refreshes.delete(member.id));
844
1025
  this.#refreshes.set(member.id, refresh);
845
1026
  return refresh;
@@ -866,7 +1047,8 @@ export class SubscriptionAccountSet {
866
1047
  }
867
1048
  }
868
1049
  #observeStream(member, response) {
869
- if (response.body === null || !response.headers.get("content-type")?.includes("text/event-stream")) {
1050
+ if (response.body === null ||
1051
+ !response.headers.get("content-type")?.includes("text/event-stream")) {
870
1052
  return response;
871
1053
  }
872
1054
  const decoder = new SseDecoder();
@@ -1,9 +1,21 @@
1
+ import type { AccountReadinessReason } from "@velum-labs/routekit-contracts";
1
2
  import type { AccountLimits, CreditSnapshot } from "./types.js";
3
+ /** @deprecated Use AccountReadinessReason from routekit-contracts. */
4
+ export type AdmissionReason = AccountReadinessReason;
5
+ export type PoolReadiness = {
6
+ eligible: boolean;
7
+ reasons: AdmissionReason[];
8
+ };
2
9
  export declare function hasUsableCredits(credits: CreditSnapshot | undefined): boolean;
3
10
  export declare function windowHeadroom(utilization: number): number;
4
11
  export declare function memberHeadroom(limits: AccountLimits | undefined, isWindowRelevant?: (key: string, limitName?: string) => boolean): number;
5
12
  export declare function isOverSwitchThreshold(headroom: number, switchThreshold: number): boolean;
6
- export declare function isPoolEligible(input: {
13
+ export declare function quotaAdmissionReasons(input: {
14
+ limits?: AccountLimits;
15
+ switchThreshold: number;
16
+ isWindowRelevant?: (key: string, limitName?: string) => boolean;
17
+ }): AdmissionReason[];
18
+ export declare function poolReadiness(input: {
7
19
  limits?: AccountLimits;
8
20
  switchThreshold: number;
9
21
  coolingUntil?: number;
@@ -14,5 +26,6 @@ export declare function isPoolEligible(input: {
14
26
  model?: string;
15
27
  now?: number;
16
28
  isWindowRelevant?: (key: string, limitName?: string) => boolean;
17
- }): boolean;
29
+ }): PoolReadiness;
30
+ export declare function isPoolEligible(input: Parameters<typeof poolReadiness>[0]): boolean;
18
31
  export declare function windowAdmissionStatus(utilization: number, switchThreshold: number, credits: CreditSnapshot | undefined, providerStatus?: string): string;
package/dist/admission.js CHANGED
@@ -21,29 +21,78 @@ export function memberHeadroom(limits, isWindowRelevant = () => true) {
21
21
  export function isOverSwitchThreshold(headroom, switchThreshold) {
22
22
  return headroom <= 1 - switchThreshold;
23
23
  }
24
- export function isPoolEligible(input) {
24
+ function blockingProviderReason(window, status) {
25
+ const normalized = status?.trim().toLowerCase();
26
+ if (normalized === "rejected") {
27
+ return { code: "provider_quota_rejected", window, status: status };
28
+ }
29
+ if (normalized === "exceeded") {
30
+ return { code: "provider_quota_exceeded", window, status: status };
31
+ }
32
+ return undefined;
33
+ }
34
+ export function quotaAdmissionReasons(input) {
35
+ if (input.limits === undefined)
36
+ return [];
37
+ const relevant = input.isWindowRelevant ?? (() => true);
38
+ const creditsUsable = hasUsableCredits(input.limits.credits);
39
+ const reasons = [];
40
+ for (const [key, window] of Object.entries(input.limits.windows)) {
41
+ if (!relevant(key, window.limitName))
42
+ continue;
43
+ const providerReason = blockingProviderReason(key, window.status);
44
+ if (providerReason !== undefined) {
45
+ reasons.push(providerReason);
46
+ continue;
47
+ }
48
+ if (!creditsUsable &&
49
+ isOverSwitchThreshold(windowHeadroom(window.utilization), input.switchThreshold)) {
50
+ reasons.push({
51
+ code: "quota_switch_threshold",
52
+ window: key,
53
+ utilization: window.utilization,
54
+ switchThreshold: input.switchThreshold
55
+ });
56
+ }
57
+ }
58
+ return reasons;
59
+ }
60
+ export function poolReadiness(input) {
25
61
  const now = input.now ?? Date.now() / 1000;
26
- if (input.catalogReady === true && (input.models?.length ?? 0) === 0)
27
- return false;
62
+ const reasons = [];
63
+ if (input.catalogReady === true && (input.models?.length ?? 0) === 0) {
64
+ reasons.push({ code: "catalog_empty" });
65
+ }
28
66
  if (input.catalogReady === true &&
29
67
  input.model !== undefined &&
30
68
  input.models !== undefined &&
31
69
  !input.models.includes(input.model)) {
32
- return false;
70
+ reasons.push({ code: "model_unavailable", model: input.model });
71
+ }
72
+ if (input.coolingUntil !== undefined && input.coolingUntil > now) {
73
+ reasons.push({ code: "cooldown_active", until: input.coolingUntil });
33
74
  }
34
- if (input.coolingUntil !== undefined && input.coolingUntil > now)
35
- return false;
36
75
  if (input.credentialExpiresAt !== undefined &&
37
76
  input.credentialExpiresAt <= now &&
38
77
  input.hasRefreshToken !== true) {
39
- return false;
78
+ reasons.push({ code: "credential_expired", expiresAt: input.credentialExpiresAt });
40
79
  }
41
- const headroom = memberHeadroom(input.limits, input.isWindowRelevant);
42
- if (!isOverSwitchThreshold(headroom, input.switchThreshold))
43
- return true;
44
- return hasUsableCredits(input.limits?.credits);
80
+ reasons.push(...quotaAdmissionReasons({
81
+ ...(input.limits !== undefined ? { limits: input.limits } : {}),
82
+ switchThreshold: input.switchThreshold,
83
+ ...(input.isWindowRelevant !== undefined ? { isWindowRelevant: input.isWindowRelevant } : {})
84
+ }));
85
+ return { eligible: reasons.length === 0, reasons };
86
+ }
87
+ export function isPoolEligible(input) {
88
+ return poolReadiness(input).eligible;
45
89
  }
46
90
  export function windowAdmissionStatus(utilization, switchThreshold, credits, providerStatus) {
91
+ const providerReason = blockingProviderReason("window", providerStatus);
92
+ if (providerReason?.code === "provider_quota_rejected")
93
+ return "rejected";
94
+ if (providerReason?.code === "provider_quota_exceeded")
95
+ return "exceeded";
47
96
  if (!isOverSwitchThreshold(windowHeadroom(utilization), switchThreshold)) {
48
97
  return providerStatus ?? "ok";
49
98
  }