@goodandready/dsh-key-rotation 0.7.0 → 0.7.1

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,11 @@ 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)',
63
64
  resetCooldown: 'Reset cooldown',
65
+ testKey: 'Test',
66
+ testOk: 'OK',
67
+ testFail: 'FAIL',
64
68
  poolExhausted: 'pool exhausted — all keys cooling',
65
69
  resetting: 'Resetting…',
66
70
  keyLabel: 'Key {n}',
@@ -100,7 +104,11 @@ window.__ModuleLoader__.load({
100
104
  keyFromEnv: 'задан в окружении, отсюда не меняется',
101
105
  keyWriteFailed: 'не удалось сохранить ключ: {msg}',
102
106
  keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
107
+ brokenKey: 'сломан (3× AUTH)',
103
108
  resetCooldown: 'Сбросить кулдаун',
109
+ testKey: 'Тест',
110
+ testOk: 'OK',
111
+ testFail: 'FAIL',
104
112
  poolExhausted: 'пул исчерпан — все ключи остывают',
105
113
  resetting: 'Сброс…',
106
114
  keyLabel: 'Ключ {n}',
@@ -266,6 +274,16 @@ window.__ModuleLoader__.load({
266
274
  .catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))))
267
275
  .finally(() => setResetting(''));
268
276
  };
277
+ const [testing, setTesting] = React.useState('');
278
+ const [testResult, setTestResult] = React.useState({});
279
+ const doTest = (ref) => {
280
+ setTesting(ref); setTestResult((m) => ({ ...m, [ref]: null }));
281
+ fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) })
282
+ .then((r) => r.json())
283
+ .then((data) => setTestResult((m) => ({ ...m, [ref]: data })))
284
+ .catch((e) => setTestResult((m) => ({ ...m, [ref]: { ok: false, message: String(e?.message ?? e) } })))
285
+ .finally(() => setTesting(''));
286
+ };
269
287
 
270
288
  const keyInfo = (providerId, ref) => {
271
289
  const entryStatus = status[providerId];
@@ -400,6 +418,7 @@ window.__ModuleLoader__.load({
400
418
  const keyStatus = (providerId, ref) => {
401
419
  const hit = keyInfo(providerId, ref);
402
420
  if (!hit) return null;
421
+ if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
403
422
  if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
404
423
  if (hit.cooldownMsLeft > 0) {
405
424
  return {
@@ -453,6 +472,10 @@ window.__ModuleLoader__.load({
453
472
  }));
454
473
  if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'w' }));
455
474
  }
475
+ 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)));
476
+ const tr = testResult[key];
477
+ if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail', title: tr.message || (tr.ok ? t('testOk') : t('testFail')) }, tr.ok ? '✓' : '✕'));
478
+ meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
456
479
  meta.push(h('span', { key: 'a', className: 'krot-acts' },
457
480
  btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
458
481
  btn('↓', () => moveKey(pIndex, kIndex, 1), { disabled: kIndex === keys.length - 1, title: t('moveDown') }),
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,7 @@ export function apply(ctx, config = {}) {
308
321
  }
309
322
  }
310
323
 
311
- return { switchCodes, cooldownMs, maxCooldownMs, poolByRef, providerToPool, cloneIds };
324
+ return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, poolByRef, providerToPool, cloneIds };
312
325
  }
313
326
 
314
327
  // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
@@ -325,27 +338,61 @@ export function apply(ctx, config = {}) {
325
338
  const pool = poolByRef.get(ref);
326
339
  if (!pool) return original(ref);
327
340
  const now = Date.now();
341
+ const list = pool.weightedRefs ?? pool.refs;
328
342
  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];
343
+ for (let i = 0; i < list.length; i++) {
344
+ const index = (start + i) % list.length;
345
+ const candidate = list[index];
332
346
  const until = pool.state.failedUntil.get(candidate);
333
347
  if (until !== undefined && until > now) continue;
348
+ // perHour quota check
349
+ if (pool.perHour) {
350
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
351
+ let win = pool.state.quotaWindows.get(candidate);
352
+ if (!win || now - win.start >= 3600000) win = { count: 0, start: now };
353
+ if (win.count >= pool.perHour) {
354
+ const until = win.start + 3600000;
355
+ if ((pool.state.failedUntil.get(candidate) ?? 0) < until) pool.state.failedUntil.set(candidate, until);
356
+ continue;
357
+ }
358
+ }
334
359
  let hit = await original(candidate);
335
360
  if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
336
- pool.state.pointer = (index + 1) % pool.refs.length;
361
+ pool.state.pointer = (index + 1) % list.length;
337
362
  pool.state.lastUsed = candidate;
338
363
  if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
339
364
  pool.state.failedUntil.delete(candidate);
365
+ if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
366
+ if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
367
+ if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
368
+ pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
369
+ if (pool.perHour) {
370
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
371
+ let win2 = pool.state.quotaWindows.get(candidate);
372
+ if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
373
+ win2.count++;
374
+ pool.state.quotaWindows.set(candidate, win2);
375
+ }
340
376
  return hit;
341
377
  }
342
378
  // fallback: env var (transient, not persisted)
343
379
  const envVal = envValue(candidate);
344
380
  if (envVal !== undefined) {
345
- pool.state.pointer = (index + 1) % pool.refs.length;
381
+ pool.state.pointer = (index + 1) % list.length;
346
382
  pool.state.lastUsed = candidate;
347
383
  if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
348
384
  pool.state.failedUntil.delete(candidate);
385
+ if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
386
+ if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
387
+ if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
388
+ pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
389
+ if (pool.perHour) {
390
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
391
+ let win2 = pool.state.quotaWindows.get(candidate);
392
+ if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
393
+ win2.count++;
394
+ pool.state.quotaWindows.set(candidate, win2);
395
+ }
349
396
  return { value: envVal, source: 'env' };
350
397
  }
