@goodandready/dsh-key-rotation 0.5.3 → 0.5.4

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/client.js CHANGED
@@ -60,6 +60,9 @@ window.__ModuleLoader__.load({
60
60
  keyFromEnv: 'from the environment, read-only here',
61
61
  keyWriteFailed: 'could not store the key: {msg}',
62
62
  keyHint: 'The value is stored in DSH credentials and never sent back to the browser — only its last 5 characters are shown. Names are generated for you; hover a key to see the one it uses.',
63
+ resetCooldown: 'Reset cooldown',
64
+ poolExhausted: 'pool exhausted — all keys cooling',
65
+ resetting: 'Resetting…',
63
66
  keyLabel: 'Key {n}',
64
67
  };
65
68
  const ru = {
@@ -97,6 +100,9 @@ window.__ModuleLoader__.load({
97
100
  keyFromEnv: 'задан в окружении, отсюда не меняется',
98
101
  keyWriteFailed: 'не удалось сохранить ключ: {msg}',
99
102
  keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
103
+ resetCooldown: 'Сбросить кулдаун',
104
+ poolExhausted: 'пул исчерпан — все ключи остывают',
105
+ resetting: 'Сброс…',
100
106
  keyLabel: 'Ключ {n}',
101
107
  };
102
108
 
@@ -250,6 +256,16 @@ window.__ModuleLoader__.load({
250
256
  // хоста они не приходят, в карточке видны лишь последние символы.
251
257
  const [secretDraft, setSecretDraft] = React.useState({});
252
258
  const [secretError, setSecretError] = React.useState('');
259
+ const [resetting, setResetting] = React.useState('');
260
+ const doReset = (providerId) => {
261
+ setResetting(providerId);
262
+ setSecretError('');
263
+ fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: providerId }) })
264
+ .then((r) => r.json().then((data) => ({ ok: r.ok, data })))
265
+ .then(({ ok, data }) => { if (!ok) throw new Error(data?.error?.message ?? 'unknown error'); })
266
+ .catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))))
267
+ .finally(() => setResetting(''));
268
+ };
253
269
 
254
270
  const keyInfo = (providerId, ref) => {
255
271
  const entryStatus = status[providerId];
@@ -457,6 +473,9 @@ window.__ModuleLoader__.load({
457
473
  .replace('{reason}', String(providerStatus.lastReason || '—'))
458
474
  .replace('{ago}', formatAgo(t, providerStatus.lastSwitchAt))
459
475
  : t('switchesNone'));
476
+ const exhaustionWarning = providerStatus && providerStatus.lastExhaustionAt && (Date.now() - providerStatus.lastExhaustionAt) < 3600000
477
+ ? h('p', { className: 'krot-err' }, t('poolExhausted') + ' (' + formatAgo(t, providerStatus.lastExhaustionAt) + ')')
478
+ : null;
460
479
 
461
480
  return h('div', { key: pIndex, className: 'krot-prov' },
462
481
  h('div', { className: 'krot-prov-head' },
@@ -467,6 +486,9 @@ window.__ModuleLoader__.load({
467
486
  h('div', { className: 'krot-foot' },
468
487
  btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
469
488
  switchesLine,
489
+ exhaustionWarning,
490
+ (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { style: { fontSize: '11px', marginTop: '6px' } }, h('summary', null, 'Recent failures ('+providerStatus.events.length+')'), h('ul', { style: { margin: '4px 0 0', paddingLeft: '16px' } }, providerStatus.events.slice().reverse().map((ev, i) => h('li', { key: i }, new Date(ev.at).toLocaleTimeString() + ' ' + ev.ref + ' ' + ev.reason + ' cd=' + ev.cooldownMs)) )) : null),
491
+ btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
470
492
  ),
471
493
  );
472
494
  });
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 } from './pool.js';
36
+ import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue } from './pool.js';
37
37
 
38
38
  export const name = 'dsh-key-rotation';
39
39
  export const inject = ['llm', 'webServer', 'settings', 'credentials'];
@@ -45,11 +45,18 @@ const NS = 'dsh-key-rotation';
45
45
  const CONFIG_PATH = '/dsh-key-rotation/config';
46
46
  const STATUS_PATH = '/dsh-key-rotation/status';
47
47
  const KEY_PATH = '/dsh-key-rotation/key';
48
+ const RESET_PATH = '/dsh-key-rotation/reset';
48
49
 
49
50
  /** The llm-pi-ai namespace whose provider profiles map providers to pools. */
50
51
  const PIAI_NS = 'llm-pi-ai';
51
52
  /** Marker on internally re-dispatched requests so the interceptor does not loop. */
52
53
  const MARKER = '__dshKeyRotation';
54
+ const MAX_EVENTS = 50;
55
+ function pushEvent(pool, ref, reason, cooldownMs) {
56
+ const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs };
57
+ pool.state.events.push(ev);
58
+ if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
59
+ }
53
60
 
