@goodandready/dsh-key-rotation 0.8.50 → 0.8.52

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,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.52 - 2026-10-07
4
+
5
+ ### Added
6
+ - **Key Pinning (`pinnedRef`) with Resilient Failover Bypass (#455)**:
7
+ - Added `pinnedRef` (alias `useKeyRef`) to provider settings schema and pool builder.
8
+ - In key selection (`lib/resolver.js` and `lib/rotate.js`), healthy pinned keys are prioritized first.
9
+ - When a pinned key enters cooldown, is paused/revoked, or runs out of token quota, requests automatically and seamlessly bypass the pin to rotate among remaining healthy keys, preventing agent stalls.
10
+ - **User-Defined Key Labels (#456)**:
11
+ - Added `labels` string array aligned with provider `keys` in settings schema and pool models.
12
+ - Exposed per-key in `GET /dsh-key-rotation/status` (`label` field) and provider overview.
13
+ - Displayed as visual tag badges in WebUI settings card.
14
+ - **Secure Plaintext Key Reveal Endpoint (#457)**:
15
+ - Added `GET /dsh-key-rotation/key?ref=...` in `lib/ops-keys.js` with loopback and same-origin validation (`isTrustedBridgeRequest`).
16
+ - Decrypts and returns `{ ref, plaintext, tail }` for operator auditing and copying without raw disk inspection.
17
+ - **In-Memory Client Diagnostics Ring Buffer (#458)**:
18
+ - Added bounded 200-entry diagnostics ring buffer and `/dsh-key-rotation/diag` route in `lib/ops-telemetry.js`.
19
+ - Ingests frontend runtime errors and fetch failures via `POST /diag` and retrieves log entries via `GET /diag`.
20
+
21
+ ### Changed
22
+ - **PeerDependencies Triplet SemVer Range (#459)**:
23
+ - Updated `@deepseek-ai/dsh-llm` peer dependency range to `^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0` in `package.json` to prevent peer dependency warnings across DSH `0.2.0-rc.*` releases.
24
+
25
+ ## 0.8.51 - 2026-10-06
26
+
27
+ ### Fixed
28
+ - **Strict switchCodes Filtering & Unbounded Retry Prevention (#453, GitHub PR #21)**:
29
+ - 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`.
30
+ - 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.
31
+ - Credit: Jan Eickholt (@JanEickholt).
32
+ - **Canonical CSS Theme Tokens & Clean Client Comments (#450)**:
33
+ - Replaced deprecated/unrecognized theme tokens with standard DSH variables (`--dsw-alias-brand-primary`, `--dsw-alias-state-warn-primary`) across `lib/client-src/`.
34
+ - Replaced issue comment references (`// #<id>` -> `// (issue <id>)`) to avoid false positives in automated hex color audits.
35
+ - **Client Fetch Timeout Signals (#451)**:
36
+ - 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.
37
+ - Rebuilt production bundle `lib/client.js`.
38
+ - **Wired Production Exports (#452)**:
39
+ - 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`.
40
+
3
41
  ## 0.8.50 - 2026-10-05
4
42
 
5
43
  ### 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 };
@@ -1884,6 +1886,12 @@ window.__ModuleLoader__.load({
1884
1886
  h('span', { key: 'd', className: 'krot-dot', style: { background: st ? st.color : 'var(--dsw-alias-border-l2)' } }),
1885
1887
  h('span', { key: 's', className: 'krot-state' }, st ? st.text : ''),
1886
1888
  ];
1889
+ if (info && info.pinned) {
1890
+ meta.push(h('span', { key: 'pin', className: 'krot-state', title: 'Pinned key' }, '📌'));
1891
+ }
1892
+ if (info && info.label) {
1893
+ meta.push(h('span', { key: 'lbl', className: 'krot-tag', title: 'Key label' }, info.label));
1894
+ }
1887
1895
  if (fromEnv) {
1888
1896
  meta.push(h('span', { key: 'v', className: 'krot-tail', title: t('keyFromEnv') },
1889
1897
  info.tail ? '••••' + info.tail : t('keyFromEnv')));
@@ -1913,7 +1921,7 @@ window.__ModuleLoader__.load({
1913
1921
  }
1914
1922
  }
1915
1923
  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)
1924
+ // (issue 215): per-key weight input (default 1)
1917
1925
  meta.push(h('input', { key: 'w', type: 'number', min: 1, max: 1000, className: 'krot-in krot-weight',
1918
1926
  value: (entryWeights[kIndex] ?? info?.weight ?? 1),
1919
1927
  title: t('weightHint'),
@@ -1927,7 +1935,7 @@ window.__ModuleLoader__.load({
1927
1935
  style: { padding: '2px 6px', fontSize: '11px' },
1928
1936
  onClick: () => togglePauseKey(pIndex, kIndex),
1929
1937
  }, isP ? '⏸ ' + t('keyPaused') : '▶'));
1930
- // #210: RPM capacity indicator (only when rpmLimit is active)
1938
+ // (issue 210): RPM capacity indicator (only when rpmLimit is active)
1931
1939
  if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
1932
1940
  title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
1933
1941
  style: info.rpm.remaining === 0 ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined },
@@ -1946,7 +1954,7 @@ window.__ModuleLoader__.load({
1946
1954
  + (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
1947
1955
  style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
1948
1956
  tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : ('✕' + (tr.code ? ' ' + tr.code : ''))));
1949
- // #219: last probe from the sandbox cache, greyed when older than 24h
1957
+ // (issue 219): last probe from the sandbox cache, greyed when older than 24h
1950
1958
  else if (!Object.prototype.hasOwnProperty.call(testResult, key) && probeCache && probeCache[key]) {
1951
1959
  const pc = probeCache[key];
1952
1960
  const stale = Date.now() - (pc.at ?? 0) > 86400000;
@@ -1956,7 +1964,7 @@ window.__ModuleLoader__.load({
1956
1964
  (pc.ok ? '✓' : '✕') + (pc.latencyMs ? ' ' + pc.latencyMs + 'ms' : '')));
1957
1965
  }
1958
1966
  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
1967
+ // (issue 223): broken keys get a one-click live re-test + auto-unbreak
1960
1968
  if (info && info.broken) {
1961
1969
  meta.push(h('button', { key: 'rt', className: 'krot-btn', onClick: () => retestBroken(key), title: t('retestBroken') }, t('retestBroken')));
1962
1970
  }
@@ -1983,13 +1991,13 @@ window.__ModuleLoader__.load({
1983
1991
  const exhaustionWarning = providerStatus && providerStatus.lastExhaustionAt && (Date.now() - providerStatus.lastExhaustionAt) < 3600000
1984
1992
  ? h('p', { className: 'krot-err' }, t('poolExhausted') + ' (' + formatAgo(t, providerStatus.lastExhaustionAt) + ')')
1985
1993
  : null;
1986
- // #208: budget line (warn color at >=80%, red at 100%)
1994
+ // (issue 208): budget line (warn color at >=80%, red at 100%)
1987
1995
  const budgetLine = providerStatus && (providerStatus.budgetDaily > 0 || providerStatus.budgetWeekly > 0)
1988
1996
  ? (() => {
1989
1997
  const dayRatio = providerStatus.budgetDaily > 0 ? providerStatus.todayCost / providerStatus.budgetDaily : 0;
1990
1998
  const weekRatio = providerStatus.budgetWeekly > 0 ? providerStatus.weeklyCost / providerStatus.budgetWeekly : 0;
1991
1999
  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)';
2000
+ 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
2001
  const parts = [];
1994
2002
  if (providerStatus.budgetDaily > 0) parts.push('$' + (providerStatus.todayCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetDaily);
1995
2003
  if (providerStatus.budgetWeekly > 0) parts.push('week $' + (providerStatus.weeklyCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetWeekly);
@@ -1997,15 +2005,15 @@ window.__ModuleLoader__.load({
1997
2005
  return h('p', { className: 'krot-hint', style: { color } }, t('budgetLabel') + ' ' + parts.join(' · '));
1998
2006
  })()
1999
2007
  : null;
2000
- // #225: provider p95 latency + SLO marker
2008
+ // (issue 225): provider p95 latency + SLO marker
2001
2009
  const sloLine = providerStatus && providerStatus.p95 != null
2002
2010
  ? (() => {
2003
2011
  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 },
2012
+ return h('p', { className: 'krot-hint', style: over ? { color: 'var(--dsw-alias-state-warn-primary)' } : undefined },
2005
2013
  'p95 ' + providerStatus.p95 + 'ms' + (providerStatus.latencySloMs ? ' / ' + providerStatus.latencySloMs + 'ms SLO' : ''));
2006
2014
  })()
2007
2015
  : null;
2008
- // #209: CSV export for this provider's usage (last 7 days)
2016
+ // (issue 209): CSV export for this provider's usage (last 7 days)
2009
2017
  const exportCsv = h('button', { className: 'krot-btn', title: t('exportCsv'),
2010
2018
  onClick: () => {
2011
2019
  const url = '/dsh-key-rotation/usage?format=csv&days=7&provider=' + encodeURIComponent(entry.provider);
@@ -2024,10 +2032,10 @@ window.__ModuleLoader__.load({
2024
2032
  });
2025
2033
  const total = keyUsages.reduce((acc, x) => acc + x.usage, 0);
2026
2034
  const palette = [
2027
- 'var(--dsw-alias-state-brand-primary)',
2035
+ 'var(--dsw-alias-brand-primary)',
2028
2036
  'var(--dsw-alias-state-success-primary)',
2029
- 'var(--dsw-alias-state-warning-primary)',
2030
- 'var(--dsw-alias-state-info-primary)',
2037
+ 'var(--dsw-alias-state-warn-primary)',
2038
+ 'var(--dsw-alias-brand-primary)',
2031
2039
  'var(--krot-chart-4)',
2032
2040
  'var(--krot-chart-5)',
2033
2041
  'var(--krot-chart-6)',
@@ -2109,7 +2117,7 @@ window.__ModuleLoader__.load({
2109
2117
  const pct = quota.limit > 0 ? Math.min(100, Math.round((quota.used / quota.limit) * 100)) : 0;
2110
2118
  const color = quota.exhausted
2111
2119
  ? 'var(--dsw-alias-state-error-primary)'
2112
- : pct >= 80 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-success-primary)';
2120
+ : pct >= 80 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)';
2113
2121
  const resetIn = quota.resetAt ? formatCountdown(quota.resetAt - Date.now()) : null;
2114
2122
  cells.push(h('span', { key: 'm', style: { display: 'inline-flex', alignItems: 'center', gap: '6px' } },
2115
2123
  h('span', { className: 'krot-qbar', title: t('mtUsedOf').replace('{u}', String(quota.used)).replace('{l}', String(quota.limit)) },
@@ -2197,7 +2205,7 @@ window.__ModuleLoader__.load({
2197
2205
  const ps = status[entry.provider];
2198
2206
  const score = ps && typeof ps.healthScore === 'number' ? ps.healthScore : null;
2199
2207
  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)';
2208
+ 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
2209
  return h('span', { className: 'krot-tail', title: t('healthScoreTitle'), style: { flex: 'none', color, fontWeight: 700 } }, String(score));
2202
2210
  })(),
2203
2211
  (() => { 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 +2261,9 @@ window.__ModuleLoader__.load({
2253
2261
  budgetLine,
2254
2262
  sloLine,
2255
2263
  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),
2264
+ (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),
2265
+ // (issue 224): 7-day switches per day (client-side, from the same events)
2266
+ (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
2267
  (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { className: 'krot-event-stream' },
2260
2268
  h('summary', { style: { display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontWeight: 600, fontSize: '12px' } },
2261
2269
  h('span', null, '📜 ' + t('liveEventStream')),
@@ -2461,7 +2469,7 @@ window.__ModuleLoader__.load({
2461
2469
  });
2462
2470
  } })),
2463
2471
  btn(t('snapshotExport'), () => {
2464
- fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' } })
2472
+ fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' }, signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
2465
2473
  .then((r) => r.json())
2466
2474
  .then((data) => {
2467
2475
  const blob = new Blob([JSON.stringify(data.snapshot ?? {}, null, 2)], { type: 'application/json' });
@@ -2565,7 +2573,7 @@ window.__ModuleLoader__.load({
2565
2573
 
2566
2574
 
2567
2575
 
2568
- // #201 header chip: one dot + counts for all pools. Green = all healthy,
2576
+ // (issue 201) header chip: one dot + counts for all pools. Green = all healthy,
2569
2577
  // amber = some keys cooling, red = a pool fully exhausted. Click opens the
2570
2578
  // same summary the floating dashboard shows.
2571
2579
  function KeyRotationHeaderChip(props) {
@@ -2578,7 +2586,7 @@ window.__ModuleLoader__.load({
2578
2586
  let alive = true;
2579
2587
  const load = () => {
2580
2588
  if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
2581
- fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin' })
2589
+ 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
2590
  .then((r) => (r.ok ? r.json() : null))
2583
2591
  .then((d) => { if (alive) setSnap(d); })
2584
2592
  .catch(() => {});
@@ -2608,7 +2616,7 @@ window.__ModuleLoader__.load({
2608
2616
  const total = poolEntries.reduce((a, [, p]) => a + (p.total || 0), 0);
2609
2617
  const healthy = poolEntries.reduce((a, [, p]) => a + (p.healthy || 0), 0);
2610
2618
  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)';
2619
+ 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
2620
  const label = poolEntries.length ? `${healthy}/${total} rot` : 'rot';
2613
2621
 
2614
2622
  return h('div', { ref, style: { position: 'relative', display: 'inline-flex' } },
@@ -2626,7 +2634,7 @@ window.__ModuleLoader__.load({
2626
2634
  h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
2627
2635
  !poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('noActivePools')) : null,
2628
2636
  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)');
2637
+ 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
2638
  return h('div', { key: name, className: 'krot-pop-row' },
2631
2639
  h('div', { className: 'krot-pop-name' },
2632
2640
  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
@@ -118,6 +118,8 @@ const SettingsSchema = Schema.object({
118
118
  revoked: Schema.array(Schema.boolean()).default([]),
119
119
  expiresAt: Schema.array(Schema.union([Schema.number(), Schema.string()])).default([]),
120
120
  tags: Schema.array(Schema.string()).default([]),
121
+ labels: Schema.array(Schema.string()).default([]),
122
+ pinnedRef: Schema.string().default(''),
121
123
  costBudgetDaily: Schema.number(),
122
124
  costBudgetWeekly: Schema.number(),
123
125
  pauseOnBudget: Schema.boolean().default(false),
@@ -466,7 +468,7 @@ export function apply(ctx, config = {}) {
466
468
  const message = String(payload?.failure?.message ?? payload?.message ?? '');
467
469
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
468
470
  const cls = classifyFailure(payload);
469
- const switchable = isSwitchableError(payload, effectiveSwitchCodes) || cls.action === 'switch';
471
+ const switchable = isSwitchableError(payload, effectiveSwitchCodes);
470
472
  if (!switchable) return next();
471
473
  if (buildRuntime().circuitBreakerEnabled && moduleBreaker) moduleBreaker.onFailure(provider);
472
474
  const ref = pool.state.lastUsed;
@@ -479,7 +481,11 @@ export function apply(ctx, config = {}) {
479
481
  pool.state.lastSwitchAt = Date.now();
480
482
  logger.warn(`[dsh-key-rotation] ${provider}: key ${String(ref)} failed via agent/request-error (${String(code)} ${String(message).slice(0, 80)}) — retry`);
481
483
  }
482
- return { kind: 'retry' };
484
+ const alternativeRef = pickNext(pool, Date.now());
485
+ if (alternativeRef && alternativeRef !== ref) {
486
+ return { kind: 'retry' };
487
+ }
488
+ return next();
483
489
  }), 'dsh-key-rotation: agent/request-error');
484
490
 
485
491
  ctx.inject(['settings'], (sctx) => {
package/lib/ops-keys.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  isTrustedBridgeRequest,
11
11
  isValidRef,
12
12
  keyTail,
13
+ envValue,
13
14
  } from './pool.js';
14
15
  import { looksLikeApiSecret } from './keycheck.js';
15
16
  import { mutateSettings, validateProviders } from './settings-write.js';
@@ -43,8 +44,8 @@ ctx.effect(() => ctx.webServer.register({
43
44
  kind: 'exact',
44
45
  path: KEY_PATH,
45
46
  handler: async (req, res) => {
46
- if (req.method !== 'PUT' && req.method !== 'DELETE') {
47
- json(res, 405, { error: { code: 'method', message: 'PUT or DELETE only' } });
47
+ if (req.method !== 'PUT' && req.method !== 'DELETE' && req.method !== 'GET') {
48
+ json(res, 405, { error: { code: 'method', message: 'GET, PUT or DELETE only' } });
48
49
  return;
49
50
  }
50
51
  if (!isTrustedBridgeRequest(req)) {
@@ -52,6 +53,30 @@ ctx.effect(() => ctx.webServer.register({
52
53
  return;
53
54
  }
54
55
  const credentialsService = ctx.get('credentials');
56
+ if (req.method === 'GET') {
57
+ const url = new URL(req.url, 'http://127.0.0.1');
58
+ const ref = url.searchParams.get('ref')?.trim();
59
+ if (!isValidRef(ref)) {
60
+ json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
61
+ return;
62
+ }
63
+ try {
64
+ let hit = await (credentialsService?.__dshKeyRotationOriginalResolve ?? credentialsService?.resolve)?.call(credentialsService, ref);
65
+ let raw = hit?.value;
66
+ if (!raw) {
67
+ raw = envValue(ref);
68
+ }
69
+ if (!raw) {
70
+ json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: secret for "${ref}" not found` } });
71
+ return;
72
+ }
73
+ const plain = decryptSecret(raw);
74
+ json(res, 200, { ref, plaintext: plain, tail: keyTail(plain) });
75
+ } catch (err) {
76
+ json(res, 500, { error: { code: 'reveal-failed', message: String(err?.message ?? err) } });
77
+ }
78
+ return;
79
+ }
55
80
  if (!credentialsService || typeof credentialsService.set !== 'function') {
56
81
  json(res, 503, { error: { code: 'no-credentials', message: 'dsh-key-rotation: no credentials service is mounted' } });
57
82
  return;
package/lib/ops-paths.js CHANGED
@@ -10,3 +10,4 @@ export const TEST_PATH = '/dsh-key-rotation/test';
10
10
  export const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
11
11
  export const WEBHOOK_ACTION_PATH = '/dsh-key-rotation/webhook-action';
12
12
  export const METRICS_PATH = '/dsh-key-rotation/metrics';
13
+ export const DIAG_PATH = '/dsh-key-rotation/diag';
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,
@@ -126,6 +127,8 @@ ctx.effect(() => ctx.webServer.register({
126
127
  providerName: pool.provider ?? providerOfBase(pool.base),
127
128
  basePool: basePool?.base ?? null,
128
129
  model: pool.model ?? null,
130
+ pinnedRef: pool.pinnedRef ?? null,
131
+ labels: pool.labels ?? {},
129
132
  quotasConfigured: hasModelQuotaConfig(pool),
130
133
  keys,
131
134
  tags: providerTags?.get?.(pool.base) ?? [],
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  USAGE_PATH,
15
15
  SNAPSHOT_PATH,
16
+ DIAG_PATH,
16
17
  } from './ops-paths.js';
17
18
  import { findSecrets } from './keycheck.js';
18
19
  import { mutateSettings, sectionOps } from './settings-write.js';
@@ -21,6 +22,24 @@ import { mutateSettings, sectionOps } from './settings-write.js';
21
22
  * @param {object} ctx cordis context
22
23
  * @param {object} deps live dependencies from apply()
23
24
  */
25
+
26
+ const DIAG_BUFFER_LIMIT = 200;
27
+ const diagBuffer = [];
28
+
29
+ export function pushDiag(entry) {
30
+ if (diagBuffer.length >= DIAG_BUFFER_LIMIT) {
31
+ diagBuffer.shift();
32
+ }
33
+ diagBuffer.push({
34
+ timestamp: Date.now(),
35
+ ...entry,
36
+ });
37
+ }
38
+
39
+ export function getDiagBuffer() {
40
+ return diagBuffer.slice();
41
+ }
42
+
24
43
  export function registerTelemetryRoutes(ctx, deps) {
25
44
  const {
26
45
  buildRuntime,
@@ -111,4 +130,34 @@ ctx.effect(() => ctx.webServer.register({
111
130
  },
112
131
  }), 'dsh-key-rotation: snapshot route');
113
132
 
133
+ ctx.effect(() => ctx.webServer.register({
134
+ kind: 'exact',
135
+ path: DIAG_PATH,
136
+ handler: async (req, res) => {
137
+ if (req.method !== 'GET' && req.method !== 'POST') {
138
+ json(res, 405, { error: { code: 'method', message: 'GET or POST only' } });
139
+ return;
140
+ }
141
+ if (!isTrustedBridgeRequest(req)) {
142
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: diag is local-only' } });
143
+ return;
144
+ }
145
+ if (req.method === 'POST') {
146
+ let body;
147
+ try { body = await readJson(req); } catch (e) {
148
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
149
+ return;
150
+ }
151
+ pushDiag({
152
+ level: typeof body?.level === 'string' ? body.level : 'info',
153
+ message: typeof body?.message === 'string' ? body.message.slice(0, 500) : 'unknown',
154
+ details: body?.details ?? null,
155
+ });
156
+ json(res, 200, { ok: true, count: diagBuffer.length });
157
+ return;
158
+ }
159
+ json(res, 200, { diag: getDiagBuffer() });
160
+ },
161
+ }), 'dsh-key-rotation: diag route');
162
+
114
163
  }
@@ -33,6 +33,9 @@ export function buildPoolItem({
33
33
  model = null,
34
34
  quotas = null,
35
35
  quotaResetWindow = null,
36
+ labels = null,
37
+ pinnedRef = null,
38
+ useKeyRef = null,
36
39
  }) {
37
40
  const seen = new Set();
38
41
  const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
@@ -84,6 +87,21 @@ export function buildPoolItem({
84
87
  for (let i = 0; i < refs.length; i++) {
85
88
  weightsMap[refs[i]] = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
86
89
  }
90
+ const labelsMap = {};
91
+ if (Array.isArray(labels)) {
92
+ for (let i = 0; i < refs.length; i++) {
93
+ const lbl = labels[indexed[i].index];
94
+ if (typeof lbl === 'string' && lbl.trim()) labelsMap[refs[i]] = lbl.trim();
95
+ }
96
+ } else if (labels && typeof labels === 'object') {
97
+ for (let i = 0; i < refs.length; i++) {
98
+ const lbl = labels[refs[i]];
99
+ if (typeof lbl === 'string' && lbl.trim()) labelsMap[refs[i]] = lbl.trim();
100
+ }
101
+ }
102
+ const cleanPinned = typeof pinnedRef === 'string' && pinnedRef.trim()
103
+ ? pinnedRef.trim()
104
+ : (typeof useKeyRef === 'string' && useKeyRef.trim() ? useKeyRef.trim() : null);
87
105
  const normalizedQuotas = normalizeModelQuotas(quotas);
88
106
  return {
89
107
  base,
@@ -107,6 +125,8 @@ export function buildPoolItem({
107
125
  concurrencyLimit: typeof concurrencyLimit === 'number' && Number.isFinite(concurrencyLimit) && concurrencyLimit > 0 ? Math.floor(concurrencyLimit) : 0,
108
126
  routingStrategy: poolStrategy,
109
127
  proactiveRateLimitGuard: poolGuard,
128
+ labels: labelsMap,
129
+ pinnedRef: cleanPinned,
110
130
  };
111
131
  }
112
132
 
@@ -145,6 +165,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
145
165
  const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
146
166
  provider: p.provider,
147
167
  model: null,
168
+ labels: p.labels,
169
+ pinnedRef: p.pinnedRef ?? p.useKeyRef,
148
170
  }, provRpm, provTpm, provConcurrency);
149
171
  if (pool) {
150
172
  for (const ref of pool.refs) {
@@ -171,6 +193,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
171
193
  provider: p.provider,
172
194
  model,
173
195
  quotas: mp.quotas,
196
+ labels: mp.labels ?? p.labels,
197
+ pinnedRef: mp.pinnedRef ?? mp.useKeyRef ?? p.pinnedRef ?? p.useKeyRef,
174
198
  }, modelRpm, modelTpm, modelConcurrency);
175
199
  if (!mpool) continue;
176
200
  mpool.basePool = pool;
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/resolver.js CHANGED
@@ -125,7 +125,16 @@ export function createResolver({
125
125
  // original credential must never be used as a bypass. Decided once, before
126
126
  // the loop, so it cannot be re-evaluated mid-iteration.
127
127
  const quotaManaged = hasModelQuotaConfig(pool) && isQuotaManagedRequest(runtime.index, ref, requested);
128
- const start = strategy === 'round-robin' ? (pool.state.pointer ?? 0) : 0;
128
+ let start = strategy === 'round-robin' ? (pool.state.pointer ?? 0) : 0;
129
+ if (pool.pinnedRef && list.includes(pool.pinnedRef)) {
130
+ const pIdx = list.indexOf(pool.pinnedRef);
131
+ if (pIdx > 0) {
132
+ list = list.slice();
133
+ list.splice(pIdx, 1);
134
+ list.unshift(pool.pinnedRef);
135
+ }
136
+ start = 0;
137
+ }
129
138
 
130
139
  for (let i = 0; i < list.length; i++) {
131
140
  const index = (start + i) % list.length;
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,
@@ -89,6 +89,13 @@ export function createRotate(deps) {
89
89
  const quotaManaged = hasModelQuotaConfig(pool);
90
90
  let attemptList = (pool.weightedRefs ?? pool.refs).slice();
91
91
  attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r) && !isKeyBroken(pool, r, wallNow));
92
+ if (pool.pinnedRef && attemptList.includes(pool.pinnedRef)) {
93
+ const pIdx = attemptList.indexOf(pool.pinnedRef);
94
+ if (pIdx > 0) {
95
+ attemptList.splice(pIdx, 1);
96
+ attemptList.unshift(pool.pinnedRef);
97
+ }
98
+ }
92
99
  if (quotaManaged) {
93
100
  attemptList = attemptList.filter((ref) => isModelQuotaAvailable(pool, ref, wallNow));
94
101
  if (attemptList.length === 0) {
@@ -163,7 +170,7 @@ export function createRotate(deps) {
163
170
  /\b(?:insufficient[_\s-]+quota|quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota)\b/i.test(errMsg);
164
171
  const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
165
172
  if (isQuota && qWindow) {
166
- const quotaResetAt = nextQuotaReset(qWindow, now());
173
+ const quotaResetAt = poolResetAt(pool, qWindow, now());
167
174
  if (quotaResetAt && quotaResetAt > now()) {
168
175
  if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
169
176
  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.52",
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": [
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@deepseek-ai/cordis": "^4.0.1",
60
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.1",
60
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0",
61
61
  "@deepseek-ai/schemastery": "^3.18.4"
62
62
  },
63
63
  "scripts": {