@goodandready/dsh-key-rotation 0.6.1 → 0.7.0
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/README.md +7 -0
- package/lib/index.js +18 -6
- package/lib/pool.js +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
- **rotation counter** — how many times a provider switched key, on which failure, and how long ago.
|
|
18
18
|
- **key order** — ↑/↓ buttons; the order of keys is the order they are tried.
|
|
19
19
|
- **switch codes as checkboxes** instead of a comma-separated string.
|
|
20
|
+
- **Exponential backoff** — repeated failures on the same key double its cooldown (base → ×2 → ×4 → cap ×8), so a dead key is not retried every window.
|
|
21
|
+
- **Reset cooldown** — a *Reset cooldown* button in the card clears a provider's cooldown immediately (also via `POST /dsh-key-rotation/reset`).
|
|
22
|
+
- **Env bootstrap** — if a pool ref (e.g. `MYPROVIDER_API_KEY`) is already set in `process.env`, it is treated as a transient credential without needing a DSH credential first.
|
|
23
|
+
- **Per-provider cooldown** — override `cooldownMs` (and `maxCooldownMs`) per provider, fallback to the global values.
|
|
24
|
+
- **Exhaustion warning** — when every key is cooling, a red warning appears in the card and `lastExhaustionAt`/`exhaustionCount` are exposed via `GET /dsh-key-rotation/status`.
|
|
25
|
+
- **Failure log** — last 20 failures per provider (`at`, `ref`, `reason`, `cooldownMs`) via `/status` and a collapsible *Recent failures* list.
|
|
26
|
+
- **Non-stream safety net** — an `agent/request-error` hook retries sync calls (embeddings, batch) with the next key when the error is switchable.
|
|
20
27
|
|
|
21
28
|
## Install
|
|
22
29
|
|
package/lib/index.js
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
// providers: array [{ provider, keys: [envName, ...] }]
|
|
34
34
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
35
|
import Schema from '@deepseek-ai/schemastery';
|
|
36
|
-
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue } from './pool.js';
|
|
36
|
+
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired } from './pool.js';
|
|
37
37
|
|
|
38
38
|
export const name = 'dsh-key-rotation';
|
|
39
39
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
@@ -77,10 +77,12 @@ const DEFAULT_PROVIDERS = [];
|
|
|
77
77
|
export const Config = Schema.object({
|
|
78
78
|
switchCodes: Schema.array(Schema.string()).default([...DEFAULT_SWITCH_CODES]),
|
|
79
79
|
cooldownMs: Schema.number().default(60000),
|
|
80
|
+
maxCooldownMs: Schema.number(),
|
|
80
81
|
providers: Schema.array(Schema.object({
|
|
81
82
|
provider: Schema.string().required(),
|
|
82
83
|
keys: Schema.array(Schema.string()).default([]),
|
|
83
84
|
cooldownMs: Schema.number(),
|
|
85
|
+
maxCooldownMs: Schema.number(),
|
|
84
86
|
})).default([...DEFAULT_PROVIDERS]),
|
|
85
87
|
});
|
|
86
88
|
|
|
@@ -241,6 +243,14 @@ export function apply(ctx, config = {}) {
|
|
|
241
243
|
// ── key-pool state, persisted across config reloads ──
|
|
242
244
|
// base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
|
|
243
245
|
const poolState = new Map();
|
|
246
|
+
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
247
|
+
ctx.effect(() => {
|
|
248
|
+
const id = setInterval(() => {
|
|
249
|
+
const n = sweepExpired(poolState, Date.now());
|
|
250
|
+
if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
251
|
+
}, 30000);
|
|
252
|
+
return () => clearInterval(id);
|
|
253
|
+
}, 'dsh-key-rotation: sweep expired cooldowns');
|
|
244
254
|
|
|
245
255
|
// ── runtime snapshot: config + llm-pi-ai profile mapping ──
|
|
246
256
|
function buildRuntime() {
|
|
@@ -249,6 +259,7 @@ export function apply(ctx, config = {}) {
|
|
|
249
259
|
const cfg = Config(structuredClone(getConfig() ?? {})) ?? {};
|
|
250
260
|
const switchCodes = new Set(cfg.switchCodes ?? DEFAULT_SWITCH_CODES);
|
|
251
261
|
const cooldownMs = cfg.cooldownMs ?? 60000;
|
|
262
|
+
const maxCooldownMs = cfg.maxCooldownMs ?? undefined;
|
|
252
263
|
|
|
253
264
|
// ref -> pool (every key env of every configured provider)
|
|
254
265
|
const poolByRef = new Map();
|
|
@@ -279,7 +290,8 @@ export function apply(ctx, config = {}) {
|
|
|
279
290
|
poolState.set(p.provider, state);
|
|
280
291
|
}
|
|
281
292
|
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
282
|
-
const
|
|
293
|
+
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
294
|
+
const pool = { base: p.provider, refs, state, cooldownMs: poolCooldown, maxCooldownMs: poolMax };
|
|
283
295
|
for (const ref of refs) poolByRef.set(ref, pool);
|
|
284
296
|
for (let i = 1; i < refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
285
297
|
}
|
|
@@ -296,7 +308,7 @@ export function apply(ctx, config = {}) {
|
|
|
296
308
|
}
|
|
297
309
|
}
|
|
298
310
|
|
|
299
|
-
return { switchCodes, cooldownMs, poolByRef, providerToPool, cloneIds };
|
|
311
|
+
return { switchCodes, cooldownMs, maxCooldownMs, poolByRef, providerToPool, cloneIds };
|
|
300
312
|
}
|
|
301
313
|
|
|
302
314
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
@@ -352,7 +364,7 @@ export function apply(ctx, config = {}) {
|
|
|
352
364
|
// the resolve patch hands out the next key on each dispatch.
|
|
353
365
|
function rotate(options, pool) {
|
|
354
366
|
return (async function* () {
|
|
355
|
-
const { switchCodes, cooldownMs } = buildRuntime();
|
|
367
|
+
const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
|
|
356
368
|
let lastFailure = null;
|
|
357
369
|
|
|
358
370
|
for (let attempt = 0; attempt < pool.refs.length; attempt++) {
|
|
@@ -363,7 +375,7 @@ export function apply(ctx, config = {}) {
|
|
|
363
375
|
// mark the internal dispatch so the interceptor does not re-rotate
|
|
364
376
|
inner = ctx.llm.stream({ ...options, [MARKER]: true });
|
|
365
377
|
} catch (e) {
|
|
366
|
-
if (pool.state.lastUsed) { const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), pool.cooldownMs ?? cooldownMs); pushEvent(pool, pool.state.lastUsed, e?.code ?? 'TRANSPORT', _b); }
|
|
378
|
+
if (pool.state.lastUsed) { const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), pool.cooldownMs ?? cooldownMs, pool.maxCooldownMs ?? maxCooldownMs); pushEvent(pool, pool.state.lastUsed, e?.code ?? 'TRANSPORT', _b); }
|
|
367
379
|
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
368
380
|
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
369
381
|
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
@@ -387,7 +399,7 @@ export function apply(ctx, config = {}) {
|
|
|
387
399
|
const switchable = !yielded && kind === 'error' &&
|
|
388
400
|
(switchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
|
|
389
401
|
if (switchable) {
|
|
390
|
-
if (pool.state.lastUsed) { const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), pool.cooldownMs ?? cooldownMs); pushEvent(pool, pool.state.lastUsed, code ?? 'UNKNOWN', _b); }
|
|
402
|
+
if (pool.state.lastUsed) { const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), pool.cooldownMs ?? cooldownMs, pool.maxCooldownMs ?? maxCooldownMs); pushEvent(pool, pool.state.lastUsed, code ?? 'UNKNOWN', _b); }
|
|
391
403
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
392
404
|
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
393
405
|
pool.state.lastSwitchAt = Date.now();
|
package/lib/pool.js
CHANGED
|
@@ -139,3 +139,18 @@ export function envValue(ref) {
|
|
|
139
139
|
const v = typeof process !== 'undefined' ? process.env?.[ref] : undefined;
|
|
140
140
|
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
141
141
|
}
|
|
142
|
+
|
|
143
|
+
/** Sweep expired cooldown entries from poolState. Returns count of cleared refs. */
|
|
144
|
+
export function sweepExpired(poolState, now = Date.now()) {
|
|
145
|
+
let cleared = 0;
|
|
146
|
+
for (const st of poolState.values()) {
|
|
147
|
+
for (const [ref, until] of [...st.failedUntil.entries()]) {
|
|
148
|
+
if (until <= now) {
|
|
149
|
+
st.failedUntil.delete(ref);
|
|
150
|
+
st.failCounts?.delete(ref);
|
|
151
|
+
cleared++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return cleared;
|
|
156
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|