@goodandready/dsh-key-rotation 0.8.29 → 0.8.30
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 +28 -0
- package/README.md +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/budget-monitor.js +33 -29
- package/lib/client.js +283 -1
- package/lib/http-bridge.js +69 -6
- package/lib/index.js +31 -139
- package/lib/model-quota.js +298 -0
- package/lib/ops-keys.js +4 -1
- package/lib/ops-status.js +21 -5
- package/lib/persistence.js +25 -1
- package/lib/pool-builder.js +96 -6
- package/lib/pool-index.js +143 -0
- package/lib/pool-state.js +1 -0
- package/lib/pool.js +25 -5
- package/lib/resolver.js +159 -0
- package/lib/rotate.js +85 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createResolver } from './resolver.js';
|
|
1
2
|
import { decryptSecret } from './crypto-storage.js';
|
|
2
3
|
// dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
|
|
3
4
|
//
|
|
@@ -20,7 +21,6 @@ import { LatencyHistogram } from './histogram.js';
|
|
|
20
21
|
import { ConcurrencyTracker } from './concurrency.js';
|
|
21
22
|
import { QuotaStore } from './quota.js';
|
|
22
23
|
import { WebhookSender } from './webhook.js';
|
|
23
|
-
import { bucketAllow, bucketRetryMs } from './bucket.js';
|
|
24
24
|
import { compactUsage } from './usage-report.js';
|
|
25
25
|
import { json, handleConfigBridge } from './http-bridge.js';
|
|
26
26
|
import { createRotate } from './rotate.js';
|
|
@@ -35,7 +35,15 @@ import { notifySwitch, notifyExhaustion, pushEvent } from './notify-events.js';
|
|
|
35
35
|
import { getLogger } from './logger.js';
|
|
36
36
|
import { createSandboxService } from './sandbox-service.js';
|
|
37
37
|
import { checkBudgetAndHealthAlerts } from './budget-monitor.js';
|
|
38
|
-
import {
|
|
38
|
+
import { cleanupRemovedProviders, buildPools } from './pool-builder.js';
|
|
39
|
+
import { resolveRefPool, isQuotaManagedRequest } from './pool-index.js';
|
|
40
|
+
import {
|
|
41
|
+
isModelQuotaAvailable,
|
|
42
|
+
hasModelQuotaConfig,
|
|
43
|
+
anyModelQuotaAvailable,
|
|
44
|
+
formatModelQuotaExhaustion,
|
|
45
|
+
} from './model-quota.js';
|
|
46
|
+
import { LOCAL_QUOTA_CODE } from './rotate.js';
|
|
39
47
|
import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence } from './lifecycle.js';
|
|
40
48
|
|
|
41
49
|
export const name = '@goodandready/dsh-key-rotation';
|
|
@@ -126,6 +134,9 @@ const SettingsSchema = Schema.object({
|
|
|
126
134
|
models: Schema.dict(Schema.object({
|
|
127
135
|
keys: Schema.array(Schema.string()).default([]),
|
|
128
136
|
weights: Schema.array(Schema.number()).default([]),
|
|
137
|
+
quotas: Schema.dict(Schema.object({
|
|
138
|
+
tokenLimit: Schema.number(),
|
|
139
|
+
})).default({}),
|
|
129
140
|
})).default({}),
|
|
130
141
|
cooldownMs: Schema.number(),
|
|
131
142
|
maxCooldownMs: Schema.number(),
|
|
@@ -195,9 +206,9 @@ export function apply(ctx, config = {}) {
|
|
|
195
206
|
}
|
|
196
207
|
const runtime = buildRuntime();
|
|
197
208
|
const allActiveRefs = Array.from(runtime.poolByRef.keys());
|
|
198
|
-
const n = sweepExpired(poolState, now, allActiveRefs);
|
|
209
|
+
const n = sweepExpired(poolState, now, allActiveRefs, runtime.quotaResetWindow);
|
|
199
210
|
if (n > 0) logger.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
200
|
-
for (const pool of runtime.poolByRef.values()) {
|
|
211
|
+
for (const pool of (runtime.pools ?? runtime.poolByRef.values())) {
|
|
201
212
|
compactUsage(pool, 30, now);
|
|
202
213
|
}
|
|
203
214
|
checkBudgetAndHealthAlerts({
|
|
@@ -252,42 +263,9 @@ export function apply(ctx, config = {}) {
|
|
|
252
263
|
const rpmLimit = cfg.rpmLimit ?? 0;
|
|
253
264
|
const webhookActionToken = cfg.webhookActionToken ?? '';
|
|
254
265
|
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const cloneIds = new Set();
|
|
259
|
-
|
|
260
|
-
const makeState = (base) => {
|
|
261
|
-
let st = poolState.get(base);
|
|
262
|
-
if (!st) {
|
|
263
|
-
st = initializePoolState();
|
|
264
|
-
poolState.set(base, st);
|
|
265
|
-
}
|
|
266
|
-
return initializePoolState(st);
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
const buildPool = (base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, revoked) =>
|
|
270
|
-
buildPoolItem({ base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
|
|
271
|
-
|
|
272
|
-
for (const p of cfg.providers ?? []) {
|
|
273
|
-
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
274
|
-
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
275
|
-
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, p.revoked);
|
|
276
|
-
if (pool) {
|
|
277
|
-
for (const ref of pool.refs) poolByRef.set(ref, pool);
|
|
278
|
-
for (let i = 1; i < pool.refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
279
|
-
}
|
|
280
|
-
const models = p.models ?? {};
|
|
281
|
-
const byModel = new Map();
|
|
282
|
-
for (const [model, mp] of Object.entries(models)) {
|
|
283
|
-
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard, mp.revoked);
|
|
284
|
-
if (mpool) {
|
|
285
|
-
byModel.set(model, mpool);
|
|
286
|
-
for (const ref of mpool.refs) poolByRef.set(ref, mpool);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
if (byModel.size > 0) modelPoolByProvider.set(p.provider, byModel);
|
|
290
|
-
}
|
|
266
|
+
const built = buildPools({ cfg, poolState, defaultState: initializePoolState });
|
|
267
|
+
const { index, pools, poolByRef, modelPoolByProvider, cloneIds } = built;
|
|
268
|
+
const providerToPool = new Map(built.providerToPool);
|
|
291
269
|
|
|
292
270
|
let profiles = {};
|
|
293
271
|
try {
|
|
@@ -318,6 +296,7 @@ export function apply(ctx, config = {}) {
|
|
|
318
296
|
cfg,
|
|
319
297
|
poolState,
|
|
320
298
|
poolByRef,
|
|
299
|
+
pools,
|
|
321
300
|
providerToPool,
|
|
322
301
|
expectedClones,
|
|
323
302
|
moduleBreaker,
|
|
@@ -335,13 +314,13 @@ export function apply(ctx, config = {}) {
|
|
|
335
314
|
routingStrategy: cfg.routingStrategy ?? 'round-robin',
|
|
336
315
|
switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
|
|
337
316
|
warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
|
|
317
|
+
index, pools,
|
|
338
318
|
providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
|
|
339
319
|
cloneIds, expectedClones,
|
|
340
320
|
circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
|
|
341
321
|
circuitBreakerThreshold: cfg.circuitBreakerThreshold ?? 5,
|
|
342
322
|
circuitBreakerOpenMs: cfg.circuitBreakerOpenMs ?? 30000,
|
|
343
323
|
circuitBreakerHalfOpenProbes: cfg.circuitBreakerHalfOpenProbes ?? 1,
|
|
344
|
-
pools: [...new Set(poolByRef.values())],
|
|
345
324
|
};
|
|
346
325
|
return cachedRuntime;
|
|
347
326
|
}
|
|
@@ -353,104 +332,17 @@ export function apply(ctx, config = {}) {
|
|
|
353
332
|
if (credentials && typeof credentials.resolve === 'function') {
|
|
354
333
|
const original = credentials.resolve.bind(credentials);
|
|
355
334
|
credentials.__dshKeyRotationOriginalResolve = original;
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
const now = Date.now();
|
|
369
|
-
const strat = pool.routingStrategy ?? buildRuntime().routingStrategy ?? 'round-robin';
|
|
370
|
-
let list = pool.weightedRefs ?? pool.refs;
|
|
371
|
-
if (strat === 'lowest-latency' || strat === 'least-loaded') {
|
|
372
|
-
list = sortAttemptList(list, strat, { latencyHistogram, concurrencyTracker });
|
|
373
|
-
}
|
|
374
|
-
const start = (strat === 'round-robin') ? (pool.state.pointer ?? 0) : 0;
|
|
375
|
-
for (let i = 0; i < list.length; i++) {
|
|
376
|
-
const index = (start + i) % list.length;
|
|
377
|
-
const candidate = list[index];
|
|
378
|
-
if (isKeyRevoked(pool, candidate)) continue;
|
|
379
|
-
if (isKeyPaused(pool, candidate)) continue;
|
|
380
|
-
const until = pool.state.failedUntil.get(candidate);
|
|
381
|
-
if (until !== undefined && until > now) continue;
|
|
382
|
-
if (pool.expiresAt?.[candidate] !== undefined && now >= pool.expiresAt[candidate]) continue;
|
|
383
|
-
const rpmLimit = pool.rpmLimit ?? 0;
|
|
384
|
-
if (rpmLimit > 0) {
|
|
385
|
-
if (!pool.state.rpmWindows) pool.state.rpmWindows = new Map();
|
|
386
|
-
if (!bucketAllow(pool.state.rpmWindows, candidate, rpmLimit, now)) {
|
|
387
|
-
const waitMs = bucketRetryMs(pool.state.rpmWindows, candidate, rpmLimit, now);
|
|
388
|
-
if ((pool.state.failedUntil.get(candidate) ?? 0) < now + waitMs) {
|
|
389
|
-
pool.state.failedUntil.set(candidate, now + waitMs);
|
|
390
|
-
}
|
|
391
|
-
continue;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
if (pool.perHour) {
|
|
395
|
-
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
396
|
-
let win = pool.state.quotaWindows.get(candidate);
|
|
397
|
-
if (!win || now - win.start >= 3600000) win = { count: 0, start: now };
|
|
398
|
-
if (win.count >= pool.perHour) {
|
|
399
|
-
const until = win.start + 3600000;
|
|
400
|
-
if ((pool.state.failedUntil.get(candidate) ?? 0) < until) pool.state.failedUntil.set(candidate, until);
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
pool.state.pointer = (index + 1) % list.length;
|
|
405
|
-
let hit = await original(candidate);
|
|
406
|
-
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
407
|
-
hit = { ...hit, value: decryptSecret(hit.value) };
|
|
408
|
-
pool.state.lastUsed = candidate;
|
|
409
|
-
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
410
|
-
pool.state.lastUsedAt.set(candidate, now);
|
|
411
|
-
const store = dispatchStorage.getStore();
|
|
412
|
-
if (store && store.pool === pool) store.pickedRef = candidate;
|
|
413
|
-
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
414
|
-
pool.state.failedUntil.delete(candidate);
|
|
415
|
-
if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
|
|
416
|
-
if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
|
|
417
|
-
if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
|
|
418
|
-
pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
|
|
419
|
-
if (pool.perHour) {
|
|
420
|
-
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
421
|
-
let win2 = pool.state.quotaWindows.get(candidate);
|
|
422
|
-
if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
|
|
423
|
-
win2.count++;
|
|
424
|
-
pool.state.quotaWindows.set(candidate, win2);
|
|
425
|
-
}
|
|
426
|
-
return hit;
|
|
427
|
-
}
|
|
428
|
-
const envVal = envValue(candidate);
|
|
429
|
-
if (envVal !== undefined) {
|
|
430
|
-
const decEnv = decryptSecret(envVal);
|
|
431
|
-
pool.state.lastUsed = candidate;
|
|
432
|
-
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
433
|
-
pool.state.lastUsedAt.set(candidate, now);
|
|
434
|
-
const store = dispatchStorage.getStore();
|
|
435
|
-
if (store && store.pool === pool) store.pickedRef = candidate;
|
|
436
|
-
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
437
|
-
pool.state.failedUntil.delete(candidate);
|
|
438
|
-
if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
|
|
439
|
-
if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
|
|
440
|
-
if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
|
|
441
|
-
pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
|
|
442
|
-
if (pool.perHour) {
|
|
443
|
-
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
444
|
-
let win2 = pool.state.quotaWindows.get(candidate);
|
|
445
|
-
if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
|
|
446
|
-
win2.count++;
|
|
447
|
-
pool.state.quotaWindows.set(candidate, win2);
|
|
448
|
-
}
|
|
449
|
-
return { value: envVal, source: 'env' };
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
return original(ref);
|
|
453
|
-
};
|
|
335
|
+
const resolve = createResolver({
|
|
336
|
+
buildRuntime,
|
|
337
|
+
currentPool: () => dispatchStorage.getStore()?.pool,
|
|
338
|
+
onPicked: (pool, candidate) => {
|
|
339
|
+
const store = dispatchStorage.getStore();
|
|
340
|
+
if (store && store.pool === pool) store.pickedRef = candidate;
|
|
341
|
+
},
|
|
342
|
+
latencyHistogram,
|
|
343
|
+
concurrencyTracker,
|
|
344
|
+
});
|
|
345
|
+
credentials.resolve = async (ref) => resolve(ref, original);
|
|
454
346
|
credentials.__dshKeyRotationPatched = true;
|
|
455
347
|
return () => {
|
|
456
348
|
if (credentials.__dshKeyRotationPatched) {
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// lib/model-quota.js — locally configured per-(provider × model × credential)
|
|
2
|
+
// token budgets: Provider × Credential Key × Model × Token Quota.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately separate from lib/quota.js (QuotaStore), which mirrors
|
|
5
|
+
// *provider-reported* rate-limit headers (x-ratelimit-remaining, retry-after).
|
|
6
|
+
// A model token quota is a local budget: "this credential may spend at most N
|
|
7
|
+
// tokens on this model pool within the current reset window".
|
|
8
|
+
//
|
|
9
|
+
// State lives inside the model pool itself (`pool.state.tokenUsage`), and a pool
|
|
10
|
+
// is keyed by `provider::model`, so Key × Model isolation is structural: the
|
|
11
|
+
// Sonnet pool and the Opus pool never read each other's counters. No composite
|
|
12
|
+
// `KEY::model` global key is needed or used.
|
|
13
|
+
//
|
|
14
|
+
// Everything here is a pure function over pool state: no network, no disk, no
|
|
15
|
+
// timers, no implicit clock (callers pass `now`), so `node --test` can exercise
|
|
16
|
+
// it directly. Reads never mutate; only `consumeModelTokens()` /
|
|
17
|
+
// `resetModelQuotaIfNeeded()` write, and both normalise the entry first.
|
|
18
|
+
//
|
|
19
|
+
// Timekeeping: `resetAt` is a wall-clock epoch timestamp computed by
|
|
20
|
+
// nextQuotaReset() from lib/quota-window.js. Calendar maths must never use the
|
|
21
|
+
// monotonic process clock — that is for cooldowns and durations only.
|
|
22
|
+
|
|
23
|
+
import { nextQuotaReset } from './quota-window.js';
|
|
24
|
+
|
|
25
|
+
/** Keys that must never be used as a credential ref (prototype pollution guard). */
|
|
26
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
27
|
+
|
|
28
|
+
/** Used when quotaResetWindow is missing or unknown: a plain 24h budget window. */
|
|
29
|
+
export const FALLBACK_WINDOW_MS = 86400000;
|
|
30
|
+
|
|
31
|
+
/** Failure-free token count: a non-negative finite integer, else null. */
|
|
32
|
+
function toTokenCount(value) {
|
|
33
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null;
|
|
34
|
+
return Math.floor(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A usable token limit: a finite number greater than zero.
|
|
39
|
+
* Numeric strings are accepted because a legacy host can hand the raw YAML
|
|
40
|
+
* section to apply() without running it through Schemastery first.
|
|
41
|
+
* Missing, null, non-numeric and non-positive values all mean "no local limit".
|
|
42
|
+
*/
|
|
43
|
+
function toLimit(value) {
|
|
44
|
+
let n = value;
|
|
45
|
+
if (typeof n === 'string') {
|
|
46
|
+
if (!n.trim()) return null;
|
|
47
|
+
n = Number(n);
|
|
48
|
+
}
|
|
49
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return null;
|
|
50
|
+
return Math.floor(n);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Normalize a `models.<model>.quotas` config object into a frozen lookup.
|
|
55
|
+
*
|
|
56
|
+
* Accepts the documented `{ REF: { tokenLimit: N } }` and the shorthand
|
|
57
|
+
* `{ REF: N }`. Entries without a positive finite limit are dropped, which is
|
|
58
|
+
* what keeps "no quota configured" behaviourally identical to the pre-quota
|
|
59
|
+
* plugin — a missing quota is never read as a zero budget.
|
|
60
|
+
* A null-prototype object keeps a ref literally named `__proto__` harmless.
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeModelQuotas(quotas) {
|
|
63
|
+
const out = Object.create(null);
|
|
64
|
+
if (!quotas || typeof quotas !== 'object') return Object.freeze(out);
|
|
65
|
+
for (const [ref, value] of Object.entries(quotas)) {
|
|
66
|
+
if (typeof ref !== 'string' || ref.length === 0 || UNSAFE_KEYS.has(ref)) continue;
|
|
67
|
+
const raw = value && typeof value === 'object' ? value.tokenLimit : value;
|
|
68
|
+
const limit = toLimit(raw);
|
|
69
|
+
if (limit === null) continue;
|
|
70
|
+
out[ref] = Object.freeze({ tokenLimit: limit });
|
|
71
|
+
}
|
|
72
|
+
return Object.freeze(out);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when this pool carries at least one usable local token quota. */
|
|
76
|
+
export function hasModelQuotaConfig(pool) {
|
|
77
|
+
if (!pool) return false;
|
|
78
|
+
if (typeof pool.hasModelQuota === 'boolean') return pool.hasModelQuota;
|
|
79
|
+
const q = pool.quotas;
|
|
80
|
+
return Boolean(q && Object.keys(q).length > 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Configured token limit for `ref` in `pool`, or null when unlimited. */
|
|
84
|
+
export function getModelTokenLimit(pool, ref) {
|
|
85
|
+
if (!pool || typeof ref !== 'string' || ref.length === 0) return null;
|
|
86
|
+
const quota = pool.quotas?.[ref];
|
|
87
|
+
if (!quota) return null;
|
|
88
|
+
return toLimit(quota.tokenLimit ?? quota);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Wall-clock end of the current quota window (always > now). */
|
|
92
|
+
function nextResetAt(quotaResetWindow, now) {
|
|
93
|
+
let at = null;
|
|
94
|
+
try {
|
|
95
|
+
at = nextQuotaReset(quotaResetWindow, now);
|
|
96
|
+
} catch (_) {
|
|
97
|
+
at = null;
|
|
98
|
+
}
|
|
99
|
+
return Number.isFinite(at) && at > now ? Math.floor(at) : now + FALLBACK_WINDOW_MS;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Live tokenUsage map, or null when `create` is false and none exists yet. */
|
|
103
|
+
function usageMap(pool, create) {
|
|
104
|
+
if (!pool.state) {
|
|
105
|
+
if (!create) return null;
|
|
106
|
+
pool.state = {};
|
|
107
|
+
}
|
|
108
|
+
const map = pool.state.tokenUsage;
|
|
109
|
+
if (map instanceof Map) return map;
|
|
110
|
+
if (!create) return null;
|
|
111
|
+
const fresh = new Map();
|
|
112
|
+
pool.state.tokenUsage = fresh;
|
|
113
|
+
return fresh;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Normalized usage view for `ref` — the single place lazy reset happens.
|
|
118
|
+
*
|
|
119
|
+
* A stored entry that is missing, malformed, or past its `resetAt` reads as
|
|
120
|
+
* `{ used: 0, nextResetAt }`; otherwise its recorded values are returned as-is.
|
|
121
|
+
*
|
|
122
|
+
* `now` is intentionally required: reads must not consult an implicit clock.
|
|
123
|
+
* An omitted `now` degrades to "treat the stored window as still open" rather
|
|
124
|
+
* than throwing, so a mistaken call site cannot silently clear a budget.
|
|
125
|
+
* @returns {{used: number, resetAt: number, stored: boolean}}
|
|
126
|
+
*/
|
|
127
|
+
function readQuota(pool, ref, quotaResetWindow, now) {
|
|
128
|
+
const blank = { used: 0, resetAt: null, open: false, stored: false };
|
|
129
|
+
if (!(pool?.state?.tokenUsage instanceof Map)) return blank;
|
|
130
|
+
const entry = pool.state.tokenUsage.get(ref);
|
|
131
|
+
if (!entry || typeof entry !== 'object') return blank;
|
|
132
|
+
const used = toTokenCount(entry.used);
|
|
133
|
+
const resetAt = toTokenCount(entry.resetAt);
|
|
134
|
+
if (used === null || resetAt === null || resetAt <= 0) return blank;
|
|
135
|
+
if (Number.isFinite(now) && now >= resetAt) {
|
|
136
|
+
// Window elapsed: report zero, and hand back the next boundary lazily.
|
|
137
|
+
return { used: 0, resetAt: nextResetAt(quotaResetWindow ?? pool.quotaResetWindow, now), open: true, stored: false };
|
|
138
|
+
}
|
|
139
|
+
return { used, resetAt, open: true, stored: true };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Tokens consumed by `ref` in `pool` during the current window (0 = none/unlimited). */
|
|
143
|
+
export function getModelTokenUsage(pool, ref, now) {
|
|
144
|
+
if (getModelTokenLimit(pool, ref) === null) return 0;
|
|
145
|
+
return readQuota(pool, ref, undefined, now).used;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Remaining tokens, or null when the credential is unlimited in this pool. */
|
|
149
|
+
export function getModelTokenRemaining(pool, ref, now) {
|
|
150
|
+
const limit = getModelTokenLimit(pool, ref);
|
|
151
|
+
if (limit === null) return null;
|
|
152
|
+
return Math.max(0, limit - readQuota(pool, ref, undefined, now).used);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Quota status for one credential in one model pool (status/health APIs).
|
|
157
|
+
* @returns {{configured: true, limit: number, used: number, remaining: number,
|
|
158
|
+
* resetAt: number, exhausted: boolean}|null} null when unlimited
|
|
159
|
+
*/
|
|
160
|
+
export function getModelQuotaStatus(pool, ref, now) {
|
|
161
|
+
const limit = getModelTokenLimit(pool, ref);
|
|
162
|
+
if (limit === null) return null;
|
|
163
|
+
const quota = readQuota(pool, ref, undefined, now);
|
|
164
|
+
const at = quota.resetAt ?? (Number.isFinite(now) ? nextResetAt(pool?.quotaResetWindow, now) : 0);
|
|
165
|
+
return {
|
|
166
|
+
configured: true,
|
|
167
|
+
limit,
|
|
168
|
+
used: quota.used,
|
|
169
|
+
remaining: Math.max(0, limit - quota.used),
|
|
170
|
+
resetAt: at,
|
|
171
|
+
exhausted: quota.used >= limit,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Can this credential receive a new request for this pool right now?
|
|
177
|
+
*
|
|
178
|
+
* `used < limit` stays available, so the final request is allowed to overshoot
|
|
179
|
+
* the limit — this version has no reservation system, so a bounded concurrent
|
|
180
|
+
* overshoot is accepted behaviour rather than a bug.
|
|
181
|
+
*/
|
|
182
|
+
export function isModelQuotaAvailable(pool, ref, now) {
|
|
183
|
+
const limit = getModelTokenLimit(pool, ref);
|
|
184
|
+
if (limit === null) return true;
|
|
185
|
+
return readQuota(pool, ref, undefined, now).used < limit;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** True when at least one ref of `pool` still has local quota left. */
|
|
189
|
+
export function anyModelQuotaAvailable(pool, now) {
|
|
190
|
+
const refs = pool?.refs ?? [];
|
|
191
|
+
for (const ref of refs) {
|
|
192
|
+
if (isModelQuotaAvailable(pool, ref, now)) return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Keep only refs not blocked by a local token quota. Pools without any quota
|
|
199
|
+
* config return an unchanged copy, so the no-quota hot path stays allocation light.
|
|
200
|
+
*/
|
|
201
|
+
export function filterQuotaEligible(pool, refs, now) {
|
|
202
|
+
const list = Array.isArray(refs) ? refs : [];
|
|
203
|
+
if (!hasModelQuotaConfig(pool)) return list.slice();
|
|
204
|
+
return list.filter((ref) => isModelQuotaAvailable(pool, ref, now));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Clear the counter when its window has elapsed. Safe on a cold pool (no-op).
|
|
209
|
+
* `isModelQuotaAvailable` / `getModelQuotaStatus` already reset lazily on read;
|
|
210
|
+
* this is the explicit maintenance-sweep entry point.
|
|
211
|
+
* @returns {boolean} true when a counter was actually cleared
|
|
212
|
+
*/
|
|
213
|
+
export function resetModelQuotaIfNeeded(pool, ref, quotaResetWindow, now) {
|
|
214
|
+
const map = usageMap(pool, false);
|
|
215
|
+
if (!map) return false;
|
|
216
|
+
const entry = map.get(ref);
|
|
217
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
218
|
+
const resetAt = toTokenCount(entry.resetAt);
|
|
219
|
+
if (resetAt === null || resetAt <= 0 || !Number.isFinite(now) || now < resetAt) return false;
|
|
220
|
+
entry.used = 0;
|
|
221
|
+
entry.resetAt = nextResetAt(quotaResetWindow ?? pool.quotaResetWindow, now);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Charge `tokens` to `ref` in `pool` and return the resulting status.
|
|
227
|
+
*
|
|
228
|
+
* Only successful, usage-reporting requests call this. Unlimited credentials and
|
|
229
|
+
* non-positive / invalid token counts are no-ops, so usage is never fabricated
|
|
230
|
+
* and a request without a usage payload can never exhaust a budget.
|
|
231
|
+
* A first write normalises the entry, which is where lazy initialisation and
|
|
232
|
+
* lazy reset both happen (no per-key timers anywhere).
|
|
233
|
+
* @returns {object|null} resulting quota status, or null when nothing was charged
|
|
234
|
+
*/
|
|
235
|
+
export function consumeModelTokens(pool, ref, tokens, quotaResetWindow, now) {
|
|
236
|
+
const limit = getModelTokenLimit(pool, ref);
|
|
237
|
+
if (limit === null) return null;
|
|
238
|
+
const count = toTokenCount(tokens);
|
|
239
|
+
if (count === null || count <= 0) return null;
|
|
240
|
+
const window = quotaResetWindow ?? pool.quotaResetWindow;
|
|
241
|
+
const current = readQuota(pool, ref, window, now);
|
|
242
|
+
const resetAt = current.resetAt ?? (Number.isFinite(now) ? nextResetAt(window, now) : 0);
|
|
243
|
+
const used = Math.min(Number.MAX_SAFE_INTEGER, current.used + count);
|
|
244
|
+
usageMap(pool, true).set(ref, { used, resetAt });
|
|
245
|
+
return {
|
|
246
|
+
configured: true,
|
|
247
|
+
limit,
|
|
248
|
+
used,
|
|
249
|
+
remaining: Math.max(0, limit - used),
|
|
250
|
+
resetAt,
|
|
251
|
+
exhausted: used >= limit,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Token keys understood, in priority order (an explicit total wins). */
|
|
256
|
+
const TOTAL_KEYS = ['total_tokens', 'totalTokens'];
|
|
257
|
+
const PAIR_KEYS = [
|
|
258
|
+
['input_tokens', 'output_tokens'],
|
|
259
|
+
['inputTokens', 'outputTokens'],
|
|
260
|
+
['prompt_tokens', 'completion_tokens'],
|
|
261
|
+
['promptTokens', 'completionTokens'],
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Extract a billable token count from a provider usage payload.
|
|
266
|
+
*
|
|
267
|
+
* Priority: explicit total > input + output > prompt + completion.
|
|
268
|
+
* Non-finite, negative, null and non-numeric values are ignored, so a malformed
|
|
269
|
+
* payload can never poison pool state with NaN or a negative counter, and a
|
|
270
|
+
* payload with no recognised field returns null ("no data") rather than 0
|
|
271
|
+
* ("measured zero") — the distinction that keeps unmeasured requests from
|
|
272
|
+
* marking a key exhausted.
|
|
273
|
+
* @returns {number|null} non-negative integer, or null when no data is present
|
|
274
|
+
*/
|
|
275
|
+
export function extractUsageTokens(usage) {
|
|
276
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
277
|
+
for (const key of TOTAL_KEYS) {
|
|
278
|
+
const total = toTokenCount(usage[key]);
|
|
279
|
+
if (total !== null) return total;
|
|
280
|
+
}
|
|
281
|
+
for (const [a, b] of PAIR_KEYS) {
|
|
282
|
+
const va = toTokenCount(usage[a]);
|
|
283
|
+
const vb = toTokenCount(usage[b]);
|
|
284
|
+
if (va === null && vb === null) continue;
|
|
285
|
+
return (va ?? 0) + (vb ?? 0);
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Message used when a model pool cannot dispatch because every credential has
|
|
292
|
+
* spent its local budget. Deliberately avoids upstream-error phrasing and
|
|
293
|
+
* numeric HTTP codes so this is never re-classified as a provider failure.
|
|
294
|
+
*/
|
|
295
|
+
export function formatModelQuotaExhaustion(provider, pool) {
|
|
296
|
+
const total = (pool?.refs ?? []).length;
|
|
297
|
+
return `[dsh-key-rotation] All ${total} keys for '${provider}' have reached their local token budget for this window; no upstream request was sent.`;
|
|
298
|
+
}
|
package/lib/ops-keys.js
CHANGED
|
@@ -77,6 +77,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
77
77
|
st.authFailCounts?.delete(ref);
|
|
78
78
|
st.brokenUntil?.delete(ref);
|
|
79
79
|
st.revokedRefs?.delete(ref);
|
|
80
|
+
st.tokenUsage?.delete(ref);
|
|
80
81
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
81
82
|
}
|
|
82
83
|
bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
|
|
@@ -130,6 +131,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
130
131
|
st.authFailCounts?.clear();
|
|
131
132
|
st.brokenUntil?.clear();
|
|
132
133
|
st.revokedRefs?.clear();
|
|
134
|
+
st.tokenUsage?.clear();
|
|
133
135
|
st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
|
|
134
136
|
let circuitReset = false;
|
|
135
137
|
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
@@ -143,12 +145,13 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
143
145
|
if (ref) {
|
|
144
146
|
let found = false;
|
|
145
147
|
for (const st of poolState.values()) {
|
|
146
|
-
if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.authFailCounts?.has(ref) || st.brokenUntil?.has(ref)) {
|
|
148
|
+
if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.authFailCounts?.has(ref) || st.brokenUntil?.has(ref) || st.tokenUsage?.has(ref)) {
|
|
147
149
|
st.failedUntil?.delete(ref);
|
|
148
150
|
st.failCounts?.delete(ref);
|
|
149
151
|
st.authFailCounts?.delete(ref);
|
|
150
152
|
st.brokenUntil?.delete(ref);
|
|
151
153
|
st.revokedRefs?.delete(ref);
|
|
154
|
+
st.tokenUsage?.delete(ref);
|
|
152
155
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
153
156
|
found = true; break;
|
|
154
157
|
}
|
package/lib/ops-status.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getModelQuotaStatus, isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
|
|
1
2
|
import { decryptSecret } from './crypto-storage.js';
|
|
2
3
|
// lib/ops-status.js — status + health operational routes (#312 split from routes-ops.js).
|
|
3
4
|
import { json } from './http-bridge.js';
|
|
@@ -56,7 +57,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
56
57
|
const now = Date.now();
|
|
57
58
|
const seen = new Set();
|
|
58
59
|
const providers = [];
|
|
59
|
-
for (const pool of poolByRef.values()) {
|
|
60
|
+
for (const pool of (runtime.pools ?? poolByRef.values())) {
|
|
60
61
|
if (seen.has(pool.base)) continue;
|
|
61
62
|
seen.add(pool.base);
|
|
62
63
|
try {
|
|
@@ -89,6 +90,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
89
90
|
/* describe is optional — the card falls back to editable */
|
|
90
91
|
}
|
|
91
92
|
const until = pool.state.failedUntil.get(ref);
|
|
93
|
+
const modelQuota = getModelQuotaStatus(pool, ref, now);
|
|
92
94
|
keys.push({
|
|
93
95
|
ref,
|
|
94
96
|
present,
|
|
@@ -111,10 +113,13 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
111
113
|
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
112
114
|
paused: isKeyPaused(pool, ref),
|
|
113
115
|
revoked: isKeyRevoked(pool, ref),
|
|
116
|
+
modelQuota,
|
|
114
117
|
});
|
|
115
118
|
}
|
|
116
119
|
providers.push({
|
|
117
120
|
provider: pool.base,
|
|
121
|
+
model: pool.model ?? null,
|
|
122
|
+
quotasConfigured: hasModelQuotaConfig(pool),
|
|
118
123
|
keys,
|
|
119
124
|
tags: providerTags.get(pool.base) ?? [],
|
|
120
125
|
// #260 circuit breaker state (may be null if not yet tripped)
|
|
@@ -149,7 +154,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
149
154
|
});
|
|
150
155
|
} catch (e) {
|
|
151
156
|
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
|
|
152
|
-
providers.push({ provider: pool.base, keys: [], statusError: String(e?.message ?? e) });
|
|
157
|
+
providers.push({ provider: pool.base, model: pool.model ?? null, keys: [], statusError: String(e?.message ?? e) });
|
|
153
158
|
}
|
|
154
159
|
}
|
|
155
160
|
json(res, 200, sanitizeSnapshot({
|
|
@@ -180,23 +185,34 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
180
185
|
const now = Date.now();
|
|
181
186
|
const pools = {};
|
|
182
187
|
let exhaustedAny = false;
|
|
183
|
-
const
|
|
188
|
+
const runtimeH = buildRuntime();
|
|
184
189
|
const seenH = new Set();
|
|
185
|
-
for (const pool of
|
|
190
|
+
for (const pool of (runtimeH.pools ?? runtimeH.poolByRef.values())) {
|
|
186
191
|
if (seenH.has(pool.base)) continue;
|
|
187
192
|
seenH.add(pool.base);
|
|
193
|
+
const quotaAware = hasModelQuotaConfig(pool);
|
|
188
194
|
let healthy = 0;
|
|
195
|
+
let quotaExhausted = 0;
|
|
189
196
|
for (const ref of pool.refs) {
|
|
197
|
+
if (isKeyRevoked(pool, ref) || isKeyPaused(pool, ref)) continue;
|
|
190
198
|
const until = pool.state.failedUntil.get(ref);
|
|
191
199
|
if (until !== undefined && until > now) continue;
|
|
192
200
|
const exp = pool.expiresAt?.[ref];
|
|
193
201
|
if (exp !== undefined && now >= exp) continue;
|
|
202
|
+
if (quotaAware && !isModelQuotaAvailable(pool, ref, now)) { quotaExhausted++; continue; }
|
|
194
203
|
healthy++;
|
|
195
204
|
}
|
|
196
205
|
const total = pool.refs.length;
|
|
197
206
|
const exhausted = healthy === 0 && total > 0;
|
|
198
207
|
if (exhausted) exhaustedAny = true;
|
|
199
|
-
pools[pool.base] = {
|
|
208
|
+
pools[pool.base] = {
|
|
209
|
+
healthy,
|
|
210
|
+
total,
|
|
211
|
+
exhausted,
|
|
212
|
+
healthScore: computeHealthScore(pool.state),
|
|
213
|
+
model: pool.model ?? null,
|
|
214
|
+
modelQuota: quotaAware ? { configured: true, exhaustedKeys: quotaExhausted } : null,
|
|
215
|
+
};
|
|
200
216
|
}
|
|
201
217
|
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: typeof quotaStore?.snapshot === 'function' ? quotaStore.snapshot() : null });
|
|
202
218
|
},
|