54
61
 
55
62
  // Fallback classification by failure message. pi-ai surfaces many real quota /
@@ -73,6 +80,7 @@ export const Config = Schema.object({
73
80
  providers: Schema.array(Schema.object({
74
81
  provider: Schema.string().required(),
75
82
  keys: Schema.array(Schema.string()).default([]),
83
+ cooldownMs: Schema.number().optional(),
76
84
  })).default([...DEFAULT_PROVIDERS]),
77
85
  });
78
86
 
@@ -256,6 +264,7 @@ export function apply(ctx, config = {}) {
256
264
  if (!state) {
257
265
  state = {
258
266
  failedUntil: new Map(),
267
+ failCounts: new Map(),
259
268
  pointer: 0,
260
269
  lastUsed: undefined,
261
270
  // Счётчики для карточки настроек: без них о работе ротации можно было
@@ -263,10 +272,14 @@ export function apply(ctx, config = {}) {
263
272
  switches: 0,
264
273
  lastReason: undefined,
265
274
  lastSwitchAt: undefined,
275
+ lastExhaustionAt: undefined,
276
+ exhaustionCount: 0,
277
+ events: [],
266
278
  };
267
279
  poolState.set(p.provider, state);
268
280
  }
269
- const pool = { base: p.provider, refs, state };
281
+ const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
282
+ const pool = { base: p.provider, refs, state, cooldownMs: poolCooldown };
270
283
  for (const ref of refs) poolByRef.set(ref, pool);
271
284
  for (let i = 1; i < refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
272
285
  }
@@ -306,12 +319,23 @@ export function apply(ctx, config = {}) {
306
319
  const candidate = pool.refs[index];
307
320
  const until = pool.state.failedUntil.get(candidate);
308
321
  if (until !== undefined && until > now) continue;
309
- const hit = await original(candidate);
322
+ let hit = await original(candidate);
310
323
  if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
311
324
  pool.state.pointer = (index + 1) % pool.refs.length;
312
325
  pool.state.lastUsed = candidate;
326
+ if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
327
+ pool.state.failedUntil.delete(candidate);
313
328
  return hit;
314
329
  }
330
+ // fallback: env var (transient, not persisted)
331
+ const envVal = envValue(candidate);
332
+ if (envVal !== undefined) {
333
+ pool.state.pointer = (index + 1) % pool.refs.length;
334
+ pool.state.lastUsed = candidate;
335
+ if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
336
+ pool.state.failedUntil.delete(candidate);
337
+ return { value: envVal, source: 'env' };
338
+ }
315
339
  }
316
340
  return original(ref); // everything cooled/missing — surface the base value
317
341
  };