351
398
  }
@@ -367,7 +414,7 @@ export function apply(ctx, config = {}) {
367
414
  const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
368
415
  let lastFailure = null;
369
416
 
370
- for (let attempt = 0; attempt < pool.refs.length; attempt++) {
417
+ for (let attempt = 0; attempt < (pool.weightedRefs ?? pool.refs).length; attempt++) {
371
418
  let yielded = false;
372
419
  let switching = false;
373
420
  let inner;
@@ -375,7 +422,7 @@ export function apply(ctx, config = {}) {
375
422
  // mark the internal dispatch so the interceptor does not re-rotate
376
423
  inner = ctx.llm.stream({ ...options, [MARKER]: true });
377
424
  } 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); }
425
+ 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
426
  lastFailure = finishError(e?.code ?? 'TRANSPORT',
380
427
  `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
381
428
  console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
@@ -399,7 +446,7 @@ export function apply(ctx, config = {}) {
399
446
  const switchable = !yielded && kind === 'error' &&
400
447
  (switchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
401
448
  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); }
449
+ 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
450
  pool.state.switches = (pool.state.switches ?? 0) + 1;
404
451
  pool.state.lastReason = String(code ?? 'UNKNOWN');
405
452
  pool.state.lastSwitchAt = Date.now();
@@ -426,6 +473,15 @@ export function apply(ctx, config = {}) {
426
473
  pool.state.lastExhaustionAt = Date.now();
427
474
  pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
428
475
  console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
476
+ // notify webhook if configured and threshold reached
477
+ try {
478
+ const { notifyWebhook, notifyThreshold } = buildRuntime();
479
+ if (notifyWebhook && pool.state.exhaustionCount >= notifyThreshold) {
480
+ const payload = JSON.stringify({ provider: options.provider, exhaustionCount: pool.state.exhaustionCount, at: pool.state.lastExhaustionAt, keys: pool.refs });
481
+ fetch(notifyWebhook, { method: 'POST', headers: { 'content-type': 'application/json' }, body: payload }).catch(()=>{});
482
+ }
483
+ } catch {}
484
+
429
485
  yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
430
486
  })();
431
487
  }
@@ -494,6 +550,8 @@ export function apply(ctx, config = {}) {
494
550
  writable,
495
551
  active: pool.state.lastUsed === ref,
496
552
  cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
553
+ usage: pool.state.usageCounts?.get(ref) ?? 0,
554
+ broken: pool.state.brokenUntil?.has(ref) ?? false,
497
555
  });
498
556
  }
499
557
  providers.push({
@@ -594,6 +652,8 @@ export function apply(ctx, config = {}) {
594
652
  const cleared = st.failedUntil.size;
595
653
  st.failedUntil.clear();
596
654
  st.failCounts?.clear();
655
+ st.authFailCounts?.clear();
656
+ st.brokenUntil?.clear();
597
657
  st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
598
658
  json(res, 200, { ok: true, provider, cleared });
599
659
  return;
@@ -604,6 +664,8 @@ export function apply(ctx, config = {}) {
604
664
  if (st.failedUntil.has(ref) || st.failCounts?.has(ref)) {
605
665
  st.failedUntil.delete(ref);
606
666
  st.failCounts?.delete(ref);
667
+ st.authFailCounts?.delete(ref);
668
+ st.brokenUntil?.delete(ref);
607
669
  if (st.lastUsed === ref) st.lastUsed = undefined;
608
670
  found = true; break;
609
671
  }
@@ -617,6 +679,35 @@ export function apply(ctx, config = {}) {
617
679
  },
618
680
  }), 'dsh-key-rotation: reset route');
619
681
 
682
+ // ── test route: dry-run a single key without rotation ──
683
+ ctx.effect(() => ctx.webServer.register({
684
+ kind: 'exact',
685
+ path: TEST_PATH,
686
+ handler: async (req, res) => {
687
+ if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
688
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test is local-only' } }); return; }
689
+ let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
690
+ const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
691
+ if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
692
+ const base = ctx.get('credentials');
693
+ try {
694
+ const hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
695
+ const present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
696
+ if (!present) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
697
+ const tail = keyTail(hit.value);
698
+ // Check env source
699
+ let source = null; try { const d = await base?.describe?.(ref); source = d?.source ?? null; } catch {}
700
+ if (!present) {
701
+ const ev = envValue(ref);
702
+ if (ev !== undefined) json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' });
703
+ }
704
+ json(res, 200, { ok: true, ref, tail, source });
705
+ } catch (e) {
706
+ json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
707
+ }
708
+ },
709
+ }), 'dsh-key-rotation: test route');
710
+
620
711
  // Intercept the llm/stream waterfall: rotate any request whose provider maps
621
712
  // to a configured key pool; pass everything else (and internal dispatches)
622
713
  // straight through.
@@ -625,7 +716,7 @@ export function apply(ctx, config = {}) {
625
716
  const { providerToPool } = buildRuntime();
626
717
  const pool = providerToPool.get(options.provider);
627
718
  if (!pool) return next();
628
- console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${pool.refs.length} keys`);
719
+ console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${(pool.weightedRefs ?? pool.refs).length} slots (${pool.refs.length} keys)`);
629
720
  return rotate(options, pool);
630
721
  });
631
722
 
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.1",
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",