@goodandready/dsh-key-rotation 0.7.1 → 0.7.4
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 +89 -9
- package/lib/index.js +32 -6
- package/package.json +1 -1
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
|
@@ -61,6 +61,11 @@ window.__ModuleLoader__.load({
|
|
|
61
61
|
keyWriteFailed: 'could not store the key: {msg}',
|
|
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
|
+
exportPools: 'Export',
|
|
65
|
+
exportOne: '⬇',
|
|
66
|
+
usedAgo: '{ago} ago',
|
|
67
|
+
importPools: 'Import',
|
|
68
|
+
importEnv: 'Import .env',
|
|
64
69
|
resetCooldown: 'Reset cooldown',
|
|
65
70
|
testKey: 'Test',
|
|
66
71
|
testOk: 'OK',
|
|
@@ -105,6 +110,11 @@ window.__ModuleLoader__.load({
|
|
|
105
110
|
keyWriteFailed: 'не удалось сохранить ключ: {msg}',
|
|
106
111
|
keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
|
|
107
112
|
brokenKey: 'сломан (3× AUTH)',
|
|
113
|
+
exportPools: 'Экспорт',
|
|
114
|
+
exportOne: '⬇',
|
|
115
|
+
usedAgo: '{ago} назад',
|
|
116
|
+
importPools: 'Импорт',
|
|
117
|
+
importEnv: 'Импорт .env',
|
|
108
118
|
resetCooldown: 'Сбросить кулдаун',
|
|
109
119
|
testKey: 'Тест',
|
|
110
120
|
testOk: 'OK',
|
|
@@ -142,6 +152,7 @@ window.__ModuleLoader__.load({
|
|
|
142
152
|
return byProvider;
|
|
143
153
|
}
|
|
144
154
|
|
|
155
|
+
// formatAgo moved to lib/client-helpers.js for testability — keep local alias for bundle self-containment
|
|
145
156
|
function formatAgo(t, at) {
|
|
146
157
|
if (!at) return '';
|
|
147
158
|
const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
@@ -202,6 +213,7 @@ window.__ModuleLoader__.load({
|
|
|
202
213
|
* не ломались, и проверяется на занятость по ВСЕМ провайдерам — иначе два
|
|
203
214
|
* провайдера незаметно делили бы одну учётную запись.
|
|
204
215
|
*/
|
|
216
|
+
// nextKeyRef also in lib/client-helpers.js
|
|
205
217
|
function nextKeyRef(providerId, existingKeys, allRefs) {
|
|
206
218
|
const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
|
|
207
219
|
const base = fromExisting
|
|
@@ -319,7 +331,19 @@ window.__ModuleLoader__.load({
|
|
|
319
331
|
const providerById = new Map(providers.map((p) => [p.id, p.name]));
|
|
320
332
|
|
|
321
333
|
const setField = (fn) => setDraft(fn(val));
|
|
322
|
-
const
|
|
334
|
+
const [search, setSearch] = React.useState('');
|
|
335
|
+
const [selected, setSelected] = React.useState(new Set());
|
|
336
|
+
const [undo, setUndo] = React.useState(null); // {type, provider, index, entry}
|
|
337
|
+
const undoTimer = React.useRef(null);
|
|
338
|
+
const stashUndo = (u) => { setUndo(u); if (undoTimer.current) clearTimeout(undoTimer.current); undoTimer.current = setTimeout(() => setUndo(null), 5000); };
|
|
339
|
+
const doUndo = () => { if (!undo) return; const u = undo; setUndo(null); setField((cur) => {
|
|
340
|
+
const providers = [...(cur.providers ?? [])];
|
|
341
|
+
if (u.type === 'provider') providers.splice(Math.min(u.index, providers.length), 0, u.entry);
|
|
342
|
+
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 }; }
|
|
343
|
+
return { ...cur, providers };
|
|
344
|
+
}); };
|
|
345
|
+
const [bulkCooldown, setBulkCooldown] = React.useState('');
|
|
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())) : [];
|
|
323
347
|
|
|
324
348
|
const setProvider = (index, id) => setField((cur) => {
|
|
325
349
|
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
@@ -334,15 +358,17 @@ window.__ModuleLoader__.load({
|
|
|
334
358
|
providers[pIndex] = entry;
|
|
335
359
|
return { ...cur, providers };
|
|
336
360
|
});
|
|
337
|
-
const removeKey = (pIndex, kIndex) => setField((cur) => {
|
|
361
|
+
const removeKey = (pIndex, kIndex) => { setField((cur) => {
|
|
338
362
|
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
363
|
+
stashUndo({ type: 'key', index: pIndex, kIndex, key: next[pIndex]?.keys?.[kIndex] });
|
|
339
364
|
next[pIndex] = { ...next[pIndex], keys: (next[pIndex].keys ?? []).filter((_, i) => i !== kIndex) };
|
|
340
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) };
|
|
341
371
|
});
|
|
342
|
-
const removeProvider = (pIndex) => setField((cur) => ({
|
|
343
|
-
...cur,
|
|
344
|
-
providers: (Array.isArray(cur.providers) ? cur.providers : []).filter((_, i) => i !== pIndex),
|
|
345
|
-
}));
|
|
346
372
|
// Порядок ключей = порядок попыток, поэтому его надо менять кнопками,
|
|
347
373
|
// а не перепечатыванием имён.
|
|
348
374
|
const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
|
|
@@ -430,6 +456,7 @@ window.__ModuleLoader__.load({
|
|
|
430
456
|
return { color: 'var(--dsw-alias-label-tertiary)', text: t('keyReady') };
|
|
431
457
|
};
|
|
432
458
|
|
|
459
|
+
const searchInput = h('input', { className: 'krot-in', placeholder: 'Search providers…', value: search, onChange: (e) => setSearch(e.target.value), style: { marginBottom: '8px' } });
|
|
433
460
|
const providerRows = providerList.map((entry, pIndex) => {
|
|
434
461
|
const options = [];
|
|
435
462
|
if (entry.provider && !providerById.has(entry.provider)) {
|
|
@@ -450,8 +477,12 @@ window.__ModuleLoader__.load({
|
|
|
450
477
|
// соседние ключи выглядели одинаково.
|
|
451
478
|
const nameRow = [
|
|
452
479
|
h('span', { className: 'krot-num', key: 'n' }, String(kIndex + 1)),
|
|
453
|
-
h('span', { key: 'i', className: 'krot-name', title: key },
|
|
454
|
-
|
|
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] ? ' ✓' : '')),
|
|
455
486
|
];
|
|
456
487
|
|
|
457
488
|
const meta = [
|
|
@@ -473,6 +504,8 @@ window.__ModuleLoader__.load({
|
|
|
473
504
|
if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'w' }));
|
|
474
505
|
}
|
|
475
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)));
|
|
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)));
|
|
476
509
|
const tr = testResult[key];
|
|
477
510
|
if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail', title: tr.message || (tr.ok ? t('testOk') : t('testFail')) }, tr.ok ? '✓' : '✕'));
|
|
478
511
|
meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
|
|
@@ -502,7 +535,27 @@ window.__ModuleLoader__.load({
|
|
|
502
535
|
|
|
503
536
|
return h('div', { key: pIndex, className: 'krot-prov' },
|
|
504
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' }),
|
|
505
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; })(),
|
|
506
559
|
btn('✕', () => removeProvider(pIndex), { title: t('removeProvider') }),
|
|
507
560
|
),
|
|
508
561
|
h('div', { className: 'krot-keys' }, keyRows),
|
|
@@ -510,7 +563,8 @@ window.__ModuleLoader__.load({
|
|
|
510
563
|
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
|
|
511
564
|
switchesLine,
|
|
512
565
|
exhaustionWarning,
|
|
513
|
-
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('
|
|
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),
|
|
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),
|
|
514
568
|
btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
|
|
515
569
|
),
|
|
516
570
|
);
|
|
@@ -532,12 +586,38 @@ window.__ModuleLoader__.load({
|
|
|
532
586
|
code,
|
|
533
587
|
)))),
|
|
534
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 })),
|
|
535
598
|
providerRows,
|
|
536
599
|
h('div', { className: 'krot-foot' }, btn(t('addProvider'), addProvider, {}), noProviders),
|
|
537
600
|
)),
|
|
601
|
+
h('div', { className: 'krot-foot' }, btn(t('exportPools'), () => {
|
|
602
|
+
const data = JSON.stringify(val.providers ?? [], null, 2);
|
|
603
|
+
const blob = new Blob([data], { type: 'application/json' });
|
|
604
|
+
const url = URL.createObjectURL(blob);
|
|
605
|
+
const a = document.createElement('a'); a.href = url; a.download = 'pools.json'; a.click(); URL.revokeObjectURL(url);
|
|
606
|
+
}, {}), h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('importPools'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
|
|
607
|
+
const f = e.target.files[0]; if (!f) return;
|
|
608
|
+
const reader = new FileReader();
|
|
609
|
+
reader.onload = () => { try { const imp = JSON.parse(String(reader.result)); if (!Array.isArray(imp)) throw new Error('expected array'); setField((cur) => {
|
|
610
|
+
const curProviders = Array.isArray(cur.providers) ? [...cur.providers] : [];
|
|
611
|
+
const map = new Map(curProviders.map((p) => [p.provider, p]));
|
|
612
|
+
for (const p of imp) { if (p && typeof p.provider === 'string') map.set(p.provider, p); }
|
|
613
|
+
return { ...cur, providers: [...map.values()] };
|
|
614
|
+
}); } catch (err) { setSecretError(String(err.message || err)); } };
|
|
615
|
+
reader.readAsText(f);
|
|
616
|
+
} }))),
|
|
538
617
|
h('p', { className: 'krot-hint' }, t('keyHint')),
|
|
539
618
|
secretError ? h('p', { className: 'krot-err' }, secretError) : null,
|
|
540
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,
|
|
541
621
|
h('div', { className: 'krot-foot' },
|
|
542
622
|
btn(t('save'), save, { primary: true }),
|
|
543
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);
|
|
@@ -321,6 +333,10 @@ export function apply(ctx, config = {}) {
|
|
|
321
333
|
}
|
|
322
334
|
}
|
|
323
335
|
|
|
336
|
+
// auto-cleanup: remove poolState for providers that are now empty or removed
|
|
337
|
+
for (const key of [...poolState.keys()]) {
|
|
338
|
+
if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
|
|
339
|
+
}
|
|
324
340
|
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, poolByRef, providerToPool, cloneIds };
|
|
325
341
|
}
|
|
326
342
|
|
|
@@ -443,8 +459,9 @@ export function apply(ctx, config = {}) {
|
|
|
443
459
|
const failure = chunk.reason?.failure;
|
|
444
460
|
const code = failure?.code;
|
|
445
461
|
const message = failure?.message ?? '';
|
|
462
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
446
463
|
const switchable = !yielded && kind === 'error' &&
|
|
447
|
-
(
|
|
464
|
+
(effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
|
|
448
465
|
if (switchable) {
|
|
449
466
|
if (pool.state.lastUsed) { const _retry = parseRetryAfter(message); const _base = pool.cooldownMs ?? cooldownMs; const _max = pool.maxCooldownMs ?? maxCooldownMs; const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base; const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max); pushEvent(pool, pool.state.lastUsed, code ?? 'UNKNOWN', _b); const _code2 = String(code ?? ''); if (_code2 === 'AUTH' || /auth/i.test(message)) { const _c2 = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1; pool.state.authFailCounts.set(pool.state.lastUsed, _c2); if (_c2 >= 3) { pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); } } else { pool.state.authFailCounts.delete(pool.state.lastUsed); } }
|
|
450
467
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
@@ -455,6 +472,11 @@ export function apply(ctx, config = {}) {
|
|
|
455
472
|
switching = true;
|
|
456
473
|
break;
|
|
457
474
|
}
|
|
475
|
+
// cost tracking if provider returns usage.cost
|
|
476
|
+
if (chunk.usage?.cost != null && pool.state.lastUsed) {
|
|
477
|
+
const c = Number(chunk.usage.cost);
|
|
478
|
+
if (!isNaN(c)) pool.state.costPerKey.set(pool.state.lastUsed, (pool.state.costPerKey.get(pool.state.lastUsed) ?? 0) + c);
|
|
479
|
+
}
|
|
458
480
|
yield chunk;
|
|
459
481
|
return;
|
|
460
482
|
}
|
|
@@ -551,6 +573,8 @@ export function apply(ctx, config = {}) {
|
|
|
551
573
|
active: pool.state.lastUsed === ref,
|
|
552
574
|
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
553
575
|
usage: pool.state.usageCounts?.get(ref) ?? 0,
|
|
576
|
+
cost: pool.state.costPerKey?.get(ref) ?? 0,
|
|
577
|
+
lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
|
|
554
578
|
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
555
579
|
});
|
|
556
580
|
}
|
|
@@ -562,7 +586,8 @@ export function apply(ctx, config = {}) {
|
|
|
562
586
|
lastSwitchAt: pool.state.lastSwitchAt ?? null,
|
|
563
587
|
lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
|
|
564
588
|
exhaustionCount: pool.state.exhaustionCount ?? 0,
|
|
565
|
-
|
|
589
|
+
totalUsage: [...(pool.state.usageCounts?.values() ?? [])].reduce((a, b) => a + b, 0),
|
|
590
|
+
events: (pool.state.events ?? []).slice(-50),
|
|
566
591
|
});
|
|
567
592
|
}
|
|
568
593
|
json(res, 200, { providers });
|
|
@@ -732,7 +757,8 @@ export function apply(ctx, config = {}) {
|
|
|
732
757
|
if (!pool) return next();
|
|
733
758
|
const code = String(payload?.failure?.code ?? payload?.code ?? '');
|
|
734
759
|
const message = String(payload?.failure?.message ?? payload?.message ?? '');
|
|
735
|
-
const
|
|
760
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
761
|
+
const switchable = effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message);
|
|
736
762
|
if (!switchable) return next();
|
|
737
763
|
const ref = pool.state.lastUsed;
|
|
738
764
|
if (ref) {
|
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.4",
|
|
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",
|