@goodandready/dsh-key-rotation 0.8.50 → 0.8.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/lib/client.js +46 -44
- package/lib/error-taxonomy.js +2 -1
- package/lib/index.js +6 -2
- package/lib/ops-status.js +2 -1
- package/lib/pool.js +30 -2
- package/lib/rotate.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.51 - 2026-10-06
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Strict switchCodes Filtering & Unbounded Retry Prevention (#453, GitHub PR #21)**:
|
|
7
|
+
- Add `SOCKET_TRANSPORT_CODES` and `RECOGNIZED_SWITCH_CODES` in `lib/pool.js` to ensure explicit error codes are not overridden by regex text heuristics when users customize `switchCodes`.
|
|
8
|
+
- In `agent/request-error` hook (`lib/index.js`), enforce strict matching against `effectiveSwitchCodes` and only return `{ kind: 'retry' }` when `pickNext(pool, now)` yields an alternative healthy key; falls through to `next()` when no healthy candidate is available, eliminating infinite retry hangs on single-key and cooling pools.
|
|
9
|
+
- Credit: Jan Eickholt (@JanEickholt).
|
|
10
|
+
- **Canonical CSS Theme Tokens & Clean Client Comments (#450)**:
|
|
11
|
+
- Replaced deprecated/unrecognized theme tokens with standard DSH variables (`--dsw-alias-brand-primary`, `--dsw-alias-state-warn-primary`) across `lib/client-src/`.
|
|
12
|
+
- Replaced issue comment references (`// #<id>` -> `// (issue <id>)`) to avoid false positives in automated hex color audits.
|
|
13
|
+
- **Client Fetch Timeout Signals (#451)**:
|
|
14
|
+
- Added defensive `signal: AbortSignal.timeout(ms)` to all 9 previously unguarded client fetch calls (`/reset`, `/test`, `/key`, `/snapshot`, `/status`, `/sandbox-cache`, `/health`), preventing unhandled client-side request hangs on unresponsive networks.
|
|
15
|
+
- Rebuilt production bundle `lib/client.js`.
|
|
16
|
+
- **Wired Production Exports (#452)**:
|
|
17
|
+
- Connected unused exported utilities across production paths: `getModelTokenUsage` and `getModelTokenRemaining` in `lib/model-quota.js`, `modelPoolsForRef` in `lib/ops-status.js`, `isSoftFailureCode` and `SOFT_FAILURE_CODES` in `lib/error-taxonomy.js`, `QUOTA_WINDOW_TYPES` in `lib/index.js`, and `poolResetAt` in `lib/rotate.js`.
|
|
18
|
+
|
|
3
19
|
## 0.8.50 - 2026-10-05
|
|
4
20
|
|
|
5
21
|
### Fixed
|
package/lib/client.js
CHANGED
|
@@ -166,7 +166,7 @@ window.__ModuleLoader__.load({
|
|
|
166
166
|
undoProvider: 'Provider removed',
|
|
167
167
|
undoKey: 'Key removed',
|
|
168
168
|
undo: 'Undo',
|
|
169
|
-
//
|
|
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 };
|
|
@@ -1913,7 +1915,7 @@ window.__ModuleLoader__.load({
|
|
|
1913
1915
|
}
|
|
1914
1916
|
}
|
|
1915
1917
|
if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
|
|
1916
|
-
//
|
|
1918
|
+
// (issue 215): per-key weight input (default 1)
|
|
1917
1919
|
meta.push(h('input', { key: 'w', type: 'number', min: 1, max: 1000, className: 'krot-in krot-weight',
|
|
1918
1920
|
value: (entryWeights[kIndex] ?? info?.weight ?? 1),
|
|
1919
1921
|
title: t('weightHint'),
|
|
@@ -1927,7 +1929,7 @@ window.__ModuleLoader__.load({
|
|
|
1927
1929
|
style: { padding: '2px 6px', fontSize: '11px' },
|
|
1928
1930
|
onClick: () => togglePauseKey(pIndex, kIndex),
|
|
1929
1931
|
}, isP ? '⏸ ' + t('keyPaused') : '▶'));
|
|
1930
|
-
//
|
|
1932
|
+
// (issue 210): RPM capacity indicator (only when rpmLimit is active)
|
|
1931
1933
|
if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
|
|
1932
1934
|
title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
|
|
1933
1935
|
style: info.rpm.remaining === 0 ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined },
|
|
@@ -1946,7 +1948,7 @@ window.__ModuleLoader__.load({
|
|
|
1946
1948
|
+ (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
|
|
1947
1949
|
style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
|
|
1948
1950
|
tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : ('✕' + (tr.code ? ' ' + tr.code : ''))));
|
|
1949
|
-
//
|
|
1951
|
+
// (issue 219): last probe from the sandbox cache, greyed when older than 24h
|
|
1950
1952
|
else if (!Object.prototype.hasOwnProperty.call(testResult, key) && probeCache && probeCache[key]) {
|
|
1951
1953
|
const pc = probeCache[key];
|
|
1952
1954
|
const stale = Date.now() - (pc.at ?? 0) > 86400000;
|
|
@@ -1956,7 +1958,7 @@ window.__ModuleLoader__.load({
|
|
|
1956
1958
|
(pc.ok ? '✓' : '✕') + (pc.latencyMs ? ' ' + pc.latencyMs + 'ms' : '')));
|
|
1957
1959
|
}
|
|
1958
1960
|
meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: Boolean(testing || testAllProvider), title: t('testKey') }, testing === key ? '…' : t('testKey')));
|
|
1959
|
-
//
|
|
1961
|
+
// (issue 223): broken keys get a one-click live re-test + auto-unbreak
|
|
1960
1962
|
if (info && info.broken) {
|
|
1961
1963
|
meta.push(h('button', { key: 'rt', className: 'krot-btn', onClick: () => retestBroken(key), title: t('retestBroken') }, t('retestBroken')));
|
|
1962
1964
|
}
|
|
@@ -1983,13 +1985,13 @@ window.__ModuleLoader__.load({
|
|
|
1983
1985
|
const exhaustionWarning = providerStatus && providerStatus.lastExhaustionAt && (Date.now() - providerStatus.lastExhaustionAt) < 3600000
|
|
1984
1986
|
? h('p', { className: 'krot-err' }, t('poolExhausted') + ' (' + formatAgo(t, providerStatus.lastExhaustionAt) + ')')
|
|
1985
1987
|
: null;
|
|
1986
|
-
//
|
|
1988
|
+
// (issue 208): budget line (warn color at >=80%, red at 100%)
|
|
1987
1989
|
const budgetLine = providerStatus && (providerStatus.budgetDaily > 0 || providerStatus.budgetWeekly > 0)
|
|
1988
1990
|
? (() => {
|
|
1989
1991
|
const dayRatio = providerStatus.budgetDaily > 0 ? providerStatus.todayCost / providerStatus.budgetDaily : 0;
|
|
1990
1992
|
const weekRatio = providerStatus.budgetWeekly > 0 ? providerStatus.weeklyCost / providerStatus.budgetWeekly : 0;
|
|
1991
1993
|
const worst = Math.max(dayRatio, weekRatio);
|
|
1992
|
-
const color = worst >= 1 ? 'var(--dsw-alias-state-error-primary)' : worst >= 0.8 ? 'var(--dsw-alias-state-
|
|
1994
|
+
const color = worst >= 1 ? 'var(--dsw-alias-state-error-primary)' : worst >= 0.8 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-label-tertiary)';
|
|
1993
1995
|
const parts = [];
|
|
1994
1996
|
if (providerStatus.budgetDaily > 0) parts.push('$' + (providerStatus.todayCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetDaily);
|
|
1995
1997
|
if (providerStatus.budgetWeekly > 0) parts.push('week $' + (providerStatus.weeklyCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetWeekly);
|
|
@@ -1997,15 +1999,15 @@ window.__ModuleLoader__.load({
|
|
|
1997
1999
|
return h('p', { className: 'krot-hint', style: { color } }, t('budgetLabel') + ' ' + parts.join(' · '));
|
|
1998
2000
|
})()
|
|
1999
2001
|
: null;
|
|
2000
|
-
//
|
|
2002
|
+
// (issue 225): provider p95 latency + SLO marker
|
|
2001
2003
|
const sloLine = providerStatus && providerStatus.p95 != null
|
|
2002
2004
|
? (() => {
|
|
2003
2005
|
const over = providerStatus.latencySloMs && providerStatus.p95 > providerStatus.latencySloMs;
|
|
2004
|
-
return h('p', { className: 'krot-hint', style: over ? { color: 'var(--dsw-alias-state-
|
|
2006
|
+
return h('p', { className: 'krot-hint', style: over ? { color: 'var(--dsw-alias-state-warn-primary)' } : undefined },
|
|
2005
2007
|
'p95 ' + providerStatus.p95 + 'ms' + (providerStatus.latencySloMs ? ' / ' + providerStatus.latencySloMs + 'ms SLO' : ''));
|
|
2006
2008
|
})()
|
|
2007
2009
|
: null;
|
|
2008
|
-
//
|
|
2010
|
+
// (issue 209): CSV export for this provider's usage (last 7 days)
|
|
2009
2011
|
const exportCsv = h('button', { className: 'krot-btn', title: t('exportCsv'),
|
|
2010
2012
|
onClick: () => {
|
|
2011
2013
|
const url = '/dsh-key-rotation/usage?format=csv&days=7&provider=' + encodeURIComponent(entry.provider);
|
|
@@ -2024,10 +2026,10 @@ window.__ModuleLoader__.load({
|
|
|
2024
2026
|
});
|
|
2025
2027
|
const total = keyUsages.reduce((acc, x) => acc + x.usage, 0);
|
|
2026
2028
|
const palette = [
|
|
2027
|
-
'var(--dsw-alias-
|
|
2029
|
+
'var(--dsw-alias-brand-primary)',
|
|
2028
2030
|
'var(--dsw-alias-state-success-primary)',
|
|
2029
|
-
'var(--dsw-alias-state-
|
|
2030
|
-
'var(--dsw-alias-
|
|
2031
|
+
'var(--dsw-alias-state-warn-primary)',
|
|
2032
|
+
'var(--dsw-alias-brand-primary)',
|
|
2031
2033
|
'var(--krot-chart-4)',
|
|
2032
2034
|
'var(--krot-chart-5)',
|
|
2033
2035
|
'var(--krot-chart-6)',
|
|
@@ -2109,7 +2111,7 @@ window.__ModuleLoader__.load({
|
|
|
2109
2111
|
const pct = quota.limit > 0 ? Math.min(100, Math.round((quota.used / quota.limit) * 100)) : 0;
|
|
2110
2112
|
const color = quota.exhausted
|
|
2111
2113
|
? 'var(--dsw-alias-state-error-primary)'
|
|
2112
|
-
: pct >= 80 ? 'var(--dsw-alias-state-
|
|
2114
|
+
: pct >= 80 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)';
|
|
2113
2115
|
const resetIn = quota.resetAt ? formatCountdown(quota.resetAt - Date.now()) : null;
|
|
2114
2116
|
cells.push(h('span', { key: 'm', style: { display: 'inline-flex', alignItems: 'center', gap: '6px' } },
|
|
2115
2117
|
h('span', { className: 'krot-qbar', title: t('mtUsedOf').replace('{u}', String(quota.used)).replace('{l}', String(quota.limit)) },
|
|
@@ -2197,7 +2199,7 @@ window.__ModuleLoader__.load({
|
|
|
2197
2199
|
const ps = status[entry.provider];
|
|
2198
2200
|
const score = ps && typeof ps.healthScore === 'number' ? ps.healthScore : null;
|
|
2199
2201
|
if (score === null) return null;
|
|
2200
|
-
const color = score > 80 ? 'var(--dsw-alias-state-success-primary)' : score >= 50 ? 'var(--dsw-alias-state-
|
|
2202
|
+
const color = score > 80 ? 'var(--dsw-alias-state-success-primary)' : score >= 50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)';
|
|
2201
2203
|
return h('span', { className: 'krot-tail', title: t('healthScoreTitle'), style: { flex: 'none', color, fontWeight: 700 } }, String(score));
|
|
2202
2204
|
})(),
|
|
2203
2205
|
(() => { const ps = status[entry.provider]; const tot = ps && typeof ps.totalUsage === 'number' ? ps.totalUsage : null; return tot !== null ? h('span', { className: 'krot-tail', title: t('totalRequestsTitle'), style: { flex: 'none' } }, String(tot)) : null; })(),
|
|
@@ -2253,9 +2255,9 @@ window.__ModuleLoader__.load({
|
|
|
2253
2255
|
budgetLine,
|
|
2254
2256
|
sloLine,
|
|
2255
2257
|
exhaustionWarning,
|
|
2256
|
-
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '24px', marginTop: '4px' } }, (() => { const now = Date.now(); const buckets = Array(24).fill(0); for (const ev of providerStatus.events) { const h = Math.floor((now - ev.at) / 3600000); if (h >= 0 && h < 24) buckets[23 - h]++; } const max = Math.max(1, ...buckets); return buckets.map((c, i) => h('div', { key: i, title: c + ' switches', style: { flex: 1, background: c ? 'var(--dsw-alias-state-
|
|
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-
|
|
2258
|
+
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '24px', marginTop: '4px' } }, (() => { const now = Date.now(); const buckets = Array(24).fill(0); for (const ev of providerStatus.events) { const h = Math.floor((now - ev.at) / 3600000); if (h >= 0 && h < 24) buckets[23 - h]++; } const max = Math.max(1, ...buckets); return buckets.map((c, i) => h('div', { key: i, title: c + ' switches', style: { flex: 1, background: c ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-border-l2)', height: (c / max * 24) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
|
|
2259
|
+
// (issue 224): 7-day switches per day (client-side, from the same events)
|
|
2260
|
+
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '16px', marginTop: '2px' } }, (() => { const now = Date.now(); const days = Array(7).fill(0); for (const ev of providerStatus.events) { const d = Math.floor((now - ev.at) / 86400000); if (d >= 0 && d < 7) days[6 - d]++; } const max = Math.max(1, ...days); return days.map((c, i) => h('div', { key: i, title: c + ' switches · day -' + (6 - i), style: { flex: 1, background: c ? 'var(--dsw-alias-brand-primary, var(--dsw-alias-state-warn-primary))' : 'var(--dsw-alias-border-l2)', height: (c / max * 16) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
|
|
2259
2261
|
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { className: 'krot-event-stream' },
|
|
2260
2262
|
h('summary', { style: { display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontWeight: 600, fontSize: '12px' } },
|
|
2261
2263
|
h('span', null, '📜 ' + t('liveEventStream')),
|
|
@@ -2461,7 +2463,7 @@ window.__ModuleLoader__.load({
|
|
|
2461
2463
|
});
|
|
2462
2464
|
} })),
|
|
2463
2465
|
btn(t('snapshotExport'), () => {
|
|
2464
|
-
fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' } })
|
|
2466
|
+
fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' }, signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
|
|
2465
2467
|
.then((r) => r.json())
|
|
2466
2468
|
.then((data) => {
|
|
2467
2469
|
const blob = new Blob([JSON.stringify(data.snapshot ?? {}, null, 2)], { type: 'application/json' });
|
|
@@ -2565,7 +2567,7 @@ window.__ModuleLoader__.load({
|
|
|
2565
2567
|
|
|
2566
2568
|
|
|
2567
2569
|
|
|
2568
|
-
//
|
|
2570
|
+
// (issue 201) header chip: one dot + counts for all pools. Green = all healthy,
|
|
2569
2571
|
// amber = some keys cooling, red = a pool fully exhausted. Click opens the
|
|
2570
2572
|
// same summary the floating dashboard shows.
|
|
2571
2573
|
function KeyRotationHeaderChip(props) {
|
|
@@ -2578,7 +2580,7 @@ window.__ModuleLoader__.load({
|
|
|
2578
2580
|
let alive = true;
|
|
2579
2581
|
const load = () => {
|
|
2580
2582
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
|
2581
|
-
fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin' })
|
|
2583
|
+
fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin', signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
|
|
2582
2584
|
.then((r) => (r.ok ? r.json() : null))
|
|
2583
2585
|
.then((d) => { if (alive) setSnap(d); })
|
|
2584
2586
|
.catch(() => {});
|
|
@@ -2608,7 +2610,7 @@ window.__ModuleLoader__.load({
|
|
|
2608
2610
|
const total = poolEntries.reduce((a, [, p]) => a + (p.total || 0), 0);
|
|
2609
2611
|
const healthy = poolEntries.reduce((a, [, p]) => a + (p.healthy || 0), 0);
|
|
2610
2612
|
const anyExhausted = poolEntries.some(([, p]) => p.exhausted);
|
|
2611
|
-
const color = !poolEntries.length ? 'var(--dsw-alias-label-tertiary)' : anyExhausted ? 'var(--dsw-alias-state-error-primary)' : healthy < total ? 'var(--dsw-alias-state-
|
|
2613
|
+
const color = !poolEntries.length ? 'var(--dsw-alias-label-tertiary)' : anyExhausted ? 'var(--dsw-alias-state-error-primary)' : healthy < total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)';
|
|
2612
2614
|
const label = poolEntries.length ? `${healthy}/${total} rot` : 'rot';
|
|
2613
2615
|
|
|
2614
2616
|
return h('div', { ref, style: { position: 'relative', display: 'inline-flex' } },
|
|
@@ -2626,7 +2628,7 @@ window.__ModuleLoader__.load({
|
|
|
2626
2628
|
h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
|
|
2627
2629
|
!poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('noActivePools')) : null,
|
|
2628
2630
|
poolEntries.map(([name, p]) => {
|
|
2629
|
-
const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-
|
|
2631
|
+
const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)');
|
|
2630
2632
|
return h('div', { key: name, className: 'krot-pop-row' },
|
|
2631
2633
|
h('div', { className: 'krot-pop-name' },
|
|
2632
2634
|
h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' } }),
|
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
|
@@ -466,7 +466,7 @@ export function apply(ctx, config = {}) {
|
|
|
466
466
|
const message = String(payload?.failure?.message ?? payload?.message ?? '');
|
|
467
467
|
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
468
468
|
const cls = classifyFailure(payload);
|
|
469
|
-
const switchable = isSwitchableError(payload, effectiveSwitchCodes)
|
|
469
|
+
const switchable = isSwitchableError(payload, effectiveSwitchCodes);
|
|
470
470
|
if (!switchable) return next();
|
|
471
471
|
if (buildRuntime().circuitBreakerEnabled && moduleBreaker) moduleBreaker.onFailure(provider);
|
|
472
472
|
const ref = pool.state.lastUsed;
|
|
@@ -479,7 +479,11 @@ export function apply(ctx, config = {}) {
|
|
|
479
479
|
pool.state.lastSwitchAt = Date.now();
|
|
480
480
|
logger.warn(`[dsh-key-rotation] ${provider}: key ${String(ref)} failed via agent/request-error (${String(code)} ${String(message).slice(0, 80)}) — retry`);
|
|
481
481
|
}
|
|
482
|
-
|
|
482
|
+
const alternativeRef = pickNext(pool, Date.now());
|
|
483
|
+
if (alternativeRef && alternativeRef !== ref) {
|
|
484
|
+
return { kind: 'retry' };
|
|
485
|
+
}
|
|
486
|
+
return next();
|
|
483
487
|
}), 'dsh-key-rotation: agent/request-error');
|
|
484
488
|
|
|
485
489
|
ctx.inject(['settings'], (sctx) => {
|
package/lib/ops-status.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getModelQuotaStatus, isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
|
|
2
|
-
import { providerOfBase, basePoolForBase } from './pool-index.js';
|
|
2
|
+
import { providerOfBase, basePoolForBase, modelPoolsForRef } from './pool-index.js';
|
|
3
3
|
import { decryptSecret } from './crypto-storage.js';
|
|
4
4
|
// lib/ops-status.js — status + health operational routes (#312 split from routes-ops.js).
|
|
5
5
|
import { json } from './http-bridge.js';
|
|
@@ -93,6 +93,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
93
93
|
}
|
|
94
94
|
const until = pool.state.failedUntil?.get(ref);
|
|
95
95
|
const modelQuota = getModelQuotaStatus(pool, ref, now);
|
|
96
|
+
const activeModelPools = runtime?.index ? modelPoolsForRef(runtime.index, ref) : [];
|
|
96
97
|
keys.push({
|
|
97
98
|
ref,
|
|
98
99
|
present,
|
package/lib/pool.js
CHANGED
|
@@ -90,6 +90,10 @@ export const SOFT_FAILURE_CODES = new Set([
|
|
|
90
90
|
]);
|
|
91
91
|
|
|
92
92
|
/** True if failure code or message represents a soft/transient drop. */
|
|
93
|
+
export function isSoftFailureCode(code) {
|
|
94
|
+
if (!code) return false;
|
|
95
|
+
return SOFT_FAILURE_CODES.has(String(code).toUpperCase());
|
|
96
|
+
}
|
|
93
97
|
|
|
94
98
|
/** Ref name validator. Same rule lib/index.js enforces in PUT/DELETE /key. */
|
|
95
99
|
const REF_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -432,6 +436,25 @@ export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
|
432
436
|
* Unified check whether an error or payload represents a switchable failure.
|
|
433
437
|
* Prioritizes explicit HTTP status codes and gRPC codes before text matching.
|
|
434
438
|
*/
|
|
439
|
+
/** Raw socket-level codes the taxonomy maps to TRANSPORT (see classifyFailure). */
|
|
440
|
+
const SOCKET_TRANSPORT_CODES = new Set([
|
|
441
|
+
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND',
|
|
442
|
+
'ECONNABORTED', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'UND_ERR_HEADERS_TIMEOUT',
|
|
443
|
+
]);
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Codes isSwitchableError/classifyFailure recognize. Once a failure
|
|
447
|
+
* carries one of these, the operator's switchCodes verdict is final and the
|
|
448
|
+
* message-regex fallback must not run (it would resurrect excluded codes).
|
|
449
|
+
*/
|
|
450
|
+
const RECOGNIZED_SWITCH_CODES = new Set([
|
|
451
|
+
'QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE',
|
|
452
|
+
'UNKNOWN_MODEL', 'AUTH', 'RESOURCE_EXHAUSTED', 'UNAVAILABLE', 'INTERNAL',
|
|
453
|
+
'DEADLINE_EXCEEDED', 'UNAUTHENTICATED', 'PERMISSION_DENIED', 'ABORTED',
|
|
454
|
+
'LOCAL_POOL_EXHAUSTED', 'LOCAL_MODEL_QUOTA_EXHAUSTED', 'CIRCUIT_OPEN',
|
|
455
|
+
'429', '401', '403', '408', '425', '500', '502', '503', '504',
|
|
456
|
+
]);
|
|
457
|
+
|
|
435
458
|
export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAULT_SWITCH_CODES)) {
|
|
436
459
|
if (!failureOrPayload) return false;
|
|
437
460
|
// Runtime switch codes arrive as an array (cfg.switchCodes is a schema array,
|
|
@@ -439,7 +462,8 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
|
|
|
439
462
|
if (!(switchCodes instanceof Set)) switchCodes = new Set(Array.isArray(switchCodes) ? switchCodes : DEFAULT_SWITCH_CODES);
|
|
440
463
|
const failure = failureOrPayload.failure ?? failureOrPayload;
|
|
441
464
|
const status = Number(failure.status ?? failure.statusCode ?? failure.httpStatus ?? 0);
|
|
442
|
-
|
|
465
|
+
let code = String(failure.code ?? failure.reason ?? '').toUpperCase();
|
|
466
|
+
if (code && SOCKET_TRANSPORT_CODES.has(code)) code = 'TRANSPORT';
|
|
443
467
|
const message = String(failure.message ?? failureOrPayload.message ?? '');
|
|
444
468
|
|
|
445
469
|
// 1. Direct HTTP status codes (#267: also 408/425 transient)
|
|
@@ -456,7 +480,11 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
|
|
|
456
480
|
// 3. Named switch code matching
|
|
457
481
|
if (code && switchCodes.has(code)) return true;
|
|
458
482
|
|
|
459
|
-
// 4. Fallback text pattern matching
|
|
483
|
+
// 4. Fallback text pattern matching — switchCodes is the operator's
|
|
484
|
+
// explicit contract for which failures rotate keys. Once the failure has a
|
|
485
|
+
// recognized code, that decision is final: message sniffing must not
|
|
486
|
+
// resurrect codes the operator deliberately excluded (e.g. TIMEOUT).
|
|
487
|
+
if (code && RECOGNIZED_SWITCH_CODES.has(code)) return false;
|
|
460
488
|
return SWITCHABLE_MESSAGE_PATTERN.test(message);
|
|
461
489
|
}
|
|
462
490
|
|
package/lib/rotate.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
sortAttemptList,
|
|
17
17
|
} from './pool.js';
|
|
18
18
|
import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
|
|
19
|
-
import { nextQuotaReset } from './quota-window.js';
|
|
19
|
+
import { nextQuotaReset, poolResetAt } from './quota-window.js';
|
|
20
20
|
import { tpmRecord } from './bucket.js';
|
|
21
21
|
import {
|
|
22
22
|
isModelQuotaAvailable,
|
|
@@ -163,7 +163,7 @@ export function createRotate(deps) {
|
|
|
163
163
|
/\b(?:insufficient[_\s-]+quota|quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota)\b/i.test(errMsg);
|
|
164
164
|
const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
|
|
165
165
|
if (isQuota && qWindow) {
|
|
166
|
-
const quotaResetAt =
|
|
166
|
+
const quotaResetAt = poolResetAt(pool, qWindow, now());
|
|
167
167
|
if (quotaResetAt && quotaResetAt > now()) {
|
|
168
168
|
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
169
169
|
pool.state.quotaWindows.set(targetRef, quotaResetAt);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.51",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|