@goodandready/dsh-key-rotation 0.6.0 → 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 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 pool = { base: p.provider, refs, state, cooldownMs: poolCooldown };
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,11 +1,11 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.6.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",
7
7
  "dsh",
8
- "plugin",
8
+ "dsh-plugin",
9
9
  "bundle",
10
10
  "api-key",
11
11
  "rotation",