@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/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 { buildPoolItem, cleanupRemovedProviders } from './pool-builder.js';
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 poolByRef = new Map();
256
- const providerToPool = new Map();
257
- const modelPoolByProvider = new Map();
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
- credentials.resolve = async (ref) => {
357
- const { poolByRef } = buildRuntime();
358
- const pool = poolByRef.get(ref);
359
- if (!pool) {
360
- const res = await original(ref);
361
- if (res && typeof res.value === 'string') {
362
- return { ...res, value: decryptSecret(res.value) };
363
- }
364
- const ev = envValue(ref);
365
- if (ev !== undefined) return { value: decryptSecret(ev) };
366
- return res;
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 { poolByRef: pr, providerTags } = buildRuntime();
188
+ const runtimeH = buildRuntime();
184
189
  const seenH = new Set();
185
- for (const pool of pr.values()) {
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] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
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
  },