@goodandready/dsh-key-rotation 0.8.28 → 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 +49 -0
- package/README.md +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/budget-monitor.js +35 -30
- package/lib/cascade.js +3 -9
- package/lib/client.js +283 -1
- package/lib/http-bridge.js +69 -6
- package/lib/index.js +35 -128
- package/lib/model-quota.js +298 -0
- package/lib/ops-keys.js +8 -1
- package/lib/ops-metrics.js +6 -1
- package/lib/ops-status.js +26 -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 +33 -10
- package/lib/resolver.js +159 -0
- package/lib/rotate.js +85 -1
- package/lib/webhook.js +9 -1
- package/package.json +1 -1
package/lib/http-bridge.js
CHANGED
|
@@ -93,15 +93,78 @@ export async function writeSection(ctx, ns, section, expectedRevision, res, rese
|
|
|
93
93
|
json(res, 200, viewOf(descriptor, { writable: settings.writable, hasDocument: settings.documentPath !== void 0 }));
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Normalise one provider entry into `{ id, name }`, or null when unusable.
|
|
98
|
+
* Provider info shapes differ across core generations: `listProviders()` yields
|
|
99
|
+
* `{ id, name }`, while `listConfigurableProviders()` yields
|
|
100
|
+
* `{ provider, displayName, ... }`.
|
|
101
|
+
*/
|
|
102
|
+
function toCatalogEntry(info) {
|
|
103
|
+
if (!info || typeof info !== 'object') return null;
|
|
104
|
+
const id = typeof info.id === 'string' && info.id
|
|
105
|
+
? info.id
|
|
106
|
+
: (typeof info.provider === 'string' && info.provider ? info.provider : null);
|
|
107
|
+
if (!id) return null;
|
|
108
|
+
const name = typeof info.name === 'string' && info.name ? info.name
|
|
109
|
+
: (typeof info.displayName === 'string' && info.displayName ? info.displayName : id);
|
|
110
|
+
return { id, name };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Registered providers for the Settings card's picker (#379).
|
|
115
|
+
*
|
|
116
|
+
* `llm` is a service whose members are resolved through the host's service
|
|
117
|
+
* layer, and on newer cores some of them are *remote* (RPC-backed). A remote
|
|
118
|
+
* member may legitimately return a promise, and an older core may not expose
|
|
119
|
+
* `listProviders` at all. Both cases used to throw out of the GET handler and
|
|
120
|
+
* surface to the user as an unavailable provider list, so every call is
|
|
121
|
+
* guarded and `listConfigurableProviders()` is used as a fallback source.
|
|
122
|
+
*
|
|
123
|
+
* Never throws: an empty list is a valid answer, and the caller reports
|
|
124
|
+
* availability separately.
|
|
125
|
+
*/
|
|
96
126
|
export function providerCatalog(ctx, cloneIds) {
|
|
97
|
-
const seen = new Set();
|
|
98
127
|
const out = [];
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const push = (infos) => {
|
|
130
|
+
if (!Array.isArray(infos)) return;
|
|
131
|
+
for (const raw of infos) {
|
|
132
|
+
const entry = toCatalogEntry(raw);
|
|
133
|
+
if (!entry || seen.has(entry.id) || cloneIds?.has(entry.id)) continue;
|
|
134
|
+
seen.add(entry.id);
|
|
135
|
+
out.push(entry);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let llm = null;
|
|
140
|
+
try {
|
|
141
|
+
llm = ctx?.get?.('llm') ?? null;
|
|
142
|
+
} catch (_) {
|
|
143
|
+
llm = null;
|
|
104
144
|
}
|
|
145
|
+
|
|
146
|
+
const listProviders = llm?.listProviders;
|
|
147
|
+
if (typeof listProviders === 'function') {
|
|
148
|
+
try {
|
|
149
|
+
const infos = listProviders.call(llm);
|
|
150
|
+
if (infos && typeof infos.then !== 'function') push(infos);
|
|
151
|
+
} catch (_) {
|
|
152
|
+
/* fall through to configurable-provider directory */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (out.length === 0) {
|
|
157
|
+
const listConfigurable = llm?.listConfigurableProviders;
|
|
158
|
+
if (typeof listConfigurable === 'function') {
|
|
159
|
+
try {
|
|
160
|
+
const infos = listConfigurable.call(llm);
|
|
161
|
+
if (infos && typeof infos.then !== 'function') push(infos);
|
|
162
|
+
} catch (_) {
|
|
163
|
+
/* an unavailable catalog is reported by the card, not thrown here */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
105
168
|
return out;
|
|
106
169
|
}
|
|
107
170
|
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createResolver } from './resolver.js';
|
|
2
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
3
|
// dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
|
|
2
4
|
//
|
|
3
5
|
// Transparent key pool rotation: credentials.resolve patch + llm/stream
|
|
@@ -13,13 +15,12 @@ import {
|
|
|
13
15
|
recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter,
|
|
14
16
|
computeHealthScore, extractRateLimit, isRateLimited, selectPool, isSwitchableError,
|
|
15
17
|
formatExhaustionMessage, expiringSoon, shouldNotifyDaily, costForDay, costForWeek,
|
|
16
|
-
budgetVerdict, sortAttemptList,
|
|
18
|
+
budgetVerdict, sortAttemptList, isKeyPaused, isKeyRevoked,
|
|
17
19
|
} from './pool.js';
|
|
18
20
|
import { LatencyHistogram } from './histogram.js';
|
|
19
21
|
import { ConcurrencyTracker } from './concurrency.js';
|
|
20
22
|
import { QuotaStore } from './quota.js';
|
|
21
23
|
import { WebhookSender } from './webhook.js';
|
|
22
|
-
import { bucketAllow, bucketRetryMs } from './bucket.js';
|
|
23
24
|
import { compactUsage } from './usage-report.js';
|
|
24
25
|
import { json, handleConfigBridge } from './http-bridge.js';
|
|
25
26
|
import { createRotate } from './rotate.js';
|
|
@@ -34,7 +35,15 @@ import { notifySwitch, notifyExhaustion, pushEvent } from './notify-events.js';
|
|
|
34
35
|
import { getLogger } from './logger.js';
|
|
35
36
|
import { createSandboxService } from './sandbox-service.js';
|
|
36
37
|
import { checkBudgetAndHealthAlerts } from './budget-monitor.js';
|
|
37
|
-
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';
|
|
38
47
|
import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence } from './lifecycle.js';
|
|
39
48
|
|
|
40
49
|
export const name = '@goodandready/dsh-key-rotation';
|
|
@@ -115,6 +124,8 @@ const SettingsSchema = Schema.object({
|
|
|
115
124
|
provider: Schema.string().required(),
|
|
116
125
|
keys: Schema.array(Schema.string()).default([]),
|
|
117
126
|
weights: Schema.array(Schema.number()).default([]),
|
|
127
|
+
paused: Schema.array(Schema.boolean()).default([]),
|
|
128
|
+
revoked: Schema.array(Schema.boolean()).default([]),
|
|
118
129
|
expiresAt: Schema.array(Schema.union([Schema.number(), Schema.string()])).default([]),
|
|
119
130
|
tags: Schema.array(Schema.string()).default([]),
|
|
120
131
|
costBudgetDaily: Schema.number(),
|
|
@@ -123,6 +134,9 @@ const SettingsSchema = Schema.object({
|
|
|
123
134
|
models: Schema.dict(Schema.object({
|
|
124
135
|
keys: Schema.array(Schema.string()).default([]),
|
|
125
136
|
weights: Schema.array(Schema.number()).default([]),
|
|
137
|
+
quotas: Schema.dict(Schema.object({
|
|
138
|
+
tokenLimit: Schema.number(),
|
|
139
|
+
})).default({}),
|
|
126
140
|
})).default({}),
|
|
127
141
|
cooldownMs: Schema.number(),
|
|
128
142
|
maxCooldownMs: Schema.number(),
|
|
@@ -192,9 +206,9 @@ export function apply(ctx, config = {}) {
|
|
|
192
206
|
}
|
|
193
207
|
const runtime = buildRuntime();
|
|
194
208
|
const allActiveRefs = Array.from(runtime.poolByRef.keys());
|
|
195
|
-
const n = sweepExpired(poolState, now, allActiveRefs);
|
|
209
|
+
const n = sweepExpired(poolState, now, allActiveRefs, runtime.quotaResetWindow);
|
|
196
210
|
if (n > 0) logger.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
197
|
-
for (const pool of runtime.poolByRef.values()) {
|
|
211
|
+
for (const pool of (runtime.pools ?? runtime.poolByRef.values())) {
|
|
198
212
|
compactUsage(pool, 30, now);
|
|
199
213
|
}
|
|
200
214
|
checkBudgetAndHealthAlerts({
|
|
@@ -249,42 +263,9 @@ export function apply(ctx, config = {}) {
|
|
|
249
263
|
const rpmLimit = cfg.rpmLimit ?? 0;
|
|
250
264
|
const webhookActionToken = cfg.webhookActionToken ?? '';
|
|
251
265
|
|
|
252
|
-
const
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
const cloneIds = new Set();
|
|
256
|
-
|
|
257
|
-
const makeState = (base) => {
|
|
258
|
-
let st = poolState.get(base);
|
|
259
|
-
if (!st) {
|
|
260
|
-
st = initializePoolState();
|
|
261
|
-
poolState.set(base, st);
|
|
262
|
-
}
|
|
263
|
-
return initializePoolState(st);
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
const buildPool = (base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
|
|
267
|
-
buildPoolItem({ base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
|
|
268
|
-
|
|
269
|
-
for (const p of cfg.providers ?? []) {
|
|
270
|
-
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
271
|
-
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
272
|
-
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard);
|
|
273
|
-
if (pool) {
|
|
274
|
-
for (const ref of pool.refs) poolByRef.set(ref, pool);
|
|
275
|
-
for (let i = 1; i < pool.refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
276
|
-
}
|
|
277
|
-
const models = p.models ?? {};
|
|
278
|
-
const byModel = new Map();
|
|
279
|
-
for (const [model, mp] of Object.entries(models)) {
|
|
280
|
-
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard);
|
|
281
|
-
if (mpool) {
|
|
282
|
-
byModel.set(model, mpool);
|
|
283
|
-
for (const ref of mpool.refs) poolByRef.set(ref, mpool);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
if (byModel.size > 0) modelPoolByProvider.set(p.provider, byModel);
|
|
287
|
-
}
|
|
266
|
+
const built = buildPools({ cfg, poolState, defaultState: initializePoolState });
|
|
267
|
+
const { index, pools, poolByRef, modelPoolByProvider, cloneIds } = built;
|
|
268
|
+
const providerToPool = new Map(built.providerToPool);
|
|
288
269
|
|
|
289
270
|
let profiles = {};
|
|
290
271
|
try {
|
|
@@ -315,6 +296,7 @@ export function apply(ctx, config = {}) {
|
|
|
315
296
|
cfg,
|
|
316
297
|
poolState,
|
|
317
298
|
poolByRef,
|
|
299
|
+
pools,
|
|
318
300
|
providerToPool,
|
|
319
301
|
expectedClones,
|
|
320
302
|
moduleBreaker,
|
|
@@ -332,13 +314,13 @@ export function apply(ctx, config = {}) {
|
|
|
332
314
|
routingStrategy: cfg.routingStrategy ?? 'round-robin',
|
|
333
315
|
switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
|
|
334
316
|
warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
|
|
317
|
+
index, pools,
|
|
335
318
|
providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
|
|
336
319
|
cloneIds, expectedClones,
|
|
337
320
|
circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
|
|
338
321
|
circuitBreakerThreshold: cfg.circuitBreakerThreshold ?? 5,
|
|
339
322
|
circuitBreakerOpenMs: cfg.circuitBreakerOpenMs ?? 30000,
|
|
340
323
|
circuitBreakerHalfOpenProbes: cfg.circuitBreakerHalfOpenProbes ?? 1,
|
|
341
|
-
pools: [...new Set(poolByRef.values())],
|
|
342
324
|
};
|
|
343
325
|
return cachedRuntime;
|
|
344
326
|
}
|
|
@@ -350,92 +332,17 @@ export function apply(ctx, config = {}) {
|
|
|
350
332
|
if (credentials && typeof credentials.resolve === 'function') {
|
|
351
333
|
const original = credentials.resolve.bind(credentials);
|
|
352
334
|
credentials.__dshKeyRotationOriginalResolve = original;
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
for (let i = 0; i < list.length; i++) {
|
|
365
|
-
const index = (start + i) % list.length;
|
|
366
|
-
const candidate = list[index];
|
|
367
|
-
const until = pool.state.failedUntil.get(candidate);
|
|
368
|
-
if (until !== undefined && until > now) continue;
|
|
369
|
-
if (pool.expiresAt?.[candidate] !== undefined && now >= pool.expiresAt[candidate]) continue;
|
|
370
|
-
const rpmLimit = pool.rpmLimit ?? 0;
|
|
371
|
-
if (rpmLimit > 0) {
|
|
372
|
-
if (!pool.state.rpmWindows) pool.state.rpmWindows = new Map();
|
|
373
|
-
if (!bucketAllow(pool.state.rpmWindows, candidate, rpmLimit, now)) {
|
|
374
|
-
const waitMs = bucketRetryMs(pool.state.rpmWindows, candidate, rpmLimit, now);
|
|
375
|
-
if ((pool.state.failedUntil.get(candidate) ?? 0) < now + waitMs) {
|
|
376
|
-
pool.state.failedUntil.set(candidate, now + waitMs);
|
|
377
|
-
}
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
if (pool.perHour) {
|
|
382
|
-
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
383
|
-
let win = pool.state.quotaWindows.get(candidate);
|
|
384
|
-
if (!win || now - win.start >= 3600000) win = { count: 0, start: now };
|
|
385
|
-
if (win.count >= pool.perHour) {
|
|
386
|
-
const until = win.start + 3600000;
|
|
387
|
-
if ((pool.state.failedUntil.get(candidate) ?? 0) < until) pool.state.failedUntil.set(candidate, until);
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
pool.state.pointer = (index + 1) % list.length;
|
|
392
|
-
let hit = await original(candidate);
|
|
393
|
-
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
394
|
-
pool.state.lastUsed = candidate;
|
|
395
|
-
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
396
|
-
pool.state.lastUsedAt.set(candidate, now);
|
|
397
|
-
const store = dispatchStorage.getStore();
|
|
398
|
-
if (store && store.pool === pool) store.pickedRef = candidate;
|
|
399
|
-
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
400
|
-
pool.state.failedUntil.delete(candidate);
|
|
401
|
-
if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
|
|
402
|
-
if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
|
|
403
|
-
if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
|
|
404
|
-
pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
|
|
405
|
-
if (pool.perHour) {
|
|
406
|
-
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
407
|
-
let win2 = pool.state.quotaWindows.get(candidate);
|
|
408
|
-
if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
|
|
409
|
-
win2.count++;
|
|
410
|
-
pool.state.quotaWindows.set(candidate, win2);
|
|
411
|
-
}
|
|
412
|
-
return hit;
|
|
413
|
-
}
|
|
414
|
-
const envVal = envValue(candidate);
|
|
415
|
-
if (envVal !== undefined) {
|
|
416
|
-
pool.state.lastUsed = candidate;
|
|
417
|
-
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
418
|
-
pool.state.lastUsedAt.set(candidate, now);
|
|
419
|
-
const store = dispatchStorage.getStore();
|
|
420
|
-
if (store && store.pool === pool) store.pickedRef = candidate;
|
|
421
|
-
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
422
|
-
pool.state.failedUntil.delete(candidate);
|
|
423
|
-
if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
|
|
424
|
-
if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
|
|
425
|
-
if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
|
|
426
|
-
pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
|
|
427
|
-
if (pool.perHour) {
|
|
428
|
-
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
429
|
-
let win2 = pool.state.quotaWindows.get(candidate);
|
|
430
|
-
if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
|
|
431
|
-
win2.count++;
|
|
432
|
-
pool.state.quotaWindows.set(candidate, win2);
|
|
433
|
-
}
|
|
434
|
-
return { value: envVal, source: 'env' };
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
return original(ref);
|
|
438
|
-
};
|
|
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);
|
|
439
346
|
credentials.__dshKeyRotationPatched = true;
|
|
440
347
|
return () => {
|
|
441
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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
2
|
// lib/ops-keys.js — key store + cooldown reset + import routes (#312 split from routes-ops.js).
|
|
2
3
|
import {
|
|
3
4
|
json,
|
|
@@ -75,6 +76,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
75
76
|
st.failCounts?.delete(ref);
|
|
76
77
|
st.authFailCounts?.delete(ref);
|
|
77
78
|
st.brokenUntil?.delete(ref);
|
|
79
|
+
st.revokedRefs?.delete(ref);
|
|
80
|
+
st.tokenUsage?.delete(ref);
|
|
78
81
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
79
82
|
}
|
|
80
83
|
bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
|
|
@@ -127,6 +130,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
127
130
|
st.failCounts?.clear();
|
|
128
131
|
st.authFailCounts?.clear();
|
|
129
132
|
st.brokenUntil?.clear();
|
|
133
|
+
st.revokedRefs?.clear();
|
|
134
|
+
st.tokenUsage?.clear();
|
|
130
135
|
st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
|
|
131
136
|
let circuitReset = false;
|
|
132
137
|
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
@@ -140,11 +145,13 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
140
145
|
if (ref) {
|
|
141
146
|
let found = false;
|
|
142
147
|
for (const st of poolState.values()) {
|
|
143
|
-
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)) {
|
|
144
149
|
st.failedUntil?.delete(ref);
|
|
145
150
|
st.failCounts?.delete(ref);
|
|
146
151
|
st.authFailCounts?.delete(ref);
|
|
147
152
|
st.brokenUntil?.delete(ref);
|
|
153
|
+
st.revokedRefs?.delete(ref);
|
|
154
|
+
st.tokenUsage?.delete(ref);
|
|
148
155
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
149
156
|
found = true; break;
|
|
150
157
|
}
|