@goodandready/dsh-key-rotation 0.7.2 → 0.7.5
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/README.md +10 -0
- package/lib/client-helpers.js +21 -0
- package/lib/client.js +81 -23
- package/lib/index.js +17 -3
- package/package.json +1 -4
package/README.md
CHANGED
|
@@ -24,6 +24,16 @@
|
|
|
24
24
|
- **Exhaustion warning** — when every key is cooling, a red warning appears in the card and `lastExhaustionAt`/`exhaustionCount` are exposed via `GET /dsh-key-rotation/status`.
|
|
25
25
|
- **Failure log** — last 20 failures per provider (`at`, `ref`, `reason`, `cooldownMs`) via `/status` and a collapsible *Recent failures* list.
|
|
26
26
|
- **Non-stream safety net** — an `agent/request-error` hook retries sync calls (embeddings, batch) with the next key when the error is switchable.
|
|
27
|
+
- **Search/filter providers** — a search box above the list filters providers by id.
|
|
28
|
+
- **Bulk edit cooldown** — checkboxes per provider + a cooldown input + *Apply to selected*.
|
|
29
|
+
- **Undo delete** — after removing a key or provider, an *Undo* bar appears for 5 seconds.
|
|
30
|
+
- **Per-key last used** — `lastUsedAt` shown as "ago" next to each key.
|
|
31
|
+
- **Total requests badge** — sum of usage across a provider's keys, shown in its header.
|
|
32
|
+
- **Export single provider** — ⬇ button exports just that provider's entry.
|
|
33
|
+
- **Import from .env** — pick a `.env` file; `KEY=val` names are added to the first pool.
|
|
34
|
+
- **Copy key name** — click a *Key N* label to copy its ref name.
|
|
35
|
+
- **Sort by usage** — ⇅ sorts a provider's keys by usage (desc).
|
|
36
|
+
- **Probe history** — health-probe events appear greyed in *Recent failures*.
|
|
27
37
|
|
|
28
38
|
## Install
|
|
29
39
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function formatAgo(t, at) {
|
|
2
|
+
if (!at) return '';
|
|
3
|
+
const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
4
|
+
if (sec < 60) return t('justNow');
|
|
5
|
+
if (sec < 3600) return t('minutesAgo').replace('{n}', String(Math.round(sec / 60)));
|
|
6
|
+
return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function nextKeyRef(providerId, existingKeys, allRefs) {
|
|
10
|
+
const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
|
|
11
|
+
const base = fromExisting
|
|
12
|
+
? fromExisting.replace(/_\d+$/, '')
|
|
13
|
+
: String(providerId || 'provider').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_API_KEY';
|
|
14
|
+
const taken = new Set(allRefs);
|
|
15
|
+
if (!taken.has(base)) return base;
|
|
16
|
+
for (let n = 2; n < 1000; n++) {
|
|
17
|
+
const candidate = base + '_' + n;
|
|
18
|
+
if (!taken.has(candidate)) return candidate;
|
|
19
|
+
}
|
|
20
|
+
return base + '_' + Date.now();
|
|
21
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -62,7 +62,10 @@ window.__ModuleLoader__.load({
|
|
|
62
62
|
keyHint: 'The value is stored in DSH credentials and never sent back to the browser — only its last 5 characters are shown. Names are generated for you; hover a key to see the one it uses.',
|
|
63
63
|
brokenKey: 'broken (3× AUTH)',
|
|
64
64
|
exportPools: 'Export',
|
|
65
|
+
exportOne: '⬇',
|
|
66
|
+
usedAgo: '{ago} ago',
|
|
65
67
|
importPools: 'Import',
|
|
68
|
+
importEnv: 'Import .env',
|
|
66
69
|
resetCooldown: 'Reset cooldown',
|
|
67
70
|
testKey: 'Test',
|
|
68
71
|
testOk: 'OK',
|
|
@@ -108,7 +111,10 @@ window.__ModuleLoader__.load({
|
|
|
108
111
|
keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
|
|
109
112
|
brokenKey: 'сломан (3× AUTH)',
|
|
110
113
|
exportPools: 'Экспорт',
|
|
114
|
+
exportOne: '⬇',
|
|
115
|
+
usedAgo: '{ago} назад',
|
|
111
116
|
importPools: 'Импорт',
|
|
117
|
+
importEnv: 'Импорт .env',
|
|
112
118
|
resetCooldown: 'Сбросить кулдаун',
|
|
113
119
|
testKey: 'Тест',
|
|
114
120
|
testOk: 'OK',
|
|
@@ -146,6 +152,7 @@ window.__ModuleLoader__.load({
|
|
|
146
152
|
return byProvider;
|
|
147
153
|
}
|
|
148
154
|
|
|
155
|
+
// formatAgo moved to lib/client-helpers.js for testability — keep local alias for bundle self-containment
|
|
149
156
|
function formatAgo(t, at) {
|
|
150
157
|
if (!at) return '';
|
|
151
158
|
const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
@@ -206,6 +213,7 @@ window.__ModuleLoader__.load({
|
|
|
206
213
|
* не ломались, и проверяется на занятость по ВСЕМ провайдерам — иначе два
|
|
207
214
|
* провайдера незаметно делили бы одну учётную запись.
|
|
208
215
|
*/
|
|
216
|
+
// nextKeyRef also in lib/client-helpers.js
|
|
209
217
|
function nextKeyRef(providerId, existingKeys, allRefs) {
|
|
210
218
|
const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
|
|
211
219
|
const base = fromExisting
|
|
@@ -242,6 +250,31 @@ window.__ModuleLoader__.load({
|
|
|
242
250
|
const t = makeT(DICT, en);
|
|
243
251
|
const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
|
|
244
252
|
const [draft, setDraft] = React.useState(null);
|
|
253
|
+
// ── all hooks live ABOVE any early return (React error 310 otherwise) ──
|
|
254
|
+
const [search, setSearch] = React.useState('');
|
|
255
|
+
const [selected, setSelected] = React.useState(new Set());
|
|
256
|
+
const [bulkCooldown, setBulkCooldown] = React.useState('');
|
|
257
|
+
const [undo, setUndo] = React.useState(null);
|
|
258
|
+
const undoTimer = React.useRef(null);
|
|
259
|
+
const [testing, setTesting] = React.useState('');
|
|
260
|
+
const [testResult, setTestResult] = React.useState({});
|
|
261
|
+
const [secretDraft, setSecretDraft] = React.useState({});
|
|
262
|
+
const [secretError, setSecretError] = React.useState('');
|
|
263
|
+
const stashUndo = (u) => { setUndo(u); if (undoTimer.current) clearTimeout(undoTimer.current); undoTimer.current = setTimeout(() => setUndo(null), 5000); };
|
|
264
|
+
const doUndo = () => { if (!undo) return; const u = undo; setUndo(null); setField((cur) => {
|
|
265
|
+
const providers = [...(cur.providers ?? [])];
|
|
266
|
+
if (u.type === 'provider') providers.splice(Math.min(u.index, providers.length), 0, u.entry);
|
|
267
|
+
else if (providers[u.index]) { const keys=[...providers[u.index].keys]; keys.splice(Math.min(u.kIndex, keys.length), 0, u.key); providers[u.index] = { ...providers[u.index], keys }; }
|
|
268
|
+
return { ...cur, providers };
|
|
269
|
+
}); };
|
|
270
|
+
const doTest = (ref) => {
|
|
271
|
+
setTesting(ref); setTestResult((m) => ({ ...m, [ref]: null }));
|
|
272
|
+
fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) })
|
|
273
|
+
.then((r) => r.json())
|
|
274
|
+
.then((data) => setTestResult((m) => ({ ...m, [ref]: data })))
|
|
275
|
+
.catch((e) => setTestResult((m) => ({ ...m, [ref]: { ok: false, message: String(e?.message ?? e) } })))
|
|
276
|
+
.finally(() => setTesting(''));
|
|
277
|
+
};
|
|
245
278
|
|
|
246
279
|
const load = React.useCallback(() => {
|
|
247
280
|
setState((s) => ({ ...s, status: 'loading', error: '' }));
|
|
@@ -264,10 +297,6 @@ window.__ModuleLoader__.load({
|
|
|
264
297
|
|
|
265
298
|
const val = draft ?? state.value;
|
|
266
299
|
const status = useRotationStatus();
|
|
267
|
-
// Значения ключей живут только здесь, до нажатия «сохранить»: обратно из
|
|
268
|
-
// хоста они не приходят, в карточке видны лишь последние символы.
|
|
269
|
-
const [secretDraft, setSecretDraft] = React.useState({});
|
|
270
|
-
const [secretError, setSecretError] = React.useState('');
|
|
271
300
|
const [resetting, setResetting] = React.useState('');
|
|
272
301
|
const doReset = (providerId) => {
|
|
273
302
|
setResetting(providerId);
|
|
@@ -278,16 +307,7 @@ window.__ModuleLoader__.load({
|
|
|
278
307
|
.catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))))
|
|
279
308
|
.finally(() => setResetting(''));
|
|
280
309
|
};
|
|
281
|
-
|
|
282
|
-
const [testResult, setTestResult] = React.useState({});
|
|
283
|
-
const doTest = (ref) => {
|
|
284
|
-
setTesting(ref); setTestResult((m) => ({ ...m, [ref]: null }));
|
|
285
|
-
fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) })
|
|
286
|
-
.then((r) => r.json())
|
|
287
|
-
.then((data) => setTestResult((m) => ({ ...m, [ref]: data })))
|
|
288
|
-
.catch((e) => setTestResult((m) => ({ ...m, [ref]: { ok: false, message: String(e?.message ?? e) } })))
|
|
289
|
-
.finally(() => setTesting(''));
|
|
290
|
-
};
|
|
310
|
+
|
|
291
311
|
|
|
292
312
|
const keyInfo = (providerId, ref) => {
|
|
293
313
|
const entryStatus = status[providerId];
|
|
@@ -323,7 +343,7 @@ window.__ModuleLoader__.load({
|
|
|
323
343
|
const providerById = new Map(providers.map((p) => [p.id, p.name]));
|
|
324
344
|
|
|
325
345
|
const setField = (fn) => setDraft(fn(val));
|
|
326
|
-
const providerList = Array.isArray(val.providers) ? val.providers.filter((p) => Array.isArray(p.keys) && p.keys.length > 0) : [];
|
|
346
|
+
const providerList = Array.isArray(val.providers) ? val.providers.filter((p) => Array.isArray(p.keys) && p.keys.length > 0).filter((p) => !search || p.provider.toLowerCase().includes(search.toLowerCase())) : [];
|
|
327
347
|
|
|
328
348
|
const setProvider = (index, id) => setField((cur) => {
|
|
329
349
|
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
@@ -338,15 +358,17 @@ window.__ModuleLoader__.load({
|
|
|
338
358
|
providers[pIndex] = entry;
|
|
339
359
|
return { ...cur, providers };
|
|
340
360
|
});
|
|
341
|
-
const removeKey = (pIndex, kIndex) => setField((cur) => {
|
|
361
|
+
const removeKey = (pIndex, kIndex) => { setField((cur) => {
|
|
342
362
|
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
363
|
+
stashUndo({ type: 'key', index: pIndex, kIndex, key: next[pIndex]?.keys?.[kIndex] });
|
|
343
364
|
next[pIndex] = { ...next[pIndex], keys: (next[pIndex].keys ?? []).filter((_, i) => i !== kIndex) };
|
|
344
365
|
return { ...cur, providers: next };
|
|
366
|
+
}); };
|
|
367
|
+
const removeProvider = (pIndex) => setField((cur) => {
|
|
368
|
+
const arr = Array.isArray(cur.providers) ? cur.providers : [];
|
|
369
|
+
stashUndo({ type: 'provider', index: pIndex, entry: arr[pIndex] });
|
|
370
|
+
return { ...cur, providers: arr.filter((_, i) => i !== pIndex) };
|
|
345
371
|
});
|
|
346
|
-
const removeProvider = (pIndex) => setField((cur) => ({
|
|
347
|
-
...cur,
|
|
348
|
-
providers: (Array.isArray(cur.providers) ? cur.providers : []).filter((_, i) => i !== pIndex),
|
|
349
|
-
}));
|
|
350
372
|
// Порядок ключей = порядок попыток, поэтому его надо менять кнопками,
|
|
351
373
|
// а не перепечатыванием имён.
|
|
352
374
|
const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
|
|
@@ -434,6 +456,7 @@ window.__ModuleLoader__.load({
|
|
|
434
456
|
return { color: 'var(--dsw-alias-label-tertiary)', text: t('keyReady') };
|
|
435
457
|
};
|
|
436
458
|
|
|
459
|
+
const searchInput = h('input', { className: 'krot-in', placeholder: 'Search providers…', value: search, onChange: (e) => setSearch(e.target.value), style: { marginBottom: '8px' } });
|
|
437
460
|
const providerRows = providerList.map((entry, pIndex) => {
|
|
438
461
|
const options = [];
|
|
439
462
|
if (entry.provider && !providerById.has(entry.provider)) {
|
|
@@ -454,8 +477,12 @@ window.__ModuleLoader__.load({
|
|
|
454
477
|
// соседние ключи выглядели одинаково.
|
|
455
478
|
const nameRow = [
|
|
456
479
|
h('span', { className: 'krot-num', key: 'n' }, String(kIndex + 1)),
|
|
457
|
-
h('span', { key: 'i', className: 'krot-name', title: key },
|
|
458
|
-
|
|
480
|
+
h('span', { key: 'i', className: 'krot-name', title: key + ' (click to copy)', style: { cursor: 'copy' }, onClick: () => {
|
|
481
|
+
if (navigator.clipboard) navigator.clipboard.writeText(key).then(() => setSecretDraft((cur) => ({ ...cur, ['copied:' + key]: true }))).catch(() => {});
|
|
482
|
+
setTimeout(() => setSecretDraft((cur) => ({ ...cur, ['copied:' + key]: false })), 1500);
|
|
483
|
+
} },
|
|
484
|
+
t('keyLabel').replace('{n}', String(kIndex + 1)),
|
|
485
|
+
h('span', null, secretDraft['copied:' + key] ? ' ✓' : '')),
|
|
459
486
|
];
|
|
460
487
|
|
|
461
488
|
const meta = [
|
|
@@ -477,6 +504,7 @@ window.__ModuleLoader__.load({
|
|
|
477
504
|
if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'w' }));
|
|
478
505
|
}
|
|
479
506
|
if (info && typeof info.usage === 'number' && info.usage > 0) meta.push(h('span', { key: 'u', className: 'krot-tail', title: 'requests through this key' }, String(info.usage)));
|
|
507
|
+
if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
|
|
480
508
|
if (info && typeof info.cost === 'number' && info.cost > 0) meta.push(h('span', { key: 'c', className: 'krot-tail', title: 'cost' }, '$' + info.cost.toFixed(2)));
|
|
481
509
|
const tr = testResult[key];
|
|
482
510
|
if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail', title: tr.message || (tr.ok ? t('testOk') : t('testFail')) }, tr.ok ? '✓' : '✕'));
|
|
@@ -507,7 +535,27 @@ window.__ModuleLoader__.load({
|
|
|
507
535
|
|
|
508
536
|
return h('div', { key: pIndex, className: 'krot-prov' },
|
|
509
537
|
h('div', { className: 'krot-prov-head' },
|
|
538
|
+
h('input', { type: 'checkbox', checked: selected.has(entry.provider), onChange: (e) => { const ns = new Set(selected); if (e.target.checked) ns.add(entry.provider); else ns.delete(entry.provider); setSelected(ns); } }),
|
|
539
|
+
btn(t('exportOne'), () => {
|
|
540
|
+
const data = JSON.stringify([entry], null, 2);
|
|
541
|
+
const blob = new Blob([data], { type: 'application/json' });
|
|
542
|
+
const url = URL.createObjectURL(blob);
|
|
543
|
+
const a = document.createElement('a'); a.href = url; a.download = entry.provider + '.json'; a.click(); URL.revokeObjectURL(url);
|
|
544
|
+
}, { title: 'Export this provider' }),
|
|
545
|
+
btn('⇅', () => {
|
|
546
|
+
const ps = status[entry.provider];
|
|
547
|
+
if (!ps || !Array.isArray(ps.keys)) return;
|
|
548
|
+
const usageOf = (ref) => { const hit = ps.keys.find((k) => k.ref === ref); return hit && typeof hit.usage === 'number' ? hit.usage : 0; };
|
|
549
|
+
setField((cur) => {
|
|
550
|
+
const next = [...(cur.providers ?? [])];
|
|
551
|
+
if (!next[pIndex]) return cur;
|
|
552
|
+
const sorted = [...(next[pIndex].keys ?? [])].sort((a, b) => usageOf(b) - usageOf(a));
|
|
553
|
+
next[pIndex] = { ...next[pIndex], keys: sorted };
|
|
554
|
+
return { ...cur, providers: next };
|
|
555
|
+
});
|
|
556
|
+
}, { title: 'Sort by usage' }),
|
|
510
557
|
h('select', { className: 'krot-in', value: entry.provider, onChange: (e) => setProvider(pIndex, e.target.value) }, options),
|
|
558
|
+
(() => { const ps = status[entry.provider]; const tot = ps && typeof ps.totalUsage === 'number' ? ps.totalUsage : null; return tot !== null ? h('span', { className: 'krot-tail', title: 'total requests', style: { flex: 'none' } }, String(tot)) : null; })(),
|
|
511
559
|
btn('✕', () => removeProvider(pIndex), { title: t('removeProvider') }),
|
|
512
560
|
),
|
|
513
561
|
h('div', { className: 'krot-keys' }, keyRows),
|
|
@@ -516,7 +564,7 @@ window.__ModuleLoader__.load({
|
|
|
516
564
|
switchesLine,
|
|
517
565
|
exhaustionWarning,
|
|
518
566
|
(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),
|
|
519
|
-
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { style: { fontSize: '11px', marginTop: '6px' } }, h('summary', null, 'Recent failures ('+providerStatus.events.length+')'), h('ul', { style: { margin: '4px 0 0', paddingLeft: '16px' } }, providerStatus.events.slice().reverse().map((ev, i) => h('li', { key: i }, new Date(ev.at).toLocaleTimeString() + ' ' + ev.ref + ' ' + ev.reason + ' cd=' + ev.cooldownMs)) )) : null),
|
|
567
|
+
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { style: { fontSize: '11px', marginTop: '6px' } }, h('summary', null, 'Recent failures ('+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),
|
|
520
568
|
btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
|
|
521
569
|
),
|
|
522
570
|
);
|
|
@@ -538,6 +586,15 @@ window.__ModuleLoader__.load({
|
|
|
538
586
|
code,
|
|
539
587
|
)))),
|
|
540
588
|
field(t('providersTitle'), h('div', { className: 'krot-keys' },
|
|
589
|
+
searchInput,
|
|
590
|
+
h('div', { className: 'krot-foot' }, h('input', { className: 'krot-in', placeholder: 'Bulk cooldown ms', value: bulkCooldown, onChange: (e) => setBulkCooldown(e.target.value), style: { maxWidth: '140px' } }), btn('Apply to selected', () => {
|
|
591
|
+
const v = Number(bulkCooldown); if (!v) return;
|
|
592
|
+
setField((cur) => {
|
|
593
|
+
const next = [...(cur.providers ?? [])];
|
|
594
|
+
for (let i=0;i<next.length;i++) if (selected.has(next[i].provider)) next[i] = { ...next[i], cooldownMs: v };
|
|
595
|
+
return { ...cur, providers: next };
|
|
596
|
+
});
|
|
597
|
+
}, { disabled: selected.size === 0 || !bulkCooldown })),
|
|
541
598
|
providerRows,
|
|
542
599
|
h('div', { className: 'krot-foot' }, btn(t('addProvider'), addProvider, {}), noProviders),
|
|
543
600
|
)),
|
|
@@ -560,6 +617,7 @@ window.__ModuleLoader__.load({
|
|
|
560
617
|
h('p', { className: 'krot-hint' }, t('keyHint')),
|
|
561
618
|
secretError ? h('p', { className: 'krot-err' }, secretError) : null,
|
|
562
619
|
state.error ? h('p', { className: 'krot-err' }, state.error) : null,
|
|
620
|
+
undo ? h('div', { className: 'krot-foot' }, h('span', { className: 'krot-hint' }, undo.type === 'provider' ? 'Удалён провайдер' : 'Удалён ключ'), btn('Undo', doUndo, {})) : null,
|
|
563
621
|
h('div', { className: 'krot-foot' },
|
|
564
622
|
btn(t('save'), save, { primary: true }),
|
|
565
623
|
btn(t('discard'), load, {}),
|
package/lib/index.js
CHANGED
|
@@ -53,8 +53,8 @@ const PIAI_NS = 'llm-pi-ai';
|
|
|
53
53
|
/** Marker on internally re-dispatched requests so the interceptor does not loop. */
|
|
54
54
|
const MARKER = '__dshKeyRotation';
|
|
55
55
|
const MAX_EVENTS = 50;
|
|
56
|
-
function pushEvent(pool, ref, reason, cooldownMs) {
|
|
57
|
-
const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs };
|
|
56
|
+
function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
57
|
+
const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
|
|
58
58
|
pool.state.events.push(ev);
|
|
59
59
|
if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
|
|
60
60
|
}
|
|
@@ -250,7 +250,19 @@ export function apply(ctx, config = {}) {
|
|
|
250
250
|
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
251
251
|
ctx.effect(() => {
|
|
252
252
|
const id = setInterval(() => {
|
|
253
|
-
const
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
// probe events for keys whose cooldown just expired
|
|
255
|
+
for (const st of poolState.values()) {
|
|
256
|
+
for (const [ref, until] of [...(st.failedUntil?.entries() ?? [])]) {
|
|
257
|
+
if (until <= now && !st.probedAt?.has(ref)) {
|
|
258
|
+
st.events.push({ at: until, ref, reason: 'probe', cooldownMs: 0, type: 'probe' });
|
|
259
|
+
if (st.events.length > 50) st.events.shift();
|
|
260
|
+
if (!st.probedAt) st.probedAt = new Map();
|
|
261
|
+
st.probedAt.set(ref, until);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const n = sweepExpired(poolState, now);
|
|
254
266
|
if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
255
267
|
}, 30000);
|
|
256
268
|
return () => clearInterval(id);
|
|
@@ -562,6 +574,7 @@ export function apply(ctx, config = {}) {
|
|
|
562
574
|
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
563
575
|
usage: pool.state.usageCounts?.get(ref) ?? 0,
|
|
564
576
|
cost: pool.state.costPerKey?.get(ref) ?? 0,
|
|
577
|
+
lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
|
|
565
578
|
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
566
579
|
});
|
|
567
580
|
}
|
|
@@ -573,6 +586,7 @@ export function apply(ctx, config = {}) {
|
|
|
573
586
|
lastSwitchAt: pool.state.lastSwitchAt ?? null,
|
|
574
587
|
lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
|
|
575
588
|
exhaustionCount: pool.state.exhaustionCount ?? 0,
|
|
589
|
+
totalUsage: [...(pool.state.usageCounts?.values() ?? [])].reduce((a, b) => a + b, 0),
|
|
576
590
|
events: (pool.state.events ?? []).slice(-50),
|
|
577
591
|
});
|
|
578
592
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -54,8 +54,5 @@
|
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
56
|
"test": "node --test test/*.test.js"
|
|
57
|
-
},
|
|
58
|
-
"publishConfig": {
|
|
59
|
-
"access": "public"
|
|
60
57
|
}
|
|
61
58
|
}
|