@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 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 providerList = Array.isArray(val.providers) ? val.providers : [];
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
- t('keyLabel').replace('{n}', String(kIndex + 1))),
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('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),
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 n = sweepExpired(poolState, Date.now());
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
- (switchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
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
- events: (pool.state.events ?? []).slice(-20),
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 switchable = switchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message);
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.1",
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",