@goodandready/dsh-key-rotation 0.7.0 → 0.7.2

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,7 +60,13 @@ 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
+ brokenKey: 'broken (3× AUTH)',
64
+ exportPools: 'Export',
65
+ importPools: 'Import',
63
66
  resetCooldown: 'Reset cooldown',
67
+ testKey: 'Test',
68
+ testOk: 'OK',
69
+ testFail: 'FAIL',
64
70
  poolExhausted: 'pool exhausted — all keys cooling',
65
71
  resetting: 'Resetting…',
66
72
  keyLabel: 'Key {n}',
@@ -100,7 +106,13 @@ window.__ModuleLoader__.load({
100
106
  keyFromEnv: 'задан в окружении, отсюда не меняется',
101
107
  keyWriteFailed: 'не удалось сохранить ключ: {msg}',
102
108
  keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
109
+ brokenKey: 'сломан (3× AUTH)',
110
+ exportPools: 'Экспорт',
111
+ importPools: 'Импорт',
103
112
  resetCooldown: 'Сбросить кулдаун',
113
+ testKey: 'Тест',
114
+ testOk: 'OK',
115
+ testFail: 'FAIL',
104
116
  poolExhausted: 'пул исчерпан — все ключи остывают',
105
117
  resetting: 'Сброс…',
106
118
  keyLabel: 'Ключ {n}',
@@ -266,6 +278,16 @@ window.__ModuleLoader__.load({
266
278
  .catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))))
267
279
  .finally(() => setResetting(''));
268
280
  };
281
+ const [testing, setTesting] = React.useState('');
282
+ const [testResult, setTestResult] = React.useState({});
283
+ const doTest = (ref) => {
284
+ setTesting(ref); setTestResult((m) => ({ ...m, [ref]: null }));
285
+ fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) })
286
+ .then((r) => r.json())
287
+ .then((data) => setTestResult((m) => ({ ...m, [ref]: data })))
288
+ .catch((e) => setTestResult((m) => ({ ...m, [ref]: { ok: false, message: String(e?.message ?? e) } })))
289
+ .finally(() => setTesting(''));
290
+ };
269
291
 
