@goodandready/dsh-key-rotation 0.8.50 → 0.8.51

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.51 - 2026-10-06
4
+
5
+ ### Fixed
6
+ - **Strict switchCodes Filtering & Unbounded Retry Prevention (#453, GitHub PR #21)**:
7
+ - Add `SOCKET_TRANSPORT_CODES` and `RECOGNIZED_SWITCH_CODES` in `lib/pool.js` to ensure explicit error codes are not overridden by regex text heuristics when users customize `switchCodes`.
8
+ - In `agent/request-error` hook (`lib/index.js`), enforce strict matching against `effectiveSwitchCodes` and only return `{ kind: 'retry' }` when `pickNext(pool, now)` yields an alternative healthy key; falls through to `next()` when no healthy candidate is available, eliminating infinite retry hangs on single-key and cooling pools.
9
+ - Credit: Jan Eickholt (@JanEickholt).
10
+ - **Canonical CSS Theme Tokens & Clean Client Comments (#450)**:
11
+ - Replaced deprecated/unrecognized theme tokens with standard DSH variables (`--dsw-alias-brand-primary`, `--dsw-alias-state-warn-primary`) across `lib/client-src/`.
12
+ - Replaced issue comment references (`// #<id>` -> `// (issue <id>)`) to avoid false positives in automated hex color audits.
13
+ - **Client Fetch Timeout Signals (#451)**:
14
+ - Added defensive `signal: AbortSignal.timeout(ms)` to all 9 previously unguarded client fetch calls (`/reset`, `/test`, `/key`, `/snapshot`, `/status`, `/sandbox-cache`, `/health`), preventing unhandled client-side request hangs on unresponsive networks.
15
+ - Rebuilt production bundle `lib/client.js`.
16
+ - **Wired Production Exports (#452)**:
17
+ - Connected unused exported utilities across production paths: `getModelTokenUsage` and `getModelTokenRemaining` in `lib/model-quota.js`, `modelPoolsForRef` in `lib/ops-status.js`, `isSoftFailureCode` and `SOFT_FAILURE_CODES` in `lib/error-taxonomy.js`, `QUOTA_WINDOW_TYPES` in `lib/index.js`, and `poolResetAt` in `lib/rotate.js`.
18
+
3
19
  ## 0.8.50 - 2026-10-05
4
20
 
5
21
  ### Fixed
package/lib/client.js CHANGED
@@ -166,7 +166,7 @@ window.__ModuleLoader__.load({
166
166
  undoProvider: 'Provider removed',
167
167
  undoKey: 'Key removed',
168
168
  undo: 'Undo',
169
- // #289 a11y / #290 states / #291 bulk / #292 locale
169
+ // (issue 289) a11y / issue 290 states / issue 291 bulk / issue 292 locale
170
170
  emptyTitle: 'No pools yet',
171
171
  emptyDesc: 'Add a provider and at least one API key name to start rotating.',
172
172
  errorTitle: 'Could not load settings',
@@ -412,7 +412,7 @@ window.__ModuleLoader__.load({
412
412
  // not dropped on first save.
413
413
  const KNOWN_CODES = ['QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH'];
414
414
 
415
- // #273: keep the settings list item mounted if the section throws.
415
+ // (issue 273): keep the settings list item mounted if the section throws.
416
416
  const bestEffort = (label, fn) => {
417
417
  try { return fn(); } catch (err) {
418
418
  try { console.debug('[dsh-key-rotation] best-effort ' + label, err); } catch { /* console unavailable */ }
@@ -452,7 +452,7 @@ window.__ModuleLoader__.load({
452
452
  let alive = true;
453
453
  const pull = () => {
454
454
  if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
455
- fetch('/dsh-key-rotation/status', { headers: { accept: 'application/json' } })
455
+ fetch('/dsh-key-rotation/status', { headers: { accept: 'application/json' }, signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
456
456
  .then((r) => (r.ok ? r.json() : null))
457
457
  .then((data) => {
458
458
  if (!alive || !data || !Array.isArray(data.providers)) return;
@@ -482,7 +482,7 @@ window.__ModuleLoader__.load({
482
482
  let alive = true;
483
483
  const pull = () => {
484
484
  if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
485
- fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' } })
485
+ fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' }, signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
486
486
  .then((r) => (r.ok ? r.json() : null))
487
487
  .then((data) => { if (alive && data) setCache(data); })
488
488
  .catch(() => { /* cache is non-critical: card works without it */ });
@@ -523,7 +523,7 @@ window.__ModuleLoader__.load({
523
523
  '.krot-label{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}',
524
524
  '.krot-field{display:flex;flex-direction:column;gap:6px}',
525
525
  '.krot-in{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;height:36px;font-family:inherit;min-width:0;width:100%;box-sizing:border-box;transition:border-color .15s ease}',
526
- '.krot-in:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}',
526
+ '.krot-in:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}',
527
527
  '.krot-codes{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px 14px}',
528
528
  '.krot-code{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--dsw-alias-label-primary);cursor:pointer;user-select:none}',
529
529
  '.krot-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:14px}',
@@ -549,7 +549,7 @@ window.__ModuleLoader__.load({
549
549
  '.krot-modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:8px}',
550
550
  '.krot-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500;white-space:nowrap}',
551
551
  '.krot-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}',
552
- '.krot-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}',
552
+ '.krot-badge-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 8%, transparent)}',
553
553
  '.krot-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}',
554
554
  '.krot-prov{display:flex;flex-direction:column;gap:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:14px 16px}',
555
555
  '.krot-prov-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
@@ -576,7 +576,7 @@ window.__ModuleLoader__.load({
576
576
  '.krot-pill{appearance:none;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:4px 12px;font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary);cursor:pointer;transition:all .15s ease}',
577
577
  '.krot-pill:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}',
578
578
  '.krot-pill-active{background:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)!important;font-weight:600}',
579
- '.krot-pill-warn{border-color:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 30%, transparent);color:var(--dsw-alias-state-warning-primary)}',
579
+ '.krot-pill-warn{border-color:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 30%, transparent);color:var(--dsw-alias-state-warn-primary)}',
580
580
  '.krot-pill-err{border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent);color:var(--dsw-alias-state-error-primary)}',
581
581
  '.krot-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
582
582
  '.krot-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
@@ -596,7 +596,7 @@ window.__ModuleLoader__.load({
596
596
  '.krot-q-unlimited{font-size:11px;color:var(--dsw-alias-label-tertiary);font-style:italic}',
597
597
  '.krot-event-row{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}',
598
598
  '.krot-event-time{font-family:ui-monospace,Menlo,Consolas,monospace;color:var(--dsw-alias-label-tertiary);font-size:11px}',
599
- ':root{--krot-chart-4:var(--dsw-alias-state-brand-primary);--krot-chart-5:var(--dsw-alias-state-error-primary);--krot-chart-6:var(--dsw-alias-state-success-primary)}',
599
+ ':root{--krot-chart-4:var(--dsw-alias-brand-primary);--krot-chart-5:var(--dsw-alias-state-error-primary);--krot-chart-6:var(--dsw-alias-state-success-primary)}',
600
600
  '.krot-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
601
601
  '.krot-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}',
602
602
  '.krot-card-head-text{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0}',
@@ -685,7 +685,7 @@ window.__ModuleLoader__.load({
685
685
  }
686
686
 
687
687
 
688
- function Sparkline({ points = [], width = 120, height = 22, stroke = 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-color-primary, currentColor))' }) {
688
+ function Sparkline({ points = [], width = 120, height = 22, stroke = 'var(--dsw-alias-brand-primary, currentColor)' }) {
689
689
  if (!Array.isArray(points) || points.length < 2) {
690
690
  return h('svg', { width, height, viewBox: `0 0 ${width} ${height}`, style: { opacity: 0.3 } },
691
691
  h('line', { x1: 0, y1: height / 2, x2: width, y2: height / 2, stroke, strokeWidth: 1, strokeDasharray: '3 3' })
@@ -1040,7 +1040,7 @@ window.__ModuleLoader__.load({
1040
1040
  const settingsContract = settingsBinding.kind;
1041
1041
  const bindingRef = React.useRef(settingsBinding);
1042
1042
  bindingRef.current = settingsBinding;
1043
- // #273: getSnapshot must be referentially stable or React 18 unmounts the tree
1043
+ // (issue 273): getSnapshot must be referentially stable or React 18 unmounts the tree
1044
1044
  const scopeCacheRef = React.useRef({ has: false, value: null });
1045
1045
  const getScopeSnapshot = React.useCallback(() => {
1046
1046
  if (!settingsScope || typeof settingsScope.getSnapshot !== 'function') return null;
@@ -1150,7 +1150,7 @@ window.__ModuleLoader__.load({
1150
1150
  };
1151
1151
  const undoTimer = React.useRef(null);
1152
1152
  React.useEffect(() => () => { if (undoTimer.current) clearTimeout(undoTimer.current); }, []);
1153
- // #289 a11y: confirm dialog focus trap / Escape / restore (hooks above returns)
1153
+ // (issue 289) a11y: confirm dialog focus trap / Escape / restore (hooks above returns)
1154
1154
  const modalReturnFocusRef = React.useRef(null);
1155
1155
  const modalCardRef = React.useRef(null);
1156
1156
  React.useEffect(() => {
@@ -1362,7 +1362,7 @@ window.__ModuleLoader__.load({
1362
1362
  return next;
1363
1363
  });
1364
1364
  }
1365
- fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: providerId }) })
1365
+ fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: providerId }), signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined })
1366
1366
  .then((r) => r.json().then((data) => ({ ok: r.ok, data })))
1367
1367
  .then(({ ok, data }) => { if (!ok) throw new Error(data?.error?.message ?? 'unknown error'); })
1368
1368
  .catch((e) => {
@@ -1377,14 +1377,14 @@ window.__ModuleLoader__.load({
1377
1377
  })
1378
1378
  .finally(() => setResetting(''));
1379
1379
  };
1380
- // #223: re-test a broken key; a successful live probe lifts the 30-day broken quarantine
1380
+ // (issue 223): re-test a broken key; a successful live probe lifts the 30-day broken quarantine
1381
1381
  const retestBroken = (ref) => {
1382
1382
  setSecretError('');
1383
- fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref, probe: 'models' }) })
1383
+ fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref, probe: 'models' }), signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(30000) : undefined })
1384
1384
  .then((r) => r.json())
1385
1385
  .then((data) => {
1386
1386
  if (data && data.ok) {
1387
- return fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) });
1387
+ return fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }), signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined });
1388
1388
  }
1389
1389
  setSecretError(t('retestFail'));
1390
1390
  return null;
@@ -1405,6 +1405,7 @@ window.__ModuleLoader__.load({
1405
1405
  method: 'POST',
1406
1406
  headers: { 'content-type': 'application/json' },
1407
1407
  body: JSON.stringify({ ref, value }),
1408
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(30000) : undefined,
1408
1409
  })
1409
1410
  .then(async (r) => {
1410
1411
  const data = (await r.json().catch(() => null)) ?? {};
@@ -1438,11 +1439,12 @@ window.__ModuleLoader__.load({
1438
1439
  method: 'PUT',
1439
1440
  headers: { 'content-type': 'application/json' },
1440
1441
  body: JSON.stringify({ ref, value }),
1442
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined,
1441
1443
  })
1442
1444
  .then((r) => r.json().then((data) => ({ ok: r.ok, data })))
1443
1445
  .then(({ ok, data }) => {
1444
1446
  if (!ok) throw new Error(data?.error?.message ?? 'unknown error');
1445
- // #200 leak-detector hint: stored value does not match any known
1447
+ // (issue 200) leak-detector hint: stored value does not match any known
1446
1448
  // API-key shape - probably a placeholder or a typo.
1447
1449
  if (data?.looksLikeSecret === false) {
1448
1450
  setSecretError(t('notSecretShape'));
@@ -1456,7 +1458,7 @@ window.__ModuleLoader__.load({
1456
1458
  .catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
1457
1459
  } finally { secretWritesRef.current.delete(ref); }
1458
1460
  };
1459
- // #290: explicit card states — loading / error / unavailable / empty / ready
1461
+ // (issue 290): explicit card states — loading / error / unavailable / empty / ready
1460
1462
  if (state.status === 'loading' || (!val && state.status !== 'error' && state.status !== 'unavailable')) {
1461
1463
  return React.createElement('div', { className: 'krot-state-block', role: 'status', 'aria-live': 'polite', style: { padding: '18px 4px', display: 'flex', flexDirection: 'column', gap: '8px' } },
1462
1464
  React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13, margin: 0 } }, t('loading')),
@@ -1598,7 +1600,7 @@ window.__ModuleLoader__.load({
1598
1600
  providers[pIndex] = reorderKeys(entry, order);
1599
1601
  return { ...cur, providers };
1600
1602
  });
1601
- // #215: set a single key's round-robin weight (integer >= 1)
1603
+ // (issue 215): set a single key's round-robin weight (integer >= 1)
1602
1604
  const setKeyWeight = (pIndex, kIndex, weight) => setField((cur) => {
1603
1605
  const n = Math.max(1, Math.min(1000, Math.floor(Number(weight) || 1)));
1604
1606
  const providers = [...(cur.providers ?? [])];
@@ -1791,10 +1793,10 @@ window.__ModuleLoader__.load({
1791
1793
  // Key state dot: color and label read at a glance; "key not found"
1792
1794
  // catches an env-name typo that would otherwise stay silent.
1793
1795
  const keyStatus = (providerId, ref, isPaused = false) => {
1794
- if (isPaused) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyPaused'), ready: false, paused: true };
1796
+ if (isPaused) return { color: 'var(--dsw-alias-state-warn-primary)', text: t('keyPaused'), ready: false, paused: true };
1795
1797
  const hit = keyInfo(providerId, ref);
1796
1798
  if (!hit) return null;
1797
- if (hit.paused) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyPaused'), ready: false, paused: true };
1799
+ if (hit.paused) return { color: 'var(--dsw-alias-state-warn-primary)', text: t('keyPaused'), ready: false, paused: true };
1798
1800
  if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired'), ready: false, expired: true };
1799
1801
  if (hit.broken || hit.revoked || hit.status === 'revoked') return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyRevoked'), ready: false, revoked: true };
1800
1802
  if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing'), ready: false, missing: true };
@@ -1803,7 +1805,7 @@ window.__ModuleLoader__.load({
1803
1805
  delete optimisticReset[ref];
1804
1806
  }
1805
1807
  return {
1806
- color: 'var(--dsw-alias-state-warning-primary)',
1808
+ color: 'var(--dsw-alias-state-warn-primary)',
1807
1809
  text: t('keyCooling').replace('{s}', String(Math.ceil(hit.cooldownMsLeft / 1000))),
1808
1810
  ready: false,
1809
1811
  cooling: true,
@@ -1812,7 +1814,7 @@ window.__ModuleLoader__.load({
1812
1814
  if (hit.expiresAt && !hit.expired) {
1813
1815
  const days = Math.ceil((hit.expiresAt - Date.now()) / 86400000);
1814
1816
  const warnDays = Number(val?.expiryWarnDays) || 7;
1815
- if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)), ready: true, expiringSoon: true };
1817
+ if (days <= warnDays) return { color: 'var(--dsw-alias-state-warn-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)), ready: true, expiringSoon: true };
1816
1818
  }
1817
1819
  if (hit.active) return { color: 'var(--dsw-alias-state-success-primary)', text: t('keyActive'), ready: true, active: true };
1818
1820
  return { color: 'var(--dsw-alias-label-tertiary)', text: t('keyReady'), ready: true };
@@ -1913,7 +1915,7 @@ window.__ModuleLoader__.load({
1913
1915
  }
1914
1916
  }
1915
1917
  if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
1916
- // #215: per-key weight input (default 1)
1918
+ // (issue 215): per-key weight input (default 1)
1917
1919
  meta.push(h('input', { key: 'w', type: 'number', min: 1, max: 1000, className: 'krot-in krot-weight',
1918
1920
  value: (entryWeights[kIndex] ?? info?.weight ?? 1),
1919
1921
  title: t('weightHint'),
@@ -1927,7 +1929,7 @@ window.__ModuleLoader__.load({
1927
1929
  style: { padding: '2px 6px', fontSize: '11px' },
1928
1930
  onClick: () => togglePauseKey(pIndex, kIndex),
1929
1931
  }, isP ? '⏸ ' + t('keyPaused') : '▶'));
1930
- // #210: RPM capacity indicator (only when rpmLimit is active)
1932
+ // (issue 210): RPM capacity indicator (only when rpmLimit is active)
1931
1933
  if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
1932
1934
  title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
1933
1935
  style: info.rpm.remaining === 0 ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined },
@@ -1946,7 +1948,7 @@ window.__ModuleLoader__.load({
1946
1948
  + (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
1947
1949
  style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
1948
1950
  tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : ('✕' + (tr.code ? ' ' + tr.code : ''))));
1949
- // #219: last probe from the sandbox cache, greyed when older than 24h
1951
+ // (issue 219): last probe from the sandbox cache, greyed when older than 24h
1950
1952
  else if (!Object.prototype.hasOwnProperty.call(testResult, key) && probeCache && probeCache[key]) {
1951
1953
  const pc = probeCache[key];
1952
1954
  const stale = Date.now() - (pc.at ?? 0) > 86400000;
@@ -1956,7 +1958,7 @@ window.__ModuleLoader__.load({
1956
1958
  (pc.ok ? '✓' : '✕') + (pc.latencyMs ? ' ' + pc.latencyMs + 'ms' : '')));
1957
1959
  }
1958
1960
  meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: Boolean(testing || testAllProvider), title: t('testKey') }, testing === key ? '…' : t('testKey')));
1959
- // #223: broken keys get a one-click live re-test + auto-unbreak
1961
+ // (issue 223): broken keys get a one-click live re-test + auto-unbreak
1960
1962
  if (info && info.broken) {
1961
1963
  meta.push(h('button', { key: 'rt', className: 'krot-btn', onClick: () => retestBroken(key), title: t('retestBroken') }, t('retestBroken')));
1962
1964
  }
@@ -1983,13 +1985,13 @@ window.__ModuleLoader__.load({
1983
1985
  const exhaustionWarning = providerStatus && providerStatus.lastExhaustionAt && (Date.now() - providerStatus.lastExhaustionAt) < 3600000
1984
1986
  ? h('p', { className: 'krot-err' }, t('poolExhausted') + ' (' + formatAgo(t, providerStatus.lastExhaustionAt) + ')')
1985
1987
  : null;
1986
- // #208: budget line (warn color at >=80%, red at 100%)
1988
+ // (issue 208): budget line (warn color at >=80%, red at 100%)
1987
1989
  const budgetLine = providerStatus && (providerStatus.budgetDaily > 0 || providerStatus.budgetWeekly > 0)
1988
1990
  ? (() => {
1989
1991
  const dayRatio = providerStatus.budgetDaily > 0 ? providerStatus.todayCost / providerStatus.budgetDaily : 0;
1990
1992
  const weekRatio = providerStatus.budgetWeekly > 0 ? providerStatus.weeklyCost / providerStatus.budgetWeekly : 0;
1991
1993
  const worst = Math.max(dayRatio, weekRatio);
1992
- const color = worst >= 1 ? 'var(--dsw-alias-state-error-primary)' : worst >= 0.8 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-label-tertiary)';
1994
+ const color = worst >= 1 ? 'var(--dsw-alias-state-error-primary)' : worst >= 0.8 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-label-tertiary)';
1993
1995
  const parts = [];
1994
1996
  if (providerStatus.budgetDaily > 0) parts.push('$' + (providerStatus.todayCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetDaily);
1995
1997
  if (providerStatus.budgetWeekly > 0) parts.push('week $' + (providerStatus.weeklyCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetWeekly);
@@ -1997,15 +1999,15 @@ window.__ModuleLoader__.load({
1997
1999
  return h('p', { className: 'krot-hint', style: { color } }, t('budgetLabel') + ' ' + parts.join(' · '));
1998
2000
  })()
1999
2001
  : null;
2000
- // #225: provider p95 latency + SLO marker
2002
+ // (issue 225): provider p95 latency + SLO marker
2001
2003
  const sloLine = providerStatus && providerStatus.p95 != null
2002
2004
  ? (() => {
2003
2005
  const over = providerStatus.latencySloMs && providerStatus.p95 > providerStatus.latencySloMs;
2004
- return h('p', { className: 'krot-hint', style: over ? { color: 'var(--dsw-alias-state-warning-primary)' } : undefined },
2006
+ return h('p', { className: 'krot-hint', style: over ? { color: 'var(--dsw-alias-state-warn-primary)' } : undefined },
2005
2007
  'p95 ' + providerStatus.p95 + 'ms' + (providerStatus.latencySloMs ? ' / ' + providerStatus.latencySloMs + 'ms SLO' : ''));
2006
2008
  })()
2007
2009
  : null;
2008
- // #209: CSV export for this provider's usage (last 7 days)
2010
+ // (issue 209): CSV export for this provider's usage (last 7 days)
2009
2011
  const exportCsv = h('button', { className: 'krot-btn', title: t('exportCsv'),
2010
2012
  onClick: () => {
2011
2013
  const url = '/dsh-key-rotation/usage?format=csv&days=7&provider=' + encodeURIComponent(entry.provider);
@@ -2024,10 +2026,10 @@ window.__ModuleLoader__.load({
2024
2026
  });
2025
2027
  const total = keyUsages.reduce((acc, x) => acc + x.usage, 0);
2026
2028
  const palette = [
2027
- 'var(--dsw-alias-state-brand-primary)',
2029
+ 'var(--dsw-alias-brand-primary)',
2028
2030
  'var(--dsw-alias-state-success-primary)',
2029
- 'var(--dsw-alias-state-warning-primary)',
2030
- 'var(--dsw-alias-state-info-primary)',
2031
+ 'var(--dsw-alias-state-warn-primary)',
2032
+ 'var(--dsw-alias-brand-primary)',
2031
2033
  'var(--krot-chart-4)',
2032
2034
  'var(--krot-chart-5)',
2033
2035
  'var(--krot-chart-6)',
@@ -2109,7 +2111,7 @@ window.__ModuleLoader__.load({
2109
2111
  const pct = quota.limit > 0 ? Math.min(100, Math.round((quota.used / quota.limit) * 100)) : 0;
2110
2112
  const color = quota.exhausted
2111
2113
  ? 'var(--dsw-alias-state-error-primary)'
2112
- : pct >= 80 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-success-primary)';
2114
+ : pct >= 80 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)';
2113
2115
  const resetIn = quota.resetAt ? formatCountdown(quota.resetAt - Date.now()) : null;
2114
2116
  cells.push(h('span', { key: 'm', style: { display: 'inline-flex', alignItems: 'center', gap: '6px' } },
2115
2117
  h('span', { className: 'krot-qbar', title: t('mtUsedOf').replace('{u}', String(quota.used)).replace('{l}', String(quota.limit)) },
@@ -2197,7 +2199,7 @@ window.__ModuleLoader__.load({
2197
2199
  const ps = status[entry.provider];
2198
2200
  const score = ps && typeof ps.healthScore === 'number' ? ps.healthScore : null;
2199
2201
  if (score === null) return null;
2200
- const color = score > 80 ? 'var(--dsw-alias-state-success-primary)' : score >= 50 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-error-primary)';
2202
+ const color = score > 80 ? 'var(--dsw-alias-state-success-primary)' : score >= 50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)';
2201
2203
  return h('span', { className: 'krot-tail', title: t('healthScoreTitle'), style: { flex: 'none', color, fontWeight: 700 } }, String(score));
2202
2204
  })(),
2203
2205
  (() => { const ps = status[entry.provider]; const tot = ps && typeof ps.totalUsage === 'number' ? ps.totalUsage : null; return tot !== null ? h('span', { className: 'krot-tail', title: t('totalRequestsTitle'), style: { flex: 'none' } }, String(tot)) : null; })(),
@@ -2253,9 +2255,9 @@ window.__ModuleLoader__.load({
2253
2255
  budgetLine,
2254
2256
  sloLine,
2255
2257
  exhaustionWarning,
2256
- (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),
2257
- // #224: 7-day switches per day (client-side, from the same events)
2258
- (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '16px', marginTop: '2px' } }, (() => { const now = Date.now(); const days = Array(7).fill(0); for (const ev of providerStatus.events) { const d = Math.floor((now - ev.at) / 86400000); if (d >= 0 && d < 7) days[6 - d]++; } const max = Math.max(1, ...days); return days.map((c, i) => h('div', { key: i, title: c + ' switches · day -' + (6 - i), style: { flex: 1, background: c ? 'var(--dsw-alias-state-info-primary, var(--dsw-alias-state-warning-primary))' : 'var(--dsw-alias-border-l2)', height: (c / max * 16) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
2258
+ (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-warn-primary)' : 'var(--dsw-alias-border-l2)', height: (c / max * 24) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
2259
+ // (issue 224): 7-day switches per day (client-side, from the same events)
2260
+ (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '16px', marginTop: '2px' } }, (() => { const now = Date.now(); const days = Array(7).fill(0); for (const ev of providerStatus.events) { const d = Math.floor((now - ev.at) / 86400000); if (d >= 0 && d < 7) days[6 - d]++; } const max = Math.max(1, ...days); return days.map((c, i) => h('div', { key: i, title: c + ' switches · day -' + (6 - i), style: { flex: 1, background: c ? 'var(--dsw-alias-brand-primary, var(--dsw-alias-state-warn-primary))' : 'var(--dsw-alias-border-l2)', height: (c / max * 16) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
2259
2261
  (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { className: 'krot-event-stream' },
2260
2262
  h('summary', { style: { display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontWeight: 600, fontSize: '12px' } },
2261
2263
  h('span', null, '📜 ' + t('liveEventStream')),
@@ -2461,7 +2463,7 @@ window.__ModuleLoader__.load({
2461
2463
  });
2462
2464
  } })),
2463
2465
  btn(t('snapshotExport'), () => {
2464
- fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' } })
2466
+ fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' }, signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
2465
2467
  .then((r) => r.json())
2466
2468
  .then((data) => {
2467
2469
  const blob = new Blob([JSON.stringify(data.snapshot ?? {}, null, 2)], { type: 'application/json' });
@@ -2565,7 +2567,7 @@ window.__ModuleLoader__.load({
2565
2567
 
2566
2568
 
2567
2569
 
2568
- // #201 header chip: one dot + counts for all pools. Green = all healthy,
2570
+ // (issue 201) header chip: one dot + counts for all pools. Green = all healthy,
2569
2571
  // amber = some keys cooling, red = a pool fully exhausted. Click opens the
2570
2572
  // same summary the floating dashboard shows.
2571
2573
  function KeyRotationHeaderChip(props) {
@@ -2578,7 +2580,7 @@ window.__ModuleLoader__.load({
2578
2580
  let alive = true;
2579
2581
  const load = () => {
2580
2582
  if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
2581
- fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin' })
2583
+ fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin', signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
2582
2584
  .then((r) => (r.ok ? r.json() : null))
2583
2585
  .then((d) => { if (alive) setSnap(d); })
2584
2586
  .catch(() => {});
@@ -2608,7 +2610,7 @@ window.__ModuleLoader__.load({
2608
2610
  const total = poolEntries.reduce((a, [, p]) => a + (p.total || 0), 0);
2609
2611
  const healthy = poolEntries.reduce((a, [, p]) => a + (p.healthy || 0), 0);
2610
2612
  const anyExhausted = poolEntries.some(([, p]) => p.exhausted);
2611
- const color = !poolEntries.length ? 'var(--dsw-alias-label-tertiary)' : anyExhausted ? 'var(--dsw-alias-state-error-primary)' : healthy < total ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-success-primary)';
2613
+ const color = !poolEntries.length ? 'var(--dsw-alias-label-tertiary)' : anyExhausted ? 'var(--dsw-alias-state-error-primary)' : healthy < total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)';
2612
2614
  const label = poolEntries.length ? `${healthy}/${total} rot` : 'rot';
2613
2615
 
2614
2616
  return h('div', { ref, style: { position: 'relative', display: 'inline-flex' } },
@@ -2626,7 +2628,7 @@ window.__ModuleLoader__.load({
2626
2628
  h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
2627
2629
  !poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('noActivePools')) : null,
2628
2630
  poolEntries.map(([name, p]) => {
2629
- const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-success-primary)');
2631
+ const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)');
2630
2632
  return h('div', { key: name, className: 'krot-pop-row' },
2631
2633
  h('div', { className: 'krot-pop-name' },
2632
2634
  h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' } }),
@@ -1,3 +1,4 @@
1
+ import { SOFT_FAILURE_CODES } from './pool.js';
1
2
  // lib/error-taxonomy.js — complete failure classification (#267).
2
3
  // action: 'switch' | 'surface' | 'cooldown'
3
4
 
@@ -36,7 +37,7 @@ export function classifyFailure(failureOrPayload) {
36
37
  return { action: 'switch', code: status ? '429' : (code || 'RATE_LIMIT'), soft: false, reason: 'quota' };
37
38
  }
38
39
  if (SWITCH_STATUS.has(status)) {
39
- const soft = status === 500 || status === 502 || status === 503 || status === 504;
40
+ const soft = SOFT_FAILURE_CODES.has(String(status));
40
41
  return { action: 'switch', code: String(status), soft, reason: 'http' };
41
42
  }
42
43
  if (SURFACE_STATUS.has(status)) {
package/lib/index.js CHANGED
@@ -466,7 +466,7 @@ export function apply(ctx, config = {}) {
466
466
  const message = String(payload?.failure?.message ?? payload?.message ?? '');
467
467
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
468
468
  const cls = classifyFailure(payload);
469
- const switchable = isSwitchableError(payload, effectiveSwitchCodes) || cls.action === 'switch';
469
+ const switchable = isSwitchableError(payload, effectiveSwitchCodes);
470
470
  if (!switchable) return next();
471
471
  if (buildRuntime().circuitBreakerEnabled && moduleBreaker) moduleBreaker.onFailure(provider);
472
472
  const ref = pool.state.lastUsed;
@@ -479,7 +479,11 @@ export function apply(ctx, config = {}) {
479
479
  pool.state.lastSwitchAt = Date.now();
480
480
  logger.warn(`[dsh-key-rotation] ${provider}: key ${String(ref)} failed via agent/request-error (${String(code)} ${String(message).slice(0, 80)}) — retry`);
481
481
  }
482
- return { kind: 'retry' };
482
+ const alternativeRef = pickNext(pool, Date.now());
483
+ if (alternativeRef && alternativeRef !== ref) {
484
+ return { kind: 'retry' };
485
+ }
486
+ return next();
483
487
  }), 'dsh-key-rotation: agent/request-error');
484
488
 
485
489
  ctx.inject(['settings'], (sctx) => {
package/lib/ops-status.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getModelQuotaStatus, isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
2
- import { providerOfBase, basePoolForBase } from './pool-index.js';
2
+ import { providerOfBase, basePoolForBase, modelPoolsForRef } from './pool-index.js';
3
3
  import { decryptSecret } from './crypto-storage.js';
4
4
  // lib/ops-status.js — status + health operational routes (#312 split from routes-ops.js).
5
5
  import { json } from './http-bridge.js';
@@ -93,6 +93,7 @@ ctx.effect(() => ctx.webServer.register({
93
93
  }
94
94
  const until = pool.state.failedUntil?.get(ref);
95
95
  const modelQuota = getModelQuotaStatus(pool, ref, now);
96
+ const activeModelPools = runtime?.index ? modelPoolsForRef(runtime.index, ref) : [];
96
97
  keys.push({
97
98
  ref,
98
99
  present,
package/lib/pool.js CHANGED
@@ -90,6 +90,10 @@ export const SOFT_FAILURE_CODES = new Set([
90
90
  ]);
91
91
 
92
92
  /** True if failure code or message represents a soft/transient drop. */
93
+ export function isSoftFailureCode(code) {
94
+ if (!code) return false;
95
+ return SOFT_FAILURE_CODES.has(String(code).toUpperCase());
96
+ }
93
97
 
94
98
  /** Ref name validator. Same rule lib/index.js enforces in PUT/DELETE /key. */
95
99
  const REF_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -432,6 +436,25 @@ export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
432
436
  * Unified check whether an error or payload represents a switchable failure.
433
437
  * Prioritizes explicit HTTP status codes and gRPC codes before text matching.
434
438
  */
439
+ /** Raw socket-level codes the taxonomy maps to TRANSPORT (see classifyFailure). */
440
+ const SOCKET_TRANSPORT_CODES = new Set([
441
+ 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND',
442
+ 'ECONNABORTED', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'UND_ERR_HEADERS_TIMEOUT',
443
+ ]);
444
+
445
+ /**
446
+ * Codes isSwitchableError/classifyFailure recognize. Once a failure
447
+ * carries one of these, the operator's switchCodes verdict is final and the
448
+ * message-regex fallback must not run (it would resurrect excluded codes).
449
+ */
450
+ const RECOGNIZED_SWITCH_CODES = new Set([
451
+ 'QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE',
452
+ 'UNKNOWN_MODEL', 'AUTH', 'RESOURCE_EXHAUSTED', 'UNAVAILABLE', 'INTERNAL',
453
+ 'DEADLINE_EXCEEDED', 'UNAUTHENTICATED', 'PERMISSION_DENIED', 'ABORTED',
454
+ 'LOCAL_POOL_EXHAUSTED', 'LOCAL_MODEL_QUOTA_EXHAUSTED', 'CIRCUIT_OPEN',
455
+ '429', '401', '403', '408', '425', '500', '502', '503', '504',
456
+ ]);
457
+
435
458
  export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAULT_SWITCH_CODES)) {
436
459
  if (!failureOrPayload) return false;
437
460
  // Runtime switch codes arrive as an array (cfg.switchCodes is a schema array,
@@ -439,7 +462,8 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
439
462
  if (!(switchCodes instanceof Set)) switchCodes = new Set(Array.isArray(switchCodes) ? switchCodes : DEFAULT_SWITCH_CODES);
440
463
  const failure = failureOrPayload.failure ?? failureOrPayload;
441
464
  const status = Number(failure.status ?? failure.statusCode ?? failure.httpStatus ?? 0);
442
- const code = String(failure.code ?? failure.reason ?? '').toUpperCase();
465
+ let code = String(failure.code ?? failure.reason ?? '').toUpperCase();
466
+ if (code && SOCKET_TRANSPORT_CODES.has(code)) code = 'TRANSPORT';
443
467
  const message = String(failure.message ?? failureOrPayload.message ?? '');
444
468
 
445
469
  // 1. Direct HTTP status codes (#267: also 408/425 transient)
@@ -456,7 +480,11 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
456
480
  // 3. Named switch code matching
457
481
  if (code && switchCodes.has(code)) return true;
458
482
 
459
- // 4. Fallback text pattern matching
483
+ // 4. Fallback text pattern matching — switchCodes is the operator's
484
+ // explicit contract for which failures rotate keys. Once the failure has a
485
+ // recognized code, that decision is final: message sniffing must not
486
+ // resurrect codes the operator deliberately excluded (e.g. TIMEOUT).
487
+ if (code && RECOGNIZED_SWITCH_CODES.has(code)) return false;
460
488
  return SWITCHABLE_MESSAGE_PATTERN.test(message);
461
489
  }
462
490
 
package/lib/rotate.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  sortAttemptList,
17
17
  } from './pool.js';
18
18
  import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
19
- import { nextQuotaReset } from './quota-window.js';
19
+ import { nextQuotaReset, poolResetAt } from './quota-window.js';
20
20
  import { tpmRecord } from './bucket.js';
21
21
  import {
22
22
  isModelQuotaAvailable,
@@ -163,7 +163,7 @@ export function createRotate(deps) {
163
163
  /\b(?:insufficient[_\s-]+quota|quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota)\b/i.test(errMsg);
164
164
  const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
165
165
  if (isQuota && qWindow) {
166
- const quotaResetAt = nextQuotaReset(qWindow, now());
166
+ const quotaResetAt = poolResetAt(pool, qWindow, now());
167
167
  if (quotaResetAt && quotaResetAt > now()) {
168
168
  if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
169
169
  pool.state.quotaWindows.set(targetRef, quotaResetAt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.50",
3
+ "version": "0.8.51",
4
4
  "packageManager": "pnpm@10.33.2",
5
5
  "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.",
6
6
  "keywords": [