@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 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
- const [testing, setTesting] = React.useState('');
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
- 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] ? ' ✓' : '')),
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 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);
@@ -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.2",
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
  }