@@ -339,7 +363,7 @@ export function apply(ctx, config = {}) {
339
363
  // mark the internal dispatch so the interceptor does not re-rotate
340
364
  inner = ctx.llm.stream({ ...options, [MARKER]: true });
341
365
  } catch (e) {
342
- if (pool.state.lastUsed) pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + cooldownMs);
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); }
343
367
  lastFailure = finishError(e?.code ?? 'TRANSPORT',
344
368
  `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
345
369
  console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
@@ -363,7 +387,7 @@ export function apply(ctx, config = {}) {
363
387
  const switchable = !yielded && kind === 'error' &&
364
388
  (switchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
365
389
  if (switchable) {
366
- if (pool.state.lastUsed) pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + cooldownMs);
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); }
367
391
  pool.state.switches = (pool.state.switches ?? 0) + 1;
368
392
  pool.state.lastReason = String(code ?? 'UNKNOWN');
369
393
  pool.state.lastSwitchAt = Date.now();
@@ -386,6 +410,10 @@ export function apply(ctx, config = {}) {
386
410
  return; // clean end — served
387
411
  }
388
412
 
413
+ // pool exhausted — all keys cooling or missing
414
+ pool.state.lastExhaustionAt = Date.now();
415
+ pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
416
+ console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
389
417
  yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
390
418
  })();
391
419
  }
@@ -427,11 +455,14 @@ export function apply(ctx, config = {}) {
427
455
  // The resolve patch is installed on this same service, so ask for
428
456
  // the exact ref: a pool ref would otherwise round-robin to another
429
457
  // key and report a missing name as present.
430
- const hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
458
+ let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
431
459
  present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
432
- // Only the last few characters travel to the browser: enough to
433
- // tell two keys apart in the card, useless for authenticating.
434
460
  if (present) tail = keyTail(hit.value);
461
+ // fallback: env var bootstrapping (issue #7)
462
+ if (!present) {
463
+ const ev = envValue(ref);
464
+ if (ev !== undefined) { present = true; tail = keyTail(ev); source = 'env'; writable = false; }
465
+ }
435
466
  } catch {
436
467
  present = false;
437
468
  }
@@ -459,6 +490,9 @@ export function apply(ctx, config = {}) {
459
490
  switches: pool.state.switches ?? 0,
460
491
  lastReason: pool.state.lastReason ?? null,
461
492
  lastSwitchAt: pool.state.lastSwitchAt ?? null,
493
+ lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
494
+ exhaustionCount: pool.state.exhaustionCount ?? 0,
495
+ events: (pool.state.events ?? []).slice(-20),
462
496
  });
463
497
  }
464
498
  json(res, 200, { providers });
@@ -522,6 +556,55 @@ export function apply(ctx, config = {}) {
522
556
  },
523
557
  }), 'dsh-key-rotation: key route');
524
558
 
559
+ // ── reset route: clear cooldown for a provider (or a single ref) ──
560
+ ctx.effect(() => ctx.webServer.register({
561
+ kind: 'exact',
562
+ path: RESET_PATH,
563
+ handler: async (req, res) => {
564
+ if (req.method !== 'POST') {
565
+ json(res, 405, { error: { code: 'method', message: 'POST only' } });
566
+ return;
567
+ }
568
+ if (!isTrustedBridgeRequest(req)) {
569
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: reset is local-only' } });
570
+ return;
571
+ }
572
+ let body;
573
+ try { body = await readJson(req); } catch (e) {
574
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
575
+ return;
576
+ }
577
+ const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
578
+ const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
579
+ if (provider) {
580
+ const st = poolState.get(provider);
581
+ if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
582
+ const cleared = st.failedUntil.size;
583
+ st.failedUntil.clear();
584
+ st.failCounts?.clear();
585
+ st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
586
+ json(res, 200, { ok: true, provider, cleared });
587
+ return;
588
+ }
589
+ if (ref) {
590
+ let found = false;
591
+ for (const st of poolState.values()) {
592
+ if (st.failedUntil.has(ref) || st.failCounts?.has(ref)) {
593
+ st.failedUntil.delete(ref);
594
+ st.failCounts?.delete(ref);
595
+ if (st.lastUsed === ref) st.lastUsed = undefined;
596
+ found = true; break;
597
+ }
598
+ }
599
+ // idempotent: even if ref was not cooling, report ok if it looks like a valid ref name
600
+ if (!found && !isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
601
+ json(res, 200, { ok: true, ref });
602
+ return;
603
+ }
604
+ json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: POST requires {"provider": "..."} or {"ref": "..."}' } });
605
+ },
606
+ }), 'dsh-key-rotation: reset route');
607
+
525
608
  // Intercept the llm/stream waterfall: rotate any request whose provider maps
526
609
  // to a configured key pool; pass everything else (and internal dispatches)
527
610
  // straight through.
package/lib/pool.js CHANGED
@@ -105,3 +105,37 @@ export function applyCooldown(pool, ref, cooldownMs, now = Date.now()) {
105
105
  },
106
106
  };
107
107
  }
108
+
109
+ /** Exponential backoff for a repeatedly failing key.
110
+ * failCount 1 => baseMs, 2 => baseMs*2, 3 => baseMs*4, capped at baseMs*8 (or maxMs).
111
+ * Pure and easily unit-tested. */
112
+ export function computeBackoff(baseMs, failCount, maxMs) {
113
+ const cap = maxMs ?? baseMs * 8;
114
+ if (failCount <= 1) return Math.min(baseMs, cap);
115
+ const backoff = baseMs * (1 << (failCount - 1)); // 2^(n-1)
116
+ return Math.min(backoff, cap);
117
+ }
118
+
119
+ /** Record a failure for `ref` in `pool.state`, applying exponential backoff.
120
+ * Mutates pool.state.failedUntil and pool.state.failCounts. Returns the backoff used. */
121
+ export function recordFailure(pool, ref, now, baseMs, maxMs) {
122
+ if (!pool.state.failCounts) pool.state.failCounts = new Map();
123
+ const prev = pool.state.failCounts.get(ref) ?? 0;
124
+ const next = prev + 1;
125
+ pool.state.failCounts.set(ref, next);
126
+ const backoff = computeBackoff(baseMs, next, maxMs);
127
+ pool.state.failedUntil.set(ref, now + backoff);
128
+ return backoff;
129
+ }
130
+
131
+ /** Record a success for `ref` — clears its cooldown and resets its fail count. */
132
+ export function recordSuccess(pool, ref) {
133
+ if (pool.state.failCounts) pool.state.failCounts.delete(ref);
134
+ pool.state.failedUntil.delete(ref);
135
+ }
136
+
137
+ /** Return env value for ref if present in process.env, else undefined. */
138
+ export function envValue(ref) {
139
+ const v = typeof process !== 'undefined' ? process.env?.[ref] : undefined;
140
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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",