@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 +38 -0
- package/lib/client.js +52 -44
- package/lib/error-taxonomy.js +2 -1
- package/lib/index.js +8 -2
- package/lib/ops-keys.js +27 -2
- package/lib/ops-paths.js +1 -0
- package/lib/ops-status.js +4 -1
- package/lib/ops-telemetry.js +49 -0
- package/lib/pool-builder.js +24 -0
- package/lib/pool.js +30 -2
- package/lib/resolver.js +10 -1
- package/lib/rotate.js +9 -2
- package/package.json +2 -2
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
|
-
//
|
|
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
|
-
//
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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-
|
|
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
|
-
//
|
|
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-
|
|
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
|
-
//
|
|
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-
|
|
2035
|
+
'var(--dsw-alias-brand-primary)',
|
|
2028
2036
|
'var(--dsw-alias-state-success-primary)',
|
|
2029
|
-
'var(--dsw-alias-state-
|
|
2030
|
-
'var(--dsw-alias-
|
|
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-
|
|
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-
|
|
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-
|
|
2257
|
-
//
|
|
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-
|
|
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
|
-
//
|
|
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-
|
|
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-
|
|
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' } }),
|
package/lib/error-taxonomy.js
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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) ?? [],
|
package/lib/ops-telemetry.js
CHANGED
|
@@ -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
|
}
|
package/lib/pool-builder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 ||
|
|
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": {
|