270
292
  const keyInfo = (providerId, ref) => {
271
293
  const entryStatus = status[providerId];
@@ -301,7 +323,7 @@ window.__ModuleLoader__.load({
301
323
  const providerById = new Map(providers.map((p) => [p.id, p.name]));
302
324
 
303
325
  const setField = (fn) => setDraft(fn(val));
304
- const providerList = Array.isArray(val.providers) ? val.providers : [];
326
+ const providerList = Array.isArray(val.providers) ? val.providers.filter((p) => Array.isArray(p.keys) && p.keys.length > 0) : [];
305
327
 
306
328
  const setProvider = (index, id) => setField((cur) => {
307
329
  const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
@@ -400,6 +422,7 @@ window.__ModuleLoader__.load({
400
422
  const keyStatus = (providerId, ref) => {
401
423
  const hit = keyInfo(providerId, ref);
402
424
  if (!hit) return null;
425
+ if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
403
426
  if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
404
427
  if (hit.cooldownMsLeft > 0) {
405
428
  return {
@@ -453,6 +476,11 @@ window.__ModuleLoader__.load({
453
476
  }));
454
477
  if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'w' }));
455
478
  }
479
+ if (info && typeof info.usage === 'number' && info.usage > 0) meta.push(h('span', { key: 'u', className: 'krot-tail', title: 'requests through this key' }, String(info.usage)));
480
+ if (info && typeof info.cost === 'number' && info.cost > 0) meta.push(h('span', { key: 'c', className: 'krot-tail', title: 'cost' }, '$' + info.cost.toFixed(2)));
481
+ const tr = testResult[key];
482
+ if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail', title: tr.message || (tr.ok ? t('testOk') : t('testFail')) }, tr.ok ? '✓' : '✕'));
483
+ meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
456
484
  meta.push(h('span', { key: 'a', className: 'krot-acts' },
457
485
  btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
458
486
  btn('↓', () => moveKey(pIndex, kIndex, 1), { disabled: kIndex === keys.length - 1, title: t('moveDown') }),
@@ -487,6 +515,7 @@ window.__ModuleLoader__.load({
487
515
  btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
488
516
  switchesLine,
489
517
  exhaustionWarning,
518
+ (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '24px', marginTop: '4px' } }, (() => { const now = Date.now(); const buckets = Array(24).fill(0); for (const ev of providerStatus.events) { const h = Math.floor((now - ev.at) / 3600000); if (h >= 0 && h < 24) buckets[23 - h]++; } const max = Math.max(1, ...buckets); return buckets.map((c, i) => h('div', { key: i, title: c + ' switches', style: { flex: 1, background: c ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-border-l2)', height: (c / max * 24) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
490
519
  (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
520
  btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
492
521
  ),
@@ -512,6 +541,22 @@ window.__ModuleLoader__.load({
512
541
  providerRows,
513
542
  h('div', { className: 'krot-foot' }, btn(t('addProvider'), addProvider, {}), noProviders),
514
543
  )),
544
+ h('div', { className: 'krot-foot' }, btn(t('exportPools'), () => {
545
+ const data = JSON.stringify(val.providers ?? [], null, 2);
546
+ const blob = new Blob([data], { type: 'application/json' });
547
+ const url = URL.createObjectURL(blob);
548
+ const a = document.createElement('a'); a.href = url; a.download = 'pools.json'; a.click(); URL.revokeObjectURL(url);
549
+ }, {}), h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('importPools'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
550
+ const f = e.target.files[0]; if (!f) return;
551
+ const reader = new FileReader();
552
+ reader.onload = () => { try { const imp = JSON.parse(String(reader.result)); if (!Array.isArray(imp)) throw new Error('expected array'); setField((cur) => {
553
+ const curProviders = Array.isArray(cur.providers) ? [...cur.providers] : [];
554
+ const map = new Map(curProviders.map((p) => [p.provider, p]));
555
+ for (const p of imp) { if (p && typeof p.provider === 'string') map.set(p.provider, p); }
556
+ return { ...cur, providers: [...map.values()] };
557
+ }); } catch (err) { setSecretError(String(err.message || err)); } };
558
+ reader.readAsText(f);
559
+ } }))),
515
560
  h('p', { className: 'krot-hint' }, t('keyHint')),
516
561
  secretError ? h('p', { className: 'krot-err' }, secretError) : null,
517
562
  state.error ? h('p', { className: 'krot-err' }, state.error) : null,
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, sweepExpired } from './pool.js';
36
+ import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter } from './pool.js';
37
37
 
38
38
  export const name = 'dsh-key-rotation';
39
39
  export const inject = ['llm', 'webServer', 'settings', 'credentials'];
@@ -46,6 +46,7 @@ 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
48
  const RESET_PATH = '/dsh-key-rotation/reset';
49
+ const TEST_PATH = '/dsh-key-rotation/test';
49
50
 
50
51
  /** The llm-pi-ai namespace whose provider profiles map providers to pools. */
51
52
  const PIAI_NS = 'llm-pi-ai';
@@ -78,9 +79,12 @@ export const Config = Schema.object({
78
79
  switchCodes: Schema.array(Schema.string()).default([...DEFAULT_SWITCH_CODES]),
79
80
  cooldownMs: Schema.number().default(60000),
80
81
  maxCooldownMs: Schema.number(),
82
+ notifyWebhook: Schema.string().default(''),
83
+ notifyThreshold: Schema.number().default(3),
81
84
  providers: Schema.array(Schema.object({
82
85
  provider: Schema.string().required(),
83
86
  keys: Schema.array(Schema.string()).default([]),
87
+ weights: Schema.array(Schema.number()).default([]),
84
88
  cooldownMs: Schema.number(),
85
89
  maxCooldownMs: Schema.number(),
86
90
  })).default([...DEFAULT_PROVIDERS]),
@@ -260,6 +264,8 @@ export function apply(ctx, config = {}) {
260
264
  const switchCodes = new Set(cfg.switchCodes ?? DEFAULT_SWITCH_CODES);
261
265
  const cooldownMs = cfg.cooldownMs ?? 60000;
262
266
  const maxCooldownMs = cfg.maxCooldownMs ?? undefined;
267
+ const notifyWebhook = cfg.notifyWebhook ?? '';
268
+ const notifyThreshold = cfg.notifyThreshold ?? 3;
263
269
 
264
270
  // ref -> pool (every key env of every configured provider)
265
271
  const poolByRef = new Map();
@@ -286,12 +292,19 @@ export function apply(ctx, config = {}) {
286
292
  lastExhaustionAt: undefined,
287
293
  exhaustionCount: 0,
288
294
  events: [],
295
+ usageCounts: new Map(),
289
296
  };
290
297
  poolState.set(p.provider, state);
291
298
  }
292
299
  const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
293
300
  const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
294
- const pool = { base: p.provider, refs, state, cooldownMs: poolCooldown, maxCooldownMs: poolMax };
301
+ const weights = Array.isArray(p.weights) ? p.weights : [];
302
+ const weightedRefs = [];
303
+ for (let i = 0; i < refs.length; i++) {
304
+ const w = typeof weights[i] === 'number' && weights[i] > 0 ? Math.floor(weights[i]) : 1;
305
+ for (let k = 0; k < w; k++) weightedRefs.push(refs[i]);
306
+ }
307
+ const pool = { base: p.provider, refs, weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs, state, cooldownMs: poolCooldown, maxCooldownMs: poolMax };
295
308
  for (const ref of refs) poolByRef.set(ref, pool);
296
309
  for (let i = 1; i < refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
297
310
  }
@@ -308,7 +321,11 @@ export function apply(ctx, config = {}) {
308
321
  }
309
322
  }
310
323
 
311
- return { switchCodes, cooldownMs, maxCooldownMs, poolByRef, providerToPool, cloneIds };
324
+ // auto-cleanup: remove poolState for providers that are now empty or removed
325
+ for (const key of [...poolState.keys()]) {
326
+ if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
327
+ }
328
+ return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, poolByRef, providerToPool, cloneIds };
312
329
  }
313
330
 
314
331
  // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
@@ -325,27 +342,61 @@ export function apply(ctx, config = {}) {
325
342
  const pool = poolByRef.get(ref);
326
343
  if (!pool) return original(ref);
327
344
  const now = Date.now();
345
+ const list = pool.weightedRefs ?? pool.refs;
328
346
  const start = pool.state.pointer ?? 0;
329
- for (let i = 0; i < pool.refs.length; i++) {
330
- const index = (start + i) % pool.refs.length;
331
- const candidate = pool.refs[index];
347
+ for (let i = 0; i < list.length; i++) {
348
+ const index = (start + i) % list.length;
349
+ const candidate = list[index];
332
350
  const until = pool.state.failedUntil.get(candidate);
333
351
  if (until !== undefined && until > now) continue;
352
+ // perHour quota check
353
+ if (pool.perHour) {
354
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
355
+ let win = pool.state.quotaWindows.get(candidate);
356
+ if (!win || now - win.start >= 3600000) win = { count: 0, start: now };
357
+ if (win.count >= pool.perHour) {
358
+ const until = win.start + 3600000;
359
+ if ((pool.state.failedUntil.get(candidate) ?? 0) < until) pool.state.failedUntil.set(candidate, until);
360
+ continue;
361
+ }
362
+ }
334
363
  let hit = await original(candidate);
335
364
  if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
336
- pool.state.pointer = (index + 1) % pool.refs.length;
365
+ pool.state.pointer = (index + 1) % list.length;
337
366
  pool.state.lastUsed = candidate;
338
367
  if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
339
368
  pool.state.failedUntil.delete(candidate);
369
+ if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
370
+ if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
371
+ if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
372
+ pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
373
+ if (pool.perHour) {
374
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
375
+ let win2 = pool.state.quotaWindows.get(candidate);
376
+ if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
377
+ win2.count++;
378
+ pool.state.quotaWindows.set(candidate, win2);
379
+ }
340
380
  return hit;
341
381
  }
342
382
  // fallback: env var (transient, not persisted)
343
383
  const envVal = envValue(candidate);
344
384
  if (envVal !== undefined) {
345
- pool.state.pointer = (index + 1) % pool.refs.length;
385
+ pool.state.pointer = (index + 1) % list.length;
346
386
  pool.state.lastUsed = candidate;
347
387
  if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
348
388
  pool.state.failedUntil.delete(candidate);
389
+ if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
390
+ if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
391
+ if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
392
+ pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
393
+ if (pool.perHour) {
394
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
395
+ let win2 = pool.state.quotaWindows.get(candidate);
396
+ if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
397
+ win2.count++;
398
+ pool.state.quotaWindows.set(candidate, win2);
399
+ }
349
400
  return { value: envVal, source: 'env' };
350
401
  }
351
402
  }
@@ -367,7 +418,7 @@ export function apply(ctx, config = {}) {
367
418
  const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
368
419
  let lastFailure = null;
369
420
 
370
- for (let attempt = 0; attempt < pool.refs.length; attempt++) {
421
+ for (let attempt = 0; attempt < (pool.weightedRefs ?? pool.refs).length; attempt++) {
371
422
  let yielded = false;
372
423
  let switching = false;
373
424
  let inner;
@@ -375,7 +426,7 @@ export function apply(ctx, config = {}) {
375
426
  // mark the internal dispatch so the interceptor does not re-rotate
376
427
  inner = ctx.llm.stream({ ...options, [MARKER]: true });
377
428
  } catch (e) {
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); }
429
+ if (pool.state.lastUsed) { const _retry = parseRetryAfter(String(e?.message ?? '')); const _base = pool.cooldownMs ?? cooldownMs; const _max = pool.maxCooldownMs ?? maxCooldownMs; const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base; const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max); pushEvent(pool, pool.state.lastUsed, e?.code ?? 'TRANSPORT', _b); const _code = String(e?.code ?? ''); if (_code === 'AUTH' || /auth/i.test(String(e?.message ?? ''))) { const _c = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1; pool.state.authFailCounts.set(pool.state.lastUsed, _c); if (_c >= 3) { pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); } } else { pool.state.authFailCounts.delete(pool.state.lastUsed); } }
379
430
  lastFailure = finishError(e?.code ?? 'TRANSPORT',
380
431
  `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
381
432
  console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
@@ -396,10 +447,11 @@ export function apply(ctx, config = {}) {
396
447
  const failure = chunk.reason?.failure;
397
448
  const code = failure?.code;
398
449
  const message = failure?.message ?? '';
450
+ const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
399
451
  const switchable = !yielded && kind === 'error' &&
400
- (switchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
452
+ (effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
401
453
  if (switchable) {
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); }
454
+ if (pool.state.lastUsed) { const _retry = parseRetryAfter(message); const _base = pool.cooldownMs ?? cooldownMs; const _max = pool.maxCooldownMs ?? maxCooldownMs; const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base; const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max); pushEvent(pool, pool.state.lastUsed, code ?? 'UNKNOWN', _b); const _code2 = String(code ?? ''); if (_code2 === 'AUTH' || /auth/i.test(message)) { const _c2 = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1; pool.state.authFailCounts.set(pool.state.lastUsed, _c2); if (_c2 >= 3) { pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); } } else { pool.state.authFailCounts.delete(pool.state.lastUsed); } }
403
455
  pool.state.switches = (pool.state.switches ?? 0) + 1;
404
456
  pool.state.lastReason = String(code ?? 'UNKNOWN');
405
457
  pool.state.lastSwitchAt = Date.now();
@@ -408,6 +460,11 @@ export function apply(ctx, config = {}) {
408
460
  switching = true;
409
461
  break;
410
462
  }
463
+ // cost tracking if provider returns usage.cost
464
+ if (chunk.usage?.cost != null && pool.state.lastUsed) {
465
+ const c = Number(chunk.usage.cost);
466
+ if (!isNaN(c)) pool.state.costPerKey.set(pool.state.lastUsed, (pool.state.costPerKey.get(pool.state.lastUsed) ?? 0) + c);
467
+ }
411
468
  yield chunk;
412
469
  return;
413
470
  }
@@ -426,6 +483,15 @@ export function apply(ctx, config = {}) {
426
483
  pool.state.lastExhaustionAt = Date.now();
427
484
  pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
428
485
  console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
486
+ // notify webhook if configured and threshold reached
487
+ try {
488
+ const { notifyWebhook, notifyThreshold } = buildRuntime();
489
+ if (notifyWebhook && pool.state.exhaustionCount >= notifyThreshold) {
490
+ const payload = JSON.stringify({ provider: options.provider, exhaustionCount: pool.state.exhaustionCount, at: pool.state.lastExhaustionAt, keys: pool.refs });
491
+ fetch(notifyWebhook, { method: 'POST', headers: { 'content-type': 'application/json' }, body: payload }).catch(()=>{});
492
+ }
493
+ } catch {}
494
+
429
495
  yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
430
496
  })();
431
497
  }
@@ -494,6 +560,9 @@ export function apply(ctx, config = {}) {
494
560
  writable,
495
561
  active: pool.state.lastUsed === ref,
496
562
  cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
563
+ usage: pool.state.usageCounts?.get(ref) ?? 0,
564
+ cost: pool.state.costPerKey?.get(ref) ?? 0,
565
+ broken: pool.state.brokenUntil?.has(ref) ?? false,
497
566
  });
498
567
  }
499
568
  providers.push({
@@ -504,7 +573,7 @@ export function apply(ctx, config = {}) {
504
573
  lastSwitchAt: pool.state.lastSwitchAt ?? null,
505
574
  lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
506
575
  exhaustionCount: pool.state.exhaustionCount ?? 0,
507
- events: (pool.state.events ?? []).slice(-20),
576
+ events: (pool.state.events ?? []).slice(-50),
508
577
  });
509
578
  }
510
579
  json(res, 200, { providers });
@@ -594,6 +663,8 @@ export function apply(ctx, config = {}) {
594
663
  const cleared = st.failedUntil.size;
595
664
  st.failedUntil.clear();
596
665
  st.failCounts?.clear();
666
+ st.authFailCounts?.clear();
667
+ st.brokenUntil?.clear();
597
668
  st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
598
669
  json(res, 200, { ok: true, provider, cleared });
599
670
  return;
@@ -604,6 +675,8 @@ export function apply(ctx, config = {}) {
604
675
  if (st.failedUntil.has(ref) || st.failCounts?.has(ref)) {
605
676
  st.failedUntil.delete(ref);
606
677
  st.failCounts?.delete(ref);
678
+ st.authFailCounts?.delete(ref);
679
+ st.brokenUntil?.delete(ref);
607
680
  if (st.lastUsed === ref) st.lastUsed = undefined;
608
681
  found = true; break;
609
682
  }
@@ -617,6 +690,35 @@ export function apply(ctx, config = {}) {
617
690
  },
618
691
  }), 'dsh-key-rotation: reset route');
619
692
 
693
+ // ── test route: dry-run a single key without rotation ──
694
+ ctx.effect(() => ctx.webServer.register({
695
+ kind: 'exact',
696
+ path: TEST_PATH,
697
+ handler: async (req, res) => {
698
+ if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
699
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test is local-only' } }); return; }
700
+ let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
701
+ const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
702
+ if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
703
+ const base = ctx.get('credentials');
704
+ try {
705
+ const hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
706
+ const present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
707
+ if (!present) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
708
+ const tail = keyTail(hit.value);
709
+ // Check env source
710
+ let source = null; try { const d = await base?.describe?.(ref); source = d?.source ?? null; } catch {}
711
+ if (!present) {
712
+ const ev = envValue(ref);
713
+ if (ev !== undefined) json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' });
714
+ }
715
+ json(res, 200, { ok: true, ref, tail, source });
716
+ } catch (e) {
717
+ json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
718
+ }
719
+ },
720
+ }), 'dsh-key-rotation: test route');
721
+
620
722
  // Intercept the llm/stream waterfall: rotate any request whose provider maps
621
723
  // to a configured key pool; pass everything else (and internal dispatches)
622
724
  // straight through.
@@ -625,7 +727,7 @@ export function apply(ctx, config = {}) {
625
727
  const { providerToPool } = buildRuntime();
626
728
  const pool = providerToPool.get(options.provider);
627
729
  if (!pool) return next();
628
- console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${pool.refs.length} keys`);
730
+ console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${(pool.weightedRefs ?? pool.refs).length} slots (${pool.refs.length} keys)`);
629
731
  return rotate(options, pool);
630
732
  });
631
733
 
@@ -641,7 +743,8 @@ export function apply(ctx, config = {}) {
641
743
  if (!pool) return next();
642
744
  const code = String(payload?.failure?.code ?? payload?.code ?? '');
643
745
  const message = String(payload?.failure?.message ?? payload?.message ?? '');
644
- const switchable = switchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message);
746
+ const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
747
+ const switchable = effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message);
645
748
  if (!switchable) return next();
646
749
  const ref = pool.state.lastUsed;
647
750
  if (ref) {
package/lib/pool.js CHANGED
@@ -154,3 +154,25 @@ export function sweepExpired(poolState, now = Date.now()) {
154
154
  }
155
155
  return cleared;
156
156
  }
157
+
158
+ /** Parse Retry-After value from a header string or message.
159
+ * Supports seconds ("60", "Retry-After: 60") and HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT").
160
+ * Returns milliseconds or undefined if not parseable. */
161
+ export function parseRetryAfter(value) {
162
+ if (typeof value !== 'string' || !value) return undefined;
163
+ // Try to extract "Retry-After: <val>" from a larger message
164
+ const m = value.match(/retry-after\s*[:=]\s*(.+)/i);
165
+ const raw = m ? m[1].trim().split(/[\n\r;]/)[0].trim() : value.trim();
166
+ // Seconds
167
+ if (/^\d+$/.test(raw)) {
168
+ const sec = Number(raw);
169
+ if (sec >= 0 && sec <= 86400 * 7) return sec * 1000;
170
+ }
171
+ // HTTP-date
172
+ const ts = Date.parse(raw);
173
+ if (!Number.isNaN(ts)) {
174
+ const diff = ts - Date.now();
175
+ if (diff > 0 && diff < 86400 * 7 * 1000) return diff;
176
+ }
177
+ return undefined;
178
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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",