@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.
- package/dist/account-set.d.ts +13 -5
- package/dist/account-set.js +225 -43
- package/dist/admission.d.ts +15 -2
- package/dist/admission.js +60 -11
- package/dist/index.d.ts +27 -26
- package/dist/index.js +16 -16
- package/dist/test/admission.test.js +48 -2
- package/dist/test/subscription-pool.test.js +318 -21
- package/dist/test/subscription-sdk.test.js +23 -4
- package/dist/types.d.ts +1 -1
- package/dist/wire.d.ts +27 -0
- package/dist/wire.js +24 -0
- package/package.json +5 -5
package/dist/account-set.d.ts
CHANGED
|
@@ -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):
|
|
39
|
-
clearCooling(memberId: string):
|
|
40
|
-
|
|
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 {};
|
package/dist/account-set.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
1
|
import { createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
|
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 ||
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
475
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
666
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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();
|
package/dist/admission.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
}):
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
reasons.push({ code: "credential_expired", expiresAt: input.credentialExpiresAt });
|
|
40
79
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|