@goodandready/dsh-key-rotation 0.8.5 → 0.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/circuit-breaker.js +19 -1
- package/lib/client.js +153 -20
- package/lib/index.js +80 -0
- package/lib/persistence.js +146 -0
- package/lib/routes-ops.js +3 -2
- package/lib/sanitize-snapshot.js +83 -0
- package/package.json +1 -1
package/lib/circuit-breaker.js
CHANGED
|
@@ -90,4 +90,22 @@ export class CircuitBreaker {
|
|
|
90
90
|
if (provider) this._st.delete(provider);
|
|
91
91
|
else this._st.clear();
|
|
92
92
|
}
|
|
93
|
-
|
|
93
|
+
|
|
94
|
+
/** Restore entries from a persistence snapshot (#287). */
|
|
95
|
+
restore(snapshot) {
|
|
96
|
+
if (!snapshot || typeof snapshot !== 'object') return 0;
|
|
97
|
+
let n = 0;
|
|
98
|
+
for (const [provider, e] of Object.entries(snapshot)) {
|
|
99
|
+
if (!e || typeof e !== 'object') continue;
|
|
100
|
+
const state = e.state === BREAKER_OPEN || e.state === BREAKER_HALF_OPEN ? e.state : BREAKER_CLOSED;
|
|
101
|
+
this._st.set(provider, {
|
|
102
|
+
state,
|
|
103
|
+
fails: Number.isFinite(e.fails) ? e.fails : 0,
|
|
104
|
+
openedAt: Number.isFinite(e.openedAt) ? e.openedAt : 0,
|
|
105
|
+
probes: 0,
|
|
106
|
+
});
|
|
107
|
+
n += 1;
|
|
108
|
+
}
|
|
109
|
+
return n;
|
|
110
|
+
}
|
|
111
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -116,6 +116,31 @@ window.__ModuleLoader__.load({
|
|
|
116
116
|
undoProvider: 'Provider removed',
|
|
117
117
|
undoKey: 'Key removed',
|
|
118
118
|
undo: 'Undo',
|
|
119
|
+
// #289 a11y / #290 states / #291 bulk / #292 locale
|
|
120
|
+
emptyTitle: 'No pools yet',
|
|
121
|
+
emptyDesc: 'Add a provider and at least one API key name to start rotating.',
|
|
122
|
+
errorTitle: 'Could not load settings',
|
|
123
|
+
retry: 'Retry',
|
|
124
|
+
unavailableTitle: 'Settings bridge unavailable',
|
|
125
|
+
unavailableDesc: 'The host did not expose the dsh-key-rotation settings namespace. Reopen Settings or check the plugin is enabled.',
|
|
126
|
+
modalClose: 'Close dialog',
|
|
127
|
+
dialogLabel: 'Confirmation',
|
|
128
|
+
loadChartAria: 'Key traffic distribution',
|
|
129
|
+
bulkCooldownPlaceholder: 'Bulk cooldown (ms)',
|
|
130
|
+
bulkApply: 'Apply to selected',
|
|
131
|
+
bulkRemove: 'Remove selected',
|
|
132
|
+
confirmBulkRemoveTitle: 'Remove {n} provider(s)?',
|
|
133
|
+
confirmBulkRemoveDesc: 'Selected providers and all their keys will be removed from rotation. You can undo once immediately after.',
|
|
134
|
+
selectProvider: 'Select provider {p}',
|
|
135
|
+
activeBadge: 'active',
|
|
136
|
+
healthScoreTitle: 'health score',
|
|
137
|
+
totalRequestsTitle: 'total requests',
|
|
138
|
+
exportProvider: 'Export this provider',
|
|
139
|
+
recentFailures: 'Recent failures ({n})',
|
|
140
|
+
noActivePools: 'No active pools',
|
|
141
|
+
headerPoolsTitle: 'Key rotation pools',
|
|
142
|
+
pausedBudget: 'paused',
|
|
143
|
+
sortUsage: 'Sort by usage',
|
|
119
144
|
};
|
|
120
145
|
|
|
121
146
|
// Коды, на которых имеет смысл переключать ключ. Список из хоста
|
|
@@ -409,6 +434,12 @@ window.__ModuleLoader__.load({
|
|
|
409
434
|
getScopeSnapshot
|
|
410
435
|
);
|
|
411
436
|
const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
|
|
437
|
+
// #290: surface settingsScope availability as card state
|
|
438
|
+
React.useEffect(() => {
|
|
439
|
+
if (!settingsScope) {
|
|
440
|
+
setState((s) => (s.status === 'unavailable' ? s : { ...s, status: 'unavailable' }));
|
|
441
|
+
}
|
|
442
|
+
}, [settingsScope]);
|
|
412
443
|
const [draft, setDraft] = React.useState(null);
|
|
413
444
|
// ── all hooks live ABOVE any early return (React error 310 otherwise) ──
|
|
414
445
|
const [search, setSearch] = React.useState('');
|
|
@@ -419,6 +450,46 @@ window.__ModuleLoader__.load({
|
|
|
419
450
|
const [bulkCooldown, setBulkCooldown] = React.useState('');
|
|
420
451
|
const [undo, setUndo] = React.useState(null);
|
|
421
452
|
const undoTimer = React.useRef(null);
|
|
453
|
+
// #289 a11y: confirm dialog focus trap / Escape / restore (hooks above returns)
|
|
454
|
+
const modalReturnFocusRef = React.useRef(null);
|
|
455
|
+
const modalCardRef = React.useRef(null);
|
|
456
|
+
React.useEffect(() => {
|
|
457
|
+
if (!confirmModal) return undefined;
|
|
458
|
+
modalReturnFocusRef.current = typeof document !== 'undefined' ? document.activeElement : null;
|
|
459
|
+
const onKey = (e) => {
|
|
460
|
+
if (e.key === 'Escape') {
|
|
461
|
+
e.stopPropagation();
|
|
462
|
+
setConfirmModal(null);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (e.key !== 'Tab' || !modalCardRef.current) return;
|
|
466
|
+
const focusables = modalCardRef.current.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
467
|
+
if (!focusables.length) return;
|
|
468
|
+
const first = focusables[0];
|
|
469
|
+
const last = focusables[focusables.length - 1];
|
|
470
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
471
|
+
e.preventDefault();
|
|
472
|
+
last.focus();
|
|
473
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
474
|
+
e.preventDefault();
|
|
475
|
+
first.focus();
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
document.addEventListener('keydown', onKey, true);
|
|
479
|
+
const id = requestAnimationFrame(() => {
|
|
480
|
+
if (!modalCardRef.current) return;
|
|
481
|
+
const cancelBtn = modalCardRef.current.querySelector('[data-krot-modal-cancel]');
|
|
482
|
+
(cancelBtn || modalCardRef.current.querySelector('button'))?.focus();
|
|
483
|
+
});
|
|
484
|
+
return () => {
|
|
485
|
+
document.removeEventListener('keydown', onKey, true);
|
|
486
|
+
cancelAnimationFrame(id);
|
|
487
|
+
const prev = modalReturnFocusRef.current;
|
|
488
|
+
if (prev && typeof prev.focus === 'function') {
|
|
489
|
+
try { prev.focus(); } catch (_) {}
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
}, [confirmModal]);
|
|
422
493
|
const [testing, setTesting] = React.useState('');
|
|
423
494
|
const [testResult, setTestResult] = React.useState({});
|
|
424
495
|
const [testAllProvider, setTestAllProvider] = React.useState('');
|
|
@@ -586,8 +657,31 @@ window.__ModuleLoader__.load({
|
|
|
586
657
|
})
|
|
587
658
|
.catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
|
|
588
659
|
};
|
|
589
|
-
|
|
590
|
-
|
|
660
|
+
// #290: explicit card states — loading / error / unavailable / empty / ready
|
|
661
|
+
if (state.status === 'loading' || (!val && state.status !== 'error' && state.status !== 'unavailable')) {
|
|
662
|
+
return React.createElement('div', { className: 'krot-state-block', role: 'status', 'aria-live': 'polite', style: { padding: '18px 4px', display: 'flex', flexDirection: 'column', gap: '8px' } },
|
|
663
|
+
React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13, margin: 0 } }, t('loading')),
|
|
664
|
+
React.createElement('div', {
|
|
665
|
+
className: 'krot-skeleton',
|
|
666
|
+
'aria-hidden': 'true',
|
|
667
|
+
style: {
|
|
668
|
+
height: 10, borderRadius: 999, background: 'linear-gradient(90deg, var(--dsw-alias-bg-layer-2), var(--dsw-alias-bg-layer-3), var(--dsw-alias-bg-layer-2))',
|
|
669
|
+
backgroundSize: '200% 100%', animation: 'krot-shimmer 1.2s ease-in-out infinite', maxWidth: 280,
|
|
670
|
+
},
|
|
671
|
+
})
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
if (state.status === 'error') {
|
|
675
|
+
return React.createElement('div', { className: 'krot-state-block', role: 'alert', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '10px', alignItems: 'flex-start' } },
|
|
676
|
+
React.createElement('p', { className: 'krot-err', style: { margin: 0 } }, t('errorTitle') + (state.error ? (': ' + state.error) : '')),
|
|
677
|
+
React.createElement('button', { type: 'button', className: 'krot-btn', onClick: () => load() }, t('retry'))
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
if (state.status === 'unavailable' || !settingsScope) {
|
|
681
|
+
return React.createElement('div', { className: 'krot-state-block', role: 'status', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
682
|
+
React.createElement('p', { style: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary)', margin: 0 } }, t('unavailableTitle')),
|
|
683
|
+
React.createElement('p', { className: 'krot-hint', style: { margin: 0 } }, t('unavailableDesc'))
|
|
684
|
+
);
|
|
591
685
|
}
|
|
592
686
|
|
|
593
687
|
const providers = state.providers;
|
|
@@ -914,7 +1008,7 @@ window.__ModuleLoader__.load({
|
|
|
914
1008
|
const parts = [];
|
|
915
1009
|
if (providerStatus.budgetDaily > 0) parts.push('$' + (providerStatus.todayCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetDaily);
|
|
916
1010
|
if (providerStatus.budgetWeekly > 0) parts.push('week $' + (providerStatus.weeklyCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetWeekly);
|
|
917
|
-
if (worst >= 1 && providerStatus.pauseOnBudget) parts.push('·
|
|
1011
|
+
if (worst >= 1 && providerStatus.pauseOnBudget) parts.push('· ' + t('pausedBudget'));
|
|
918
1012
|
return h('p', { className: 'krot-hint', style: { color } }, t('budgetLabel') + ' ' + parts.join(' · '));
|
|
919
1013
|
})()
|
|
920
1014
|
: null;
|
|
@@ -956,6 +1050,8 @@ window.__ModuleLoader__.load({
|
|
|
956
1050
|
return h('div', { className: 'krot-load-chart' },
|
|
957
1051
|
h('div', { className: 'krot-load-header' },
|
|
958
1052
|
h('span', { style: { fontWeight: 600 } }, '📊 ' + t('loadDistribution')),
|
|
1053
|
+
h('span', { className: 'krot-sr-only', style: { position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', border: 0 } },
|
|
1054
|
+
t('loadChartAria') + ': ' + (total > 0 ? keyUsages.map((x) => t('keyLabel').replace('{n}', String(x.index + 1)) + ' ' + x.usage).join(', ') : t('noTrafficYet'))),
|
|
959
1055
|
h('span', null, total > 0 ? (total + ' ' + t('statRequests')) : t('noTrafficYet'))
|
|
960
1056
|
),
|
|
961
1057
|
h('div', { className: 'krot-load-bar' },
|
|
@@ -988,13 +1084,18 @@ window.__ModuleLoader__.load({
|
|
|
988
1084
|
|
|
989
1085
|
return h('div', { key: pIndex, className: 'krot-prov' },
|
|
990
1086
|
h('div', { className: 'krot-prov-head' },
|
|
991
|
-
h('input', {
|
|
1087
|
+
h('input', {
|
|
1088
|
+
type: 'checkbox',
|
|
1089
|
+
'aria-label': t('selectProvider').replace('{p}', entry.provider),
|
|
1090
|
+
checked: selected.has(entry.provider),
|
|
1091
|
+
onChange: (e) => { const ns = new Set(selected); if (e.target.checked) ns.add(entry.provider); else ns.delete(entry.provider); setSelected(ns); },
|
|
1092
|
+
}),
|
|
992
1093
|
btn(t('exportOne'), () => {
|
|
993
1094
|
const data = JSON.stringify([entry], null, 2);
|
|
994
1095
|
const blob = new Blob([data], { type: 'application/json' });
|
|
995
1096
|
const url = URL.createObjectURL(blob);
|
|
996
1097
|
const a = document.createElement('a'); a.href = url; a.download = entry.provider + '.json'; a.click(); URL.revokeObjectURL(url);
|
|
997
|
-
}, { title: '
|
|
1098
|
+
}, { title: t('exportProvider') }),
|
|
998
1099
|
btn('⇅', () => {
|
|
999
1100
|
const ps = status[entry.provider];
|
|
1000
1101
|
if (!ps || !Array.isArray(ps.keys)) return;
|
|
@@ -1006,16 +1107,16 @@ window.__ModuleLoader__.load({
|
|
|
1006
1107
|
next[pIndex] = { ...next[pIndex], keys: sorted };
|
|
1007
1108
|
return { ...cur, providers: next };
|
|
1008
1109
|
});
|
|
1009
|
-
}, { title: '
|
|
1110
|
+
}, { title: t('sortUsage') }),
|
|
1010
1111
|
h('select', { className: 'krot-in', value: entry.provider, onChange: (e) => setProvider(pIndex, e.target.value) }, options),
|
|
1011
1112
|
(() => {
|
|
1012
1113
|
const ps = status[entry.provider];
|
|
1013
1114
|
const score = ps && typeof ps.healthScore === 'number' ? ps.healthScore : null;
|
|
1014
1115
|
if (score === null) return null;
|
|
1015
1116
|
const color = score > 80 ? 'var(--dsw-alias-state-success-primary)' : score >= 50 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-error-primary)';
|
|
1016
|
-
return h('span', { className: 'krot-tail', title: '
|
|
1117
|
+
return h('span', { className: 'krot-tail', title: t('healthScoreTitle'), style: { flex: 'none', color, fontWeight: 700 } }, String(score));
|
|
1017
1118
|
})(),
|
|
1018
|
-
(() => { const ps = status[entry.provider]; const tot = ps && typeof ps.totalUsage === 'number' ? ps.totalUsage : null; return tot !== null ? h('span', { className: 'krot-tail', title: '
|
|
1119
|
+
(() => { 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; })(),
|
|
1019
1120
|
btn('✕', () => setConfirmModal({
|
|
1020
1121
|
title: t('confirmRemoveProvTitle').replace('{p}', entry.provider),
|
|
1021
1122
|
desc: t('confirmRemoveProvDesc'),
|
|
@@ -1036,7 +1137,7 @@ window.__ModuleLoader__.load({
|
|
|
1036
1137
|
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '24px', marginTop: '4px' } }, (() => { const now = Date.now(); const buckets = Array(24).fill(0); for (const ev of providerStatus.events) { const h = Math.floor((now - ev.at) / 3600000); if (h >= 0 && h < 24) buckets[23 - h]++; } const max = Math.max(1, ...buckets); return buckets.map((c, i) => h('div', { key: i, title: c + ' switches', style: { flex: 1, background: c ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-border-l2)', height: (c / max * 24) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
|
|
1037
1138
|
// #224: 7-day switches per day (client-side, from the same events)
|
|
1038
1139
|
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '16px', marginTop: '2px' } }, (() => { const now = Date.now(); const days = Array(7).fill(0); for (const ev of providerStatus.events) { const d = Math.floor((now - ev.at) / 86400000); if (d >= 0 && d < 7) days[6 - d]++; } const max = Math.max(1, ...days); return days.map((c, i) => h('div', { key: i, title: c + ' switches · day -' + (6 - i), style: { flex: 1, background: c ? 'var(--dsw-alias-state-info-primary, var(--dsw-alias-state-warning-primary))' : 'var(--dsw-alias-border-l2)', height: (c / max * 16) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
|
|
1039
|
-
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { style: { fontSize: '11px', marginTop: '6px' } }, h('summary', null, '
|
|
1140
|
+
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { style: { fontSize: '11px', marginTop: '6px' } }, h('summary', null, t('recentFailures').replace('{n}', String(providerStatus.events.length))), h('ul', { style: { margin: '4px 0 0', paddingLeft: '16px' } }, providerStatus.events.slice().reverse().map((ev, i) => h('li', { key: i, style: ev.type === 'probe' ? { opacity: .5 } : null }, new Date(ev.at).toLocaleTimeString() + ' ' + (ev.type === 'probe' ? '[probe] ' : '') + ev.ref + ' ' + ev.reason + ' cd=' + ev.cooldownMs)) )) : null),
|
|
1040
1141
|
btn(t('resetCooldown'), () => setConfirmModal({
|
|
1041
1142
|
title: t('confirmResetTitle').replace('{p}', entry.provider),
|
|
1042
1143
|
desc: t('confirmResetDesc'),
|
|
@@ -1051,7 +1152,11 @@ window.__ModuleLoader__.load({
|
|
|
1051
1152
|
});
|
|
1052
1153
|
|
|
1053
1154
|
const noProviders = providers.length === 0
|
|
1054
|
-
? h('
|
|
1155
|
+
? h('div', { className: 'krot-empty', role: 'status', style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '16px 14px', border: '1px dashed var(--dsw-alias-border-l2)', borderRadius: 10, background: 'var(--dsw-alias-bg-layer-2)' } },
|
|
1156
|
+
h('p', { style: { margin: 0, fontWeight: 600, fontSize: 14, color: 'var(--dsw-alias-label-primary)' } }, t('emptyTitle')),
|
|
1157
|
+
h('p', { className: 'krot-hint', style: { margin: 0 } }, t('emptyDesc')),
|
|
1158
|
+
h('p', { className: 'krot-hint', style: { margin: 0 } }, t('noProviders')),
|
|
1159
|
+
)
|
|
1055
1160
|
: null;
|
|
1056
1161
|
|
|
1057
1162
|
const allConfiguredProviders = Array.isArray(val?.providers) ? val.providers : [];
|
|
@@ -1102,19 +1207,36 @@ window.__ModuleLoader__.load({
|
|
|
1102
1207
|
h('div', { className: 'krot-foot', style: { marginBottom: '8px' } },
|
|
1103
1208
|
h('input', {
|
|
1104
1209
|
className: 'krot-in',
|
|
1105
|
-
placeholder: '
|
|
1210
|
+
placeholder: t('bulkCooldownPlaceholder'),
|
|
1106
1211
|
value: bulkCooldown,
|
|
1107
1212
|
onChange: (e) => setBulkCooldown(e.target.value),
|
|
1108
1213
|
style: { maxWidth: '160px' },
|
|
1109
1214
|
}),
|
|
1110
|
-
btn('
|
|
1215
|
+
btn(t('bulkApply'), () => {
|
|
1111
1216
|
const v = Number(bulkCooldown); if (!v) return;
|
|
1112
1217
|
setField((cur) => {
|
|
1113
1218
|
const next = [...(cur.providers ?? [])];
|
|
1114
1219
|
for (let i = 0; i < next.length; i++) if (selected.has(next[i].provider)) next[i] = { ...next[i], cooldownMs: v };
|
|
1115
1220
|
return { ...cur, providers: next };
|
|
1116
1221
|
});
|
|
1117
|
-
}, { disabled: selected.size === 0 || !bulkCooldown })
|
|
1222
|
+
}, { disabled: selected.size === 0 || !bulkCooldown }),
|
|
1223
|
+
btn(t('bulkRemove'), () => {
|
|
1224
|
+
const ids = [...selected];
|
|
1225
|
+
if (!ids.length) return;
|
|
1226
|
+
setConfirmModal({
|
|
1227
|
+
title: t('confirmBulkRemoveTitle').replace('{n}', String(ids.length)),
|
|
1228
|
+
desc: t('confirmBulkRemoveDesc'),
|
|
1229
|
+
actionLabel: t('bulkRemove'),
|
|
1230
|
+
danger: true,
|
|
1231
|
+
onConfirm: () => {
|
|
1232
|
+
setField((cur) => {
|
|
1233
|
+
const next = (cur.providers ?? []).filter((p) => !ids.has(p.provider));
|
|
1234
|
+
return { ...cur, providers: next };
|
|
1235
|
+
});
|
|
1236
|
+
setSelected(new Set());
|
|
1237
|
+
},
|
|
1238
|
+
});
|
|
1239
|
+
}, { disabled: selected.size === 0, className: 'krot-btn-danger' })
|
|
1118
1240
|
),
|
|
1119
1241
|
h('div', { className: 'krot-keys' }, providerRows),
|
|
1120
1242
|
h('div', { className: 'krot-foot', style: { marginTop: '10px' } },
|
|
@@ -1126,7 +1248,7 @@ window.__ModuleLoader__.load({
|
|
|
1126
1248
|
const failoverSection = h('div', { className: 'krot-section-card' },
|
|
1127
1249
|
h('div', { className: 'krot-section-title' },
|
|
1128
1250
|
h('span', null, '⚡ ' + t('codesTitle')),
|
|
1129
|
-
h('span', { className: 'krot-badge krot-badge-warn' }, selectedCodes.size + '
|
|
1251
|
+
h('span', { className: 'krot-badge krot-badge-warn' }, selectedCodes.size + ' ' + t('activeBadge'))
|
|
1130
1252
|
),
|
|
1131
1253
|
h('div', { className: 'krot-section-desc' }, t('sectionFailoverDesc')),
|
|
1132
1254
|
h('div', { className: 'krot-codes' }, codeList.map((code) => h('label', { key: code, className: 'krot-code' },
|
|
@@ -1211,12 +1333,23 @@ window.__ModuleLoader__.load({
|
|
|
1211
1333
|
) : null,
|
|
1212
1334
|
].filter(Boolean);
|
|
1213
1335
|
|
|
1214
|
-
const confirmModalEl = confirmModal ? h('div', {
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1336
|
+
const confirmModalEl = confirmModal ? h('div', {
|
|
1337
|
+
className: 'krot-modal-backdrop',
|
|
1338
|
+
onClick: () => setConfirmModal(null),
|
|
1339
|
+
},
|
|
1340
|
+
h('div', {
|
|
1341
|
+
className: 'krot-modal-card',
|
|
1342
|
+
ref: modalCardRef,
|
|
1343
|
+
role: 'dialog',
|
|
1344
|
+
'aria-modal': 'true',
|
|
1345
|
+
'aria-labelledby': 'krot-modal-title',
|
|
1346
|
+
'aria-describedby': 'krot-modal-desc',
|
|
1347
|
+
onClick: (e) => e.stopPropagation(),
|
|
1348
|
+
},
|
|
1349
|
+
h('div', { className: 'krot-modal-title', id: 'krot-modal-title' }, confirmModal.title),
|
|
1350
|
+
h('div', { className: 'krot-modal-desc', id: 'krot-modal-desc' }, confirmModal.desc),
|
|
1218
1351
|
h('div', { className: 'krot-modal-actions' },
|
|
1219
|
-
btn(t('cancel'), () => setConfirmModal(null)),
|
|
1352
|
+
btn(t('cancel'), () => setConfirmModal(null), { 'data-krot-modal-cancel': '1' }),
|
|
1220
1353
|
btn(confirmModal.actionLabel || t('confirm'), () => {
|
|
1221
1354
|
const fn = confirmModal.onConfirm;
|
|
1222
1355
|
setConfirmModal(null);
|
|
@@ -1301,7 +1434,7 @@ window.__ModuleLoader__.load({
|
|
|
1301
1434
|
h('span', { style: { fontSize: '9px', opacity: 0.6 } }, open ? '▲' : '▼')
|
|
1302
1435
|
),
|
|
1303
1436
|
open ? h('div', { className: 'krot-popover' },
|
|
1304
|
-
h('div', { className: 'krot-pop-title' }, '
|
|
1437
|
+
h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
|
|
1305
1438
|
!poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, 'No active pools') : null,
|
|
1306
1439
|
poolEntries.map(([name, p]) => {
|
|
1307
1440
|
const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-success-primary)');
|
package/lib/index.js
CHANGED
|
@@ -49,6 +49,8 @@ import { BoundedMap } from './bounded-map.js';
|
|
|
49
49
|
import { classifyFailure } from './error-taxonomy.js';
|
|
50
50
|
import { safeParseJson } from './atomic-io.js';
|
|
51
51
|
import { registerOpsRoutes } from './routes-ops.js';
|
|
52
|
+
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
53
|
+
import path from 'node:path';
|
|
52
54
|
|
|
53
55
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
54
56
|
const PIAI_NS = 'llm-pi-ai';
|
|
@@ -113,6 +115,9 @@ let moduleBreaker = null;
|
|
|
113
115
|
let moduleNotifyQueue = null;
|
|
114
116
|
// Global config accessor safe against early initialization
|
|
115
117
|
let getConfig = () => null;
|
|
118
|
+
function verboseLoggingOn() {
|
|
119
|
+
try { return Boolean(getConfig()?.verboseLogging); } catch (_) { return false; }
|
|
120
|
+
}
|
|
116
121
|
let getRuntime = () => null;
|
|
117
122
|
let sandboxRunner = null;
|
|
118
123
|
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
@@ -204,6 +209,9 @@ export const Config = Schema.object({
|
|
|
204
209
|
circuitBreakerThreshold: Schema.number().default(5),
|
|
205
210
|
circuitBreakerOpenMs: Schema.number().default(30000),
|
|
206
211
|
circuitBreakerHalfOpenProbes: Schema.number().default(1),
|
|
212
|
+
// #287 persistence across restarts
|
|
213
|
+
persistenceEnabled: Schema.boolean().default(true),
|
|
214
|
+
persistencePath: Schema.string().default(''),
|
|
207
215
|
switchNotifyThrottleMs: Schema.number().default(60000),
|
|
208
216
|
warnBelowHealthy: Schema.number().default(0),
|
|
209
217
|
latencySloMs: Schema.number().default(0),
|
|
@@ -278,6 +286,77 @@ export function apply(ctx, config = {}) {
|
|
|
278
286
|
// ── key-pool state, persisted across config reloads ──
|
|
279
287
|
// base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
|
|
280
288
|
const poolState = new Map();
|
|
289
|
+
|
|
290
|
+
// #287: persist cooldowns / circuit / quota across DSH reloads.
|
|
291
|
+
// Path resolution: explicit config -> host data dir -> DSH_HOME -> cwd.
|
|
292
|
+
// Disabled (with a single warn) when no writable directory can be found.
|
|
293
|
+
let statePersistence = null;
|
|
294
|
+
try {
|
|
295
|
+
const cfg0 = getConfig() ?? config ?? {};
|
|
296
|
+
// Cordis ctx properties require inject; bare getters throw.
|
|
297
|
+
// Use only host env/cwd so apply() never trips the injector.
|
|
298
|
+
const hostDirs = [
|
|
299
|
+
process.env.DSH_HOME,
|
|
300
|
+
process.cwd(),
|
|
301
|
+
].filter((d) => typeof d === 'string' && d.length > 0);
|
|
302
|
+
const resolvedPath = resolveStatePath({
|
|
303
|
+
configuredPath: cfg0.persistencePath,
|
|
304
|
+
dataDir: hostDirs[0],
|
|
305
|
+
});
|
|
306
|
+
if (cfg0.persistenceEnabled !== false && resolvedPath) {
|
|
307
|
+
statePersistence = new StatePersistence({ filePath: resolvedPath });
|
|
308
|
+
statePersistence.load().then((snap) => {
|
|
309
|
+
if (!snap) return;
|
|
310
|
+
try {
|
|
311
|
+
StatePersistence.restorePools(poolState, snap);
|
|
312
|
+
if (moduleBreaker && snap.circuit) moduleBreaker.restore(snap.circuit);
|
|
313
|
+
if (verboseLoggingOn()) {
|
|
314
|
+
console.warn(`[dsh-key-rotation] restored ${Object.keys(snap.pools ?? {}).length} pool state(s) from ${path.basename(resolvedPath)}`);
|
|
315
|
+
}
|
|
316
|
+
} catch (e) {
|
|
317
|
+
console.warn('[dsh-key-rotation] persistence restore failed', e?.message ?? e);
|
|
318
|
+
}
|
|
319
|
+
}).catch(() => {});
|
|
320
|
+
} else if (cfg0.persistenceEnabled !== false && !resolvedPath) {
|
|
321
|
+
console.warn('[dsh-key-rotation] persistence disabled: no data directory');
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
console.warn('[dsh-key-rotation] persistence init failed', e?.message ?? e);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function persistenceSnapshot() {
|
|
328
|
+
if (!statePersistence) return null;
|
|
329
|
+
return StatePersistence.serialize({
|
|
330
|
+
poolState,
|
|
331
|
+
circuitSnapshot: moduleBreaker ? moduleBreaker.snapshot() : {},
|
|
332
|
+
quotaSnapshot: {},
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function schedulePersist() {
|
|
337
|
+
if (!statePersistence) return;
|
|
338
|
+
const snap = persistenceSnapshot();
|
|
339
|
+
if (snap) statePersistence.save(snap);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
ctx.effect(() => {
|
|
343
|
+
const timer = setInterval(() => { try { schedulePersist(); } catch (_) {} }, 15000);
|
|
344
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
345
|
+
return () => {
|
|
346
|
+
clearInterval(timer);
|
|
347
|
+
try {
|
|
348
|
+
if (statePersistence) {
|
|
349
|
+
const snap = persistenceSnapshot();
|
|
350
|
+
if (snap) {
|
|
351
|
+
statePersistence.save(snap);
|
|
352
|
+
// best-effort flush; dispose clears the debounce timer
|
|
353
|
+
void statePersistence.flush();
|
|
354
|
+
}
|
|
355
|
+
statePersistence.dispose();
|
|
356
|
+
}
|
|
357
|
+
} catch (_) {}
|
|
358
|
+
};
|
|
359
|
+
}, 'dsh-key-rotation: state persistence');
|
|
281
360
|
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
282
361
|
ctx.effect(() => {
|
|
283
362
|
const id = setInterval(() => {
|
|
@@ -786,6 +865,7 @@ export function apply(ctx, config = {}) {
|
|
|
786
865
|
if (ref) {
|
|
787
866
|
const backoff = recordFailure(pool, ref, Date.now(), pool.cooldownMs ?? 60000, undefined, cls.soft, true);
|
|
788
867
|
pushEvent(pool, ref, code || 'UNKNOWN', backoff);
|
|
868
|
+
schedulePersist();
|
|
789
869
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
790
870
|
pool.state.lastReason = code || 'UNKNOWN';
|
|
791
871
|
pool.state.lastSwitchAt = Date.now();
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// lib/persistence.js — atomic snapshot of rotation state across restarts (#287).
|
|
2
|
+
// Cooldowns, circuit-breaker entries and quota counters live in memory; a DSH
|
|
3
|
+
// reload would otherwise forget cooldowns and stampede recovered keys.
|
|
4
|
+
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { atomicWriteJson, safeReadJson } from './atomic-io.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_FILE = 'dsh-key-rotation-state.json';
|
|
9
|
+
const SAVE_DEBOUNCE_MS = 400;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{ filePath: string, now?: () => number }} opts
|
|
13
|
+
*/
|
|
14
|
+
export class StatePersistence {
|
|
15
|
+
constructor({ filePath, now = Date.now } = {}) {
|
|
16
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
17
|
+
throw new Error('StatePersistence: filePath is required');
|
|
18
|
+
}
|
|
19
|
+
this.filePath = filePath;
|
|
20
|
+
this._now = now;
|
|
21
|
+
this._timer = null;
|
|
22
|
+
this._dirty = false;
|
|
23
|
+
this._lastWrite = 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Build a plain snapshot from live maps/objects. */
|
|
27
|
+
static serialize({ poolState, circuitSnapshot, quotaSnapshot }) {
|
|
28
|
+
const pools = {};
|
|
29
|
+
if (poolState && typeof poolState.forEach === 'function') {
|
|
30
|
+
poolState.forEach((st, base) => {
|
|
31
|
+
const failedUntil = {};
|
|
32
|
+
if (st && st.failedUntil && typeof st.failedUntil.forEach === 'function') {
|
|
33
|
+
st.failedUntil.forEach((until, ref) => {
|
|
34
|
+
if (Number.isFinite(until)) failedUntil[ref] = until;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
pools[base] = {
|
|
38
|
+
failedUntil,
|
|
39
|
+
pointer: Number.isFinite(st?.pointer) ? st.pointer : 0,
|
|
40
|
+
lastUsed: typeof st?.lastUsed === 'string' ? st.lastUsed : null,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
version: 1,
|
|
46
|
+
savedAt: Date.now(),
|
|
47
|
+
pools,
|
|
48
|
+
circuit: circuitSnapshot && typeof circuitSnapshot === 'object' ? circuitSnapshot : {},
|
|
49
|
+
quota: quotaSnapshot && typeof quotaSnapshot === 'object' ? quotaSnapshot : {},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_nowSafe() {
|
|
54
|
+
const n = Number(this._now?.() ?? Date.now());
|
|
55
|
+
return Number.isFinite(n) ? n : Date.now();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Schedule a debounced atomic write. Never throws to the caller.
|
|
60
|
+
* @param {object} payload result of StatePersistence.serialize
|
|
61
|
+
*/
|
|
62
|
+
save(payload) {
|
|
63
|
+
this._pending = payload;
|
|
64
|
+
this._dirty = true;
|
|
65
|
+
if (this._timer) return;
|
|
66
|
+
this._timer = setTimeout(() => {
|
|
67
|
+
this._timer = null;
|
|
68
|
+
void this.flush();
|
|
69
|
+
}, SAVE_DEBOUNCE_MS);
|
|
70
|
+
if (typeof this._timer.unref === 'function') this._timer.unref();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async flush() {
|
|
74
|
+
if (!this._dirty || !this._pending) return false;
|
|
75
|
+
const payload = this._pending;
|
|
76
|
+
this._dirty = false;
|
|
77
|
+
this._pending = null;
|
|
78
|
+
try {
|
|
79
|
+
await atomicWriteJson(this.filePath, payload);
|
|
80
|
+
this._lastWrite = this._nowSafe();
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
// Keep previous good file; mark dirty so a later save retries.
|
|
84
|
+
this._dirty = true;
|
|
85
|
+
this._pending = this._pending || payload;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load a snapshot. Corrupt/missing file → null (never wipes memory).
|
|
92
|
+
* @returns {Promise<object|null>}
|
|
93
|
+
*/
|
|
94
|
+
async load() {
|
|
95
|
+
const raw = await safeReadJson(this.filePath, null);
|
|
96
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
97
|
+
if (raw.version !== 1) return null;
|
|
98
|
+
return raw;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Restore poolState Map from a loaded snapshot. */
|
|
102
|
+
static restorePools(poolState, snapshot) {
|
|
103
|
+
if (!poolState || !snapshot || !snapshot.pools || typeof snapshot.pools !== 'object') return 0;
|
|
104
|
+
let n = 0;
|
|
105
|
+
for (const [base, st] of Object.entries(snapshot.pools)) {
|
|
106
|
+
if (!st || typeof st !== 'object') continue;
|
|
107
|
+
const failedUntil = new Map();
|
|
108
|
+
if (st.failedUntil && typeof st.failedUntil === 'object') {
|
|
109
|
+
for (const [ref, until] of Object.entries(st.failedUntil)) {
|
|
110
|
+
if (Number.isFinite(until)) failedUntil.set(ref, until);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
poolState.set(base, {
|
|
114
|
+
failedUntil,
|
|
115
|
+
pointer: Number.isFinite(st.pointer) ? st.pointer : 0,
|
|
116
|
+
lastUsed: typeof st.lastUsed === 'string' ? st.lastUsed : undefined,
|
|
117
|
+
});
|
|
118
|
+
n += 1;
|
|
119
|
+
}
|
|
120
|
+
return n;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
dispose() {
|
|
124
|
+
if (this._timer) {
|
|
125
|
+
clearTimeout(this._timer);
|
|
126
|
+
this._timer = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the on-disk state path. Prefer an explicit config path; otherwise
|
|
133
|
+
* place the file under `dataDir` when the host provides one. Returns null when
|
|
134
|
+
* persistence cannot be enabled safely (no writable data directory).
|
|
135
|
+
*/
|
|
136
|
+
export function resolveStatePath({ dataDir, configuredPath } = {}) {
|
|
137
|
+
if (configuredPath && typeof configuredPath === 'string' && configuredPath.trim()) {
|
|
138
|
+
return configuredPath.trim();
|
|
139
|
+
}
|
|
140
|
+
if (dataDir && typeof dataDir === 'string' && dataDir.trim()) {
|
|
141
|
+
return path.join(dataDir.trim(), DEFAULT_FILE);
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default StatePersistence;
|
package/lib/routes-ops.js
CHANGED
|
@@ -22,6 +22,7 @@ import { usageRows, usageCsv } from './usage-report.js';
|
|
|
22
22
|
import { findSecrets, looksLikeApiSecret } from './keycheck.js';
|
|
23
23
|
import { nextQuotaReset } from './quota-window.js';
|
|
24
24
|
import { classifyFailure } from './error-taxonomy.js';
|
|
25
|
+
import { sanitizeSnapshot } from './sanitize-snapshot.js';
|
|
25
26
|
|
|
26
27
|
const STATUS_PATH = '/dsh-key-rotation/status';
|
|
27
28
|
const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
|
|
@@ -166,7 +167,7 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
166
167
|
providers.push({ provider: pool.base, keys: [], statusError: String(e?.message ?? e) });
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
|
-
json(res, 200, {
|
|
170
|
+
json(res, 200, sanitizeSnapshot({
|
|
170
171
|
providers,
|
|
171
172
|
// #266/#263 operational extras (additive)
|
|
172
173
|
meta: {
|
|
@@ -175,7 +176,7 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
175
176
|
breakerEnabled: runtime.circuitBreakerEnabled !== false,
|
|
176
177
|
at: now,
|
|
177
178
|
},
|
|
178
|
-
});
|
|
179
|
+
}, now));
|
|
179
180
|
},
|
|
180
181
|
}), 'dsh-key-rotation: status route');
|
|
181
182
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// lib/sanitize-snapshot.js — clamp/repair runtime snapshots before they leave the host (#288).
|
|
2
|
+
// Guards the settings card and ops APIs against NaN, negative remaining times,
|
|
3
|
+
// and inconsistent counters after clock jumps or empty-pool edge cases.
|
|
4
|
+
|
|
5
|
+
function toFiniteNumber(value, fallback = 0) {
|
|
6
|
+
const n = Number(value);
|
|
7
|
+
return Number.isFinite(n) ? n : fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function clampNonNegative(value) {
|
|
11
|
+
const n = toFiniteNumber(value, 0);
|
|
12
|
+
return n < 0 ? 0 : n;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function clampRemaining(value, nowMs) {
|
|
16
|
+
const n = toFiniteNumber(value, 0);
|
|
17
|
+
if (n <= 0) return 0;
|
|
18
|
+
// If the absolute timestamp is in the past relative to now, remaining is 0.
|
|
19
|
+
if (n > 1e12 && n <= nowMs) return 0;
|
|
20
|
+
// Cooldown remaining should not exceed ~7 days.
|
|
21
|
+
if (n < 1e12 && n > 7 * 86400000) return 7 * 86400000;
|
|
22
|
+
return n;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Sanitize a per-key status entry.
|
|
27
|
+
* @param {object} key
|
|
28
|
+
* @param {number} now
|
|
29
|
+
*/
|
|
30
|
+
export function sanitizeKeyStatus(key, now = Date.now()) {
|
|
31
|
+
if (!key || typeof key !== 'object') return null;
|
|
32
|
+
return {
|
|
33
|
+
...key,
|
|
34
|
+
present: Boolean(key.present),
|
|
35
|
+
active: Boolean(key.active),
|
|
36
|
+
cooldownMsLeft: clampRemaining(key.cooldownMsLeft, now),
|
|
37
|
+
usage: clampNonNegative(key.usage),
|
|
38
|
+
failures: clampNonNegative(key.failures),
|
|
39
|
+
weight: clampNonNegative(key.weight || 1) || 1,
|
|
40
|
+
rpm: key.rpm && typeof key.rpm === 'object'
|
|
41
|
+
? {
|
|
42
|
+
...key.rpm,
|
|
43
|
+
used: clampNonNegative(key.rpm.used),
|
|
44
|
+
remaining: clampNonNegative(key.rpm.remaining),
|
|
45
|
+
limit: clampNonNegative(key.rpm.limit),
|
|
46
|
+
}
|
|
47
|
+
: key.rpm,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sanitize a full status snapshot ({ providers: [...], ... }).
|
|
53
|
+
* Never mutates the input.
|
|
54
|
+
*/
|
|
55
|
+
export function sanitizeSnapshot(snapshot, now = Date.now()) {
|
|
56
|
+
if (!snapshot || typeof snapshot !== 'object') return { providers: [] };
|
|
57
|
+
const providers = Array.isArray(snapshot.providers) ? snapshot.providers : [];
|
|
58
|
+
return {
|
|
59
|
+
...snapshot,
|
|
60
|
+
providers: providers.map((p) => {
|
|
61
|
+
if (!p || typeof p !== 'object') return p;
|
|
62
|
+
const keys = Array.isArray(p.keys) ? p.keys : [];
|
|
63
|
+
return {
|
|
64
|
+
...p,
|
|
65
|
+
switches: clampNonNegative(p.switches),
|
|
66
|
+
totalUsage: p.totalUsage == null ? p.totalUsage : clampNonNegative(p.totalUsage),
|
|
67
|
+
healthScore: p.healthScore == null
|
|
68
|
+
? p.healthScore
|
|
69
|
+
: Math.max(0, Math.min(100, toFiniteNumber(p.healthScore, 0))),
|
|
70
|
+
lastSwitchAt: p.lastSwitchAt == null ? null : (Number.isFinite(Number(p.lastSwitchAt)) ? Number(p.lastSwitchAt) : null),
|
|
71
|
+
lastExhaustionAt: p.lastExhaustionAt == null ? null : (Number.isFinite(Number(p.lastExhaustionAt)) ? Number(p.lastExhaustionAt) : null),
|
|
72
|
+
todayCost: p.todayCost == null ? p.todayCost : clampNonNegative(p.todayCost),
|
|
73
|
+
weeklyCost: p.weeklyCost == null ? p.weeklyCost : clampNonNegative(p.weeklyCost),
|
|
74
|
+
budgetDaily: p.budgetDaily == null ? p.budgetDaily : clampNonNegative(p.budgetDaily),
|
|
75
|
+
budgetWeekly: p.budgetWeekly == null ? p.budgetWeekly : clampNonNegative(p.budgetWeekly),
|
|
76
|
+
p95: p.p95 == null ? null : clampNonNegative(p.p95),
|
|
77
|
+
keys: keys.map((k) => sanitizeKeyStatus(k, now)).filter(Boolean),
|
|
78
|
+
};
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default sanitizeSnapshot;
|
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.7",
|
|
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": [
|