@goodandready/dsh-key-rotation 0.7.28 → 0.7.30

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
@@ -44,6 +44,16 @@
44
44
  - **Usage report + CSV export** (added in 0.7.28) — `GET /dsh-key-rotation/usage?days=N[&provider=…][&format=csv]` returns per-key requests/cost over the window; an *Export CSV* button in the card downloads the same data for one provider.
45
45
  - **RPM capacity indicator** (added in 0.7.28) — `/status` carries `rpm: {used, remaining, resetMs}` per key; the card shows a ⏱ counter next to the active key.
46
46
  - **Real key test probe** (added in 0.7.28) — the per-key *Test* button now sends `probe=models` through the existing `/test` route, so it validates the key against the live API (models list + latency), not just credential presence.
47
+ - **Per-key weights in the GUI** (added in 0.7.29) — a small number input per key edits its round-robin weight (1 = equal share); reorder/remove/add keep the positional `weights` array in sync.
48
+ - **Switch notifications** (added in 0.7.29) — opt-in `switchNotify: true` sends a webhook on every key switch (who failed, why, when), deduped to at most one message per provider per `switchNotifyThrottleMs` (default 60 s).
49
+ - **Budget action buttons** (added in 0.7.29) — when `webhookActionToken` is set, budget notifications carry *Pause 1h* / *Reset cooldown* buttons (same callback route as exhaustion alerts).
50
+ - **Config snapshot export/restore** (added in 0.7.29) — *Snapshot ⬇* downloads the whole config as one JSON (token fields exported empty, keys are credential names only); *Restore* imports it back — empty token fields never wipe existing secrets, and a live-looking credential in the file is rejected.
51
+ - **Last probe result per key** (added in 0.7.29) — the card polls `/sandbox-cache` and shows the most recent probe outcome (✓/✕ + latency) next to each key, greyed out when older than 24 h.
52
+ - **Pre-exhaustion alert** (added in 0.7.30) — `warnBelowHealthy` (0 = off): while fewer keys than the threshold are healthy, a webhook fires (once per day per pool) with a *Reset cooldown* button, so the pool never silently dries up.
53
+ - **Telegram-native callbacks** (added in 0.7.30) — `/webhook-action` parses Telegram update envelopes (`callback_query.data`), so interactive buttons work natively; `POST {"setWebhook": {"botToken": …}}` registers the bot webhook in one call (the token is not stored).
54
+ - **Broken-key review** (added in 0.7.30) — keys quarantined for repeated AUTH failures get a *Re-test* button: a live probe (models list) that automatically lifts the 30-day quarantine when the key answers again.
55
+ - **7-day switches chart** (added in 0.7.30) — a second bar chart under the 24 h sparkline shows switches per day over the last week (client-side, no new server state).
56
+ - **Latency SLO alert** (added in 0.7.30) — `latencySloMs` (0 = off): when a key's p95 latency exceeds the threshold, a webhook fires (once per day per key); the card shows `p95 / SLO` per provider.
47
57
 
48
58
  ## Install
49
59
 
@@ -89,6 +99,11 @@ dsh-key-rotation:
89
99
  | `rpmLimit` | `0` | Requests-per-minute cap **per key** (0 = off). A capped key is skipped pre-emptively until its 60 s window frees up. |
90
100
  | `webhookActionToken` | `''` | Bearer token for the interactive webhook callback route. When set, exhaustion webhooks carry action buttons; empty disables them. |
91
101
  | `expiryWarnDays` | `7` | Pre-warning horizon (days) for keys with `expiresAt`: webhook + card badge. |
102
+ | `switchNotify` | `false` | Send a webhook on every key switch (opt-in — can be chatty). |
103
+ | `switchNotifyThrottleMs` | `60000` | Minimum gap between switch notifications for the same provider. |
104
+ | `warnBelowHealthy` | `0` | Webhook "pool running low" while healthy keys < N (0 = off). |
105
+ | `latencySloMs` | `0` | Latency SLO per key: webhook when p95 exceeds it (0 = off). |
106
+ | `providers[].weights` | `[]` | Positional round-robin weights per key (editable in the card, 0.7.29). |
92
107
  | `providers[].costBudgetDaily` / `.costBudgetWeekly` | `0` | Daily / weekly spend budget per provider (0 = off). Warn webhook from 80%. |
93
108
  | `providers[].pauseOnBudget` | `false` | Pause the whole pool for 24 h when a budget is exceeded. |
94
109
  | `providers[].tags` | `[]` | Free-form labels for a provider pool, surfaced in `GET /status`. |
package/lib/client.js CHANGED
@@ -67,6 +67,11 @@ window.__ModuleLoader__.load({
67
67
  rpmTitle: 'requests/min: {u} used, {r} remaining',
68
68
  budgetLabel: 'budget:',
69
69
  exportCsv: 'Export CSV',
70
+ weightHint: 'round-robin weight: how many times this key joins the cycle (1 = equal share)',
71
+ retestBroken: 'Re-test',
72
+ retestFail: 're-test failed - key still down',
73
+ snapshotExport: 'Snapshot ⬇',
74
+ snapshotImport: 'Restore',
70
75
  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.',
71
76
  brokenKey: 'broken (3× AUTH)',
72
77
  keyExpired: 'expired',
@@ -127,6 +132,11 @@ window.__ModuleLoader__.load({
127
132
  rpmTitle: 'запросов/мин: {u} использовано, {r} осталось',
128
133
  budgetLabel: 'бюджет:',
129
134
  exportCsv: 'CSV',
135
+ weightHint: 'вес в круге: сколько раз ключ участвует в ротации (1 = поровну)',
136
+ retestBroken: 'Re-test',
137
+ retestFail: 'перепроверка не прошла — ключ всё ещё недоступен',
138
+ snapshotExport: 'Снапшот ⬇',
139
+ snapshotImport: 'Восстановить',
130
140
  keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
131
141
  brokenKey: 'сломан (3× AUTH)',
132
142
  keyExpired: 'истёк',
@@ -175,6 +185,24 @@ window.__ModuleLoader__.load({
175
185
  return byProvider;
176
186
  }
177
187
 
188
+ /** Последний probe-результат по каждому ключу (#219): /sandbox-cache. */
189
+ function useProbeCache() {
190
+ const [cache, setCache] = React.useState({});
191
+ React.useEffect(() => {
192
+ let alive = true;
193
+ const pull = () => {
194
+ fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' } })
195
+ .then((r) => (r.ok ? r.json() : null))
196
+ .then((data) => { if (alive && data) setCache(data); })
197
+ .catch(() => { /* кэш не критичен: карточка работает и без него */ });
198
+ };
199
+ pull();
200
+ const id = setInterval(pull, 4000);
201
+ return () => { alive = false; clearInterval(id); };
202
+ }, []);
203
+ return cache;
204
+ }
205
+
178
206
  // formatAgo moved to lib/client-helpers.js for testability — keep local alias for bundle self-containment
179
207
  function formatAgo(t, at) {
180
208
  if (!at) return '';
@@ -345,6 +373,7 @@ window.__ModuleLoader__.load({
345
373
 
346
374
  const val = draft ?? state.value;
347
375
  const status = useRotationStatus();
376
+ const probeCache = useProbeCache();
348
377
  const [resetting, setResetting] = React.useState('');
349
378
  const doReset = (providerId) => {
350
379
  setResetting(providerId);
@@ -355,9 +384,21 @@ window.__ModuleLoader__.load({
355
384
  .catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))))
356
385
  .finally(() => setResetting(''));
357
386
  };
358
-
359
-
360
- const keyInfo = (providerId, ref) => {
387
+ // #223: re-test a broken key; a successful live probe lifts the 30-day broken quarantine
388
+ const retestBroken = (ref) => {
389
+ setSecretError('');
390
+ fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref, probe: 'models' }) })
391
+ .then((r) => r.json())
392
+ .then((data) => {
393
+ if (data && data.ok) {
394
+ return fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) });
395
+ }
396
+ setSecretError(t('retestFail'));
397
+ return null;
398
+ })
399
+ .catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
400
+ };
401
+ const keyInfo = (providerId, ref) => {
361
402
  const entryStatus = status[providerId];
362
403
  if (!entryStatus || !ref) return null;
363
404
  return (entryStatus.keys ?? []).find((k) => k.ref === ref) ?? null;
@@ -432,6 +473,8 @@ window.__ModuleLoader__.load({
432
473
  const entry = { ...(providers[pIndex] ?? {}) };
433
474
  const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
434
475
  entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
476
+ // keep weights aligned with keys (#215): new key gets default weight 1
477
+ if (Array.isArray(entry.weights) && entry.weights.length > 0) entry.weights = [...entry.weights, 1];
435
478
  providers[pIndex] = entry;
436
479
  return { ...cur, providers };
437
480
  });
@@ -439,6 +482,10 @@ window.__ModuleLoader__.load({
439
482
  const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
440
483
  stashUndo({ type: 'key', index: pIndex, kIndex, key: next[pIndex]?.keys?.[kIndex] });
441
484
  next[pIndex] = { ...next[pIndex], keys: (next[pIndex].keys ?? []).filter((_, i) => i !== kIndex) };
485
+ // weights are positional - drop along with the key (#215)
486
+ if (Array.isArray(next[pIndex].weights) && next[pIndex].weights.length > 0) {
487
+ next[pIndex] = { ...next[pIndex], weights: next[pIndex].weights.filter((_, i) => i !== kIndex) };
488
+ }
442
489
  return { ...cur, providers: next };
443
490
  }); };
444
491
  const removeProvider = (pIndex) => setField((cur) => {
@@ -458,6 +505,26 @@ window.__ModuleLoader__.load({
458
505
  keys[kIndex] = keys[target];
459
506
  keys[target] = moved;
460
507
  entry.keys = keys;
508
+ // weights are positional - swap along with the keys (#215)
509
+ const weights = [...(entry.weights ?? [])];
510
+ if (weights.length > 0) {
511
+ const w = weights[kIndex];
512
+ weights[kIndex] = weights[target];
513
+ weights[target] = w;
514
+ entry.weights = weights;
515
+ }
516
+ providers[pIndex] = entry;
517
+ return { ...cur, providers };
518
+ });
519
+ // #215: set a single key's round-robin weight (integer >= 1)
520
+ const setKeyWeight = (pIndex, kIndex, weight) => setField((cur) => {
521
+ const n = Math.max(1, Math.min(1000, Math.floor(Number(weight) || 1)));
522
+ const providers = [...(cur.providers ?? [])];
523
+ const entry = { ...(providers[pIndex] ?? {}) };
524
+ const weights = [...(entry.weights ?? [])];
525
+ while (weights.length < (entry.keys ?? []).length) weights.push(1);
526
+ weights[kIndex] = n;
527
+ entry.weights = weights;
461
528
  providers[pIndex] = entry;
462
529
  return { ...cur, providers };
463
530
  });
@@ -549,6 +616,7 @@ window.__ModuleLoader__.load({
549
616
  h('option', { key: prov.id, value: prov.id }, prov.name + (prov.id !== prov.name ? ' — ' + prov.id : ''))));
550
617
 
551
618
  const keys = entry.keys ?? [];
619
+ const entryWeights = entry.weights ?? [];
552
620
  const keyRows = keys.map((key, kIndex) => {
553
621
  const st = keyStatus(entry.provider, key);
554
622
  const info = keyInfo(entry.provider, key);
@@ -601,6 +669,12 @@ window.__ModuleLoader__.load({
601
669
  }
602
670
  }
603
671
  if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
672
+ // #215: per-key weight input (default 1)
673
+ meta.push(h('input', { key: 'w', type: 'number', min: 1, max: 1000, className: 'krot-in krot-weight',
674
+ value: (entryWeights[kIndex] ?? info?.weight ?? 1),
675
+ title: t('weightHint'),
676
+ onChange: (e) => setKeyWeight(pIndex, kIndex, e.target.value),
677
+ style: { width: '52px', padding: '2px 6px', fontSize: '12px' } }));
604
678
  // #210: RPM capacity indicator (only when rpmLimit is active)
605
679
  if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
606
680
  title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
@@ -614,7 +688,20 @@ window.__ModuleLoader__.load({
614
688
  + (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
615
689
  style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
616
690
  tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : '✕'));
691
+ // #219: last probe from the sandbox cache, greyed when older than 24h
692
+ else if (probeCache && probeCache[key]) {
693
+ const pc = probeCache[key];
694
+ const stale = Date.now() - (pc.at ?? 0) > 86400000;
695
+ meta.push(h('span', { key: 'pc', className: 'krot-tail',
696
+ title: 'last probe ' + (pc.at ? new Date(pc.at).toLocaleTimeString() : '') + (pc.ok ? ' ok' : ' ' + (pc.code ?? 'fail')),
697
+ style: { opacity: stale ? 0.4 : 0.7, color: pc.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } },
698
+ (pc.ok ? '✓' : '✕') + (pc.latencyMs ? ' ' + pc.latencyMs + 'ms' : '')));
699
+ }
617
700
  meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
701
+ // #223: broken keys get a one-click live re-test + auto-unbreak
702
+ if (info && info.broken) {
703
+ meta.push(h('button', { key: 'rt', className: 'krot-btn', onClick: () => retestBroken(key), title: t('retestBroken') }, t('retestBroken')));
704
+ }
618
705
  meta.push(h('span', { key: 'a', className: 'krot-acts' },
619
706
  btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
620
707
  btn('↓', () => moveKey(pIndex, kIndex, 1), { disabled: kIndex === keys.length - 1, title: t('moveDown') }),
@@ -652,6 +739,14 @@ window.__ModuleLoader__.load({
652
739
  return h('p', { className: 'krot-hint', style: { color } }, t('budgetLabel') + ' ' + parts.join(' · '));
653
740
  })()
654
741
  : null;
742
+ // #225: provider p95 latency + SLO marker
743
+ const sloLine = providerStatus && providerStatus.p95 != null
744
+ ? (() => {
745
+ const over = providerStatus.latencySloMs && providerStatus.p95 > providerStatus.latencySloMs;
746
+ return h('p', { className: 'krot-hint', style: over ? { color: 'var(--dsw-alias-state-warning-primary)' } : undefined },
747
+ 'p95 ' + providerStatus.p95 + 'ms' + (providerStatus.latencySloMs ? ' / ' + providerStatus.latencySloMs + 'ms SLO' : ''));
748
+ })()
749
+ : null;
655
750
  // #209: CSV export for this provider's usage (last 7 days)
656
751
  const exportCsv = h('button', { className: 'krot-btn', title: t('exportCsv'),
657
752
  onClick: () => {
@@ -698,8 +793,11 @@ window.__ModuleLoader__.load({
698
793
  btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
699
794
  switchesLine,
700
795
  budgetLine,
796
+ sloLine,
701
797
  exhaustionWarning,
702
798
  (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),
799
+ // #224: 7-day switches per day (client-side, from the same events)
800
+ (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),
703
801
  (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),
704
802
  btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
705
803
  btn(testAllProvider === entry.provider ? t('testing') : t('testAll'), () => doTestAll(entry.provider), { disabled: testAllProvider === entry.provider, title: t('testAll') }),
@@ -753,6 +851,32 @@ window.__ModuleLoader__.load({
753
851
  }); } catch (err) { setSecretError(String(err.message || err)); } };
754
852
  reader.readAsText(f);
755
853
  } }))),
854
+ // #218: full snapshot export/import - one file moves the whole config
855
+ btn(t('snapshotExport'), () => {
856
+ fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' } })
857
+ .then((r) => r.json())
858
+ .then((data) => {
859
+ const blob = new Blob([JSON.stringify(data.snapshot ?? {}, null, 2)], { type: 'application/json' });
860
+ const url = URL.createObjectURL(blob);
861
+ const a = document.createElement('a'); a.href = url; a.download = 'dsh-key-rotation-snapshot.json'; a.click(); URL.revokeObjectURL(url);
862
+ })
863
+ .catch((e) => setSecretError(String(e?.message ?? e)));
864
+ }, {}),
865
+ h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('snapshotImport'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
866
+ const f = e.target.files[0]; if (!f) return;
867
+ const reader2 = new FileReader();
868
+ reader2.onload = () => {
869
+ try {
870
+ const snap = JSON.parse(String(reader2.result));
871
+ if (!snap || typeof snap !== 'object' || Array.isArray(snap)) throw new Error('expected snapshot object');
872
+ fetch('/dsh-key-rotation/snapshot', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ snapshot: snap }) })
873
+ .then((r) => r.json().then((data) => ({ ok: r.ok, data })))
874
+ .then(({ ok, data }) => { if (!ok) throw new Error(data?.error?.message ?? 'import failed'); load(); })
875
+ .catch((err) => setSecretError(t('keyWriteFailed').replace('{msg}', String(err?.message ?? err))));
876
+ } catch (err) { setSecretError(String(err.message || err)); }
877
+ };
878
+ reader2.readAsText(f);
879
+ } })),
756
880
  h('p', { className: 'krot-hint' }, t('keyHint')),
757
881
  secretError ? h('p', { className: 'krot-err' }, secretError) : null,
758
882
  state.error ? h('p', { className: 'krot-err' }, state.error) : null,
package/lib/index.js CHANGED
@@ -44,6 +44,7 @@ const NS = 'dsh-key-rotation';
44
44
  /** Config bridge route (GET / PUT / DELETE), loopback-fenced like llm-fallback. */
45
45
  const CONFIG_PATH = '/dsh-key-rotation/config';
46
46
  const STATUS_PATH = '/dsh-key-rotation/status';
47
+ const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
47
48
  const KEY_PATH = '/dsh-key-rotation/key';
48
49
  const RESET_PATH = '/dsh-key-rotation/reset';
49
50
  const IMPORT_PATH = '/dsh-key-rotation/import';
@@ -84,7 +85,30 @@ let rotationDisabled = false;
84
85
  // #207/#208 dedupe maps: one notification per key/window per day.
85
86
  const expiryNotifiedAt = new Map();
86
87
  const budgetNotifiedAt = new Map();
88
+ const switchNotifiedAt = new Map();
89
+ const lowHealthNotifiedAt = new Map();
90
+ const sloNotifiedAt = new Map();
87
91
  const DAY_MS = 86400000;
92
+
93
+ // #216: one webhook per switch, deduped to at most one message per provider
94
+ // per switchNotifyThrottleMs. Extracted for testability.
95
+ export function notifySwitch(runtime, pool, info, hooks = { webhookSender, now: () => Date.now() }) {
96
+ if (!runtime?.notifyWebhook) return;
97
+ const throttle = Math.max(0, runtime.switchNotifyThrottleMs ?? 60000);
98
+ const last = switchNotifiedAt.get(info.provider) ?? 0;
99
+ const now = hooks.now();
100
+ if (now - last < throttle) return;
101
+ switchNotifiedAt.set(info.provider, now);
102
+ hooks.webhookSender.send(runtime.notifyWebhook, {
103
+ title: `Key switched: ${info.provider}`,
104
+ text: `${info.from} failed (${info.code}) - next key in pool`,
105
+ provider: info.provider,
106
+ kind: 'switch',
107
+ from: info.from,
108
+ code: info.code,
109
+ at: info.at,
110
+ });
111
+ }
88
112
  const MAX_EVENTS = 50;
89
113
  function pushEvent(pool, ref, reason, cooldownMs, type) {
90
114
  const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
@@ -191,6 +215,10 @@ export const Config = Schema.object({
191
215
  rpmLimit: Schema.number().default(0),
192
216
  webhookActionToken: Schema.string().default(''),
193
217
  expiryWarnDays: Schema.number().default(7),
218
+ switchNotify: Schema.boolean().default(false),
219
+ switchNotifyThrottleMs: Schema.number().default(60000),
220
+ warnBelowHealthy: Schema.number().default(0),
221
+ latencySloMs: Schema.number().default(0),
194
222
  providers: Schema.array(Schema.object({
195
223
  provider: Schema.string().required(),
196
224
  keys: Schema.array(Schema.string()).default([]),
@@ -586,12 +614,20 @@ export function apply(ctx, config = {}) {
586
614
  if (hit && shouldNotifyDaily(budgetNotifiedAt, pool.base + ':budget', now)) {
587
615
  console.warn(`[dsh-key-rotation] ${pool.base}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
588
616
  if (runtime.notifyWebhook) {
617
+ // #217: budget webhook gains action buttons when a callback token
618
+ // is configured (the /webhook-action route already knows these ids)
619
+ const token = runtime.webhookActionToken ?? '';
589
620
  webhookSender.send(runtime.notifyWebhook, {
590
621
  title: `Cost budget: ${pool.base}`,
591
622
  text: `day $${daily.toFixed(2)} of $${budget.costBudgetDaily} · week $${weekly.toFixed(2)} of $${budget.costBudgetWeekly}` + (verdict.exceeded || wVerdict.exceeded ? ' · EXCEEDED' : ''),
592
623
  provider: pool.base,
593
624
  kind: 'budget',
594
625
  spend: { daily, weekly },
626
+ actionToken: token || undefined,
627
+ actions: token ? [
628
+ { id: `pause-${pool.base}`, label: 'Pause 1h' },
629
+ { id: `reset-${pool.base}`, label: 'Reset cooldown' },
630
+ ] : undefined,
595
631
  });
596
632
  }
597
633
  }
@@ -601,6 +637,55 @@ export function apply(ctx, config = {}) {
601
637
  if ((pool.state.failedUntil.get(ref) ?? 0) < until) pool.state.failedUntil.set(ref, until);
602
638
  }
603
639
  }
640
+ // #221: pool running low - webhook while healthy < warnBelowHealthy
641
+ const warnBelow = runtime.warnBelowHealthy ?? 0;
642
+ if (warnBelow > 0) {
643
+ let healthy = 0;
644
+ for (const ref of pool.refs) {
645
+ const fu = pool.state.failedUntil.get(ref);
646
+ if (fu !== undefined && fu > now) continue;
647
+ const exp = pool.expiresAt?.[ref];
648
+ if (exp !== undefined && now >= exp) continue;
649
+ healthy++;
650
+ }
651
+ if (healthy < warnBelow && shouldNotifyDaily(lowHealthNotifiedAt, pool.base, now)) {
652
+ console.warn(`[dsh-key-rotation] ${pool.base}: pool running low - ${healthy}/${pool.refs.length} healthy`);
653
+ if (runtime.notifyWebhook) {
654
+ const token = runtime.webhookActionToken ?? '';
655
+ webhookSender.send(runtime.notifyWebhook, {
656
+ title: `Pool running low: ${pool.base}`,
657
+ text: `${healthy}/${pool.refs.length} keys healthy (alert below ${warnBelow})`,
658
+ provider: pool.base,
659
+ kind: 'low-health',
660
+ healthy,
661
+ total: pool.refs.length,
662
+ actionToken: token || undefined,
663
+ actions: token ? [{ id: `reset-${pool.base}`, label: 'Reset cooldown' }] : undefined,
664
+ });
665
+ }
666
+ }
667
+ }
668
+ // #225: latency SLO - webhook when a key's p95 exceeds the threshold
669
+ const slo = runtime.latencySloMs ?? 0;
670
+ if (slo > 0) {
671
+ for (const ref of pool.refs) {
672
+ const snap = latencyHistogram.snapshot(ref);
673
+ if (!snap.p95 || snap.p95 <= slo) continue;
674
+ if (!shouldNotifyDaily(sloNotifiedAt, pool.base + ':' + ref + ':slo', now)) continue;
675
+ console.warn(`[dsh-key-rotation] ${pool.base}: ${ref} p95 ${Math.round(snap.p95)}ms > SLO ${slo}ms`);
676
+ if (runtime.notifyWebhook) {
677
+ webhookSender.send(runtime.notifyWebhook, {
678
+ title: `Latency SLO exceeded: ${pool.base}`,
679
+ text: `${ref} p95 ${Math.round(snap.p95)}ms > ${slo}ms (${snap.count} samples)`,
680
+ provider: pool.base,
681
+ kind: 'latency-slo',
682
+ ref,
683
+ p95: Math.round(snap.p95),
684
+ slo,
685
+ });
686
+ }
687
+ }
688
+ }
604
689
  }
605
690
  } catch (_) { /* maintenance must never crash the sweep */ }
606
691
  }, 30000);
@@ -688,7 +773,8 @@ export function apply(ctx, config = {}) {
688
773
  if (exp !== undefined) parsedExpiry[refs[i]] = exp;
689
774
  }
690
775
  }
691
- return { base, refs, weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
776
+ return { base, refs, weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
777
+ weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
692
778
  state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry, rpmLimit };
693
779
  };
694
780
  for (const p of cfg.providers ?? []) {
@@ -743,7 +829,7 @@ export function apply(ctx, config = {}) {
743
829
  const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
744
830
  if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
745
831
  }
746
- return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, concurrencyLimit, canaryProbingEnabled, canaryIntervalMs, cascade, quotaResetWindow, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
832
+ return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, concurrencyLimit, canaryProbingEnabled, canaryIntervalMs, cascade, quotaResetWindow, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
747
833
  }
748
834
 
749
835
  // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
@@ -944,7 +1030,16 @@ export function apply(ctx, config = {}) {
944
1030
  pool.state.lastReason = String(code ?? 'UNKNOWN');
945
1031
  pool.state.lastSwitchAt = Date.now();
946
1032
  lastFailure = chunk;
947
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) next key`);
1033
+ console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
1034
+ // #216: per-switch webhook (opt-in switchNotify), deduped per provider
1035
+ if (buildRuntime().switchNotify && pool.state.lastUsed) {
1036
+ notifySwitch(buildRuntime(), pool, {
1037
+ provider: options.provider,
1038
+ from: pool.state.lastUsed,
1039
+ code: String(code ?? 'UNKNOWN'),
1040
+ at: pool.state.lastSwitchAt,
1041
+ });
1042
+ }
948
1043
  switching = true;
949
1044
  break;
950
1045
  }
@@ -1062,6 +1157,7 @@ export function apply(ctx, config = {}) {
1062
1157
  const { poolByRef, providerTags, providerBudgets } = buildRuntime();
1063
1158
  const base = ctx.get('credentials');
1064
1159
  const now = Date.now();
1160
+ const latencySloMs = buildRuntime().latencySloMs;
1065
1161
  const seen = new Set();
1066
1162
  const providers = [];
1067
1163
  for (const pool of poolByRef.values()) {
@@ -1107,6 +1203,8 @@ export function apply(ctx, config = {}) {
1107
1203
  cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
1108
1204
  // #210: RPM capacity snapshot (null when rpmLimit is off)
1109
1205
  rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
1206
+ // #215: effective round-robin weight of this key
1207
+ weight: pool.weights?.[pool.refs.indexOf(ref)] ?? 1,
1110
1208
  usage: pool.state.usageCounts?.get(ref) ?? 0,
1111
1209
  byModel: pool.state.byModel?.get(ref) ? Object.fromEntries(pool.state.byModel.get(ref)) : {},
1112
1210
  usageDays: pool.state.usageDays?.get(ref) ? Object.fromEntries(pool.state.usageDays.get(ref)) : {},
@@ -1127,6 +1225,12 @@ export function apply(ctx, config = {}) {
1127
1225
  lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
1128
1226
  exhaustionCount: pool.state.exhaustionCount ?? 0,
1129
1227
  totalUsage: [...(pool.state.usageCounts?.values() ?? [])].reduce((a, b) => a + b, 0),
1228
+ // #225: aggregate p95 across the pool's keys
1229
+ p95: (() => {
1230
+ const vals = (pool.refs ?? []).map((r) => latencyHistogram.snapshot(r)).filter((s) => s && s.p95 != null).map((s) => s.p95);
1231
+ return vals.length ? Math.round(Math.max(...vals)) : null;
1232
+ })(),
1233
+ latencySloMs,
1130
1234
  events: (pool.state.events ?? []).slice(-50),
1131
1235
  healthScore: computeHealthScore(pool.state),
1132
1236
  // #208: today/week spend + configured budget for the card
@@ -1182,6 +1286,58 @@ export function apply(ctx, config = {}) {
1182
1286
  },
1183
1287
  }), 'dsh-key-rotation: usage route');
1184
1288
 
1289
+ // #218: full config snapshot - one JSON file to move between machines.
1290
+ // Secret values never travel: only credential/env names. Token fields are
1291
+ // exported as empty strings; on import they keep existing values when empty.
1292
+ ctx.effect(() => ctx.webServer.register({
1293
+ kind: 'exact',
1294
+ path: SNAPSHOT_PATH,
1295
+ handler: async (req, res) => {
1296
+ if (req.method !== 'GET' && req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'GET (export) or POST (import) only' } }); return; }
1297
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: snapshot is local-only' } }); return; }
1298
+ if (req.method === 'GET') {
1299
+ const descriptor = descriptorOf(ctx, NS);
1300
+ const value = descriptor?.value ?? {};
1301
+ const exportable = { ...value };
1302
+ // token-shaped fields stay empty in the file; refs are names, not secrets
1303
+ exportable.webhookActionToken = '';
1304
+ if (exportable.incidentGitHubToken) exportable.incidentGitHubToken = '';
1305
+ json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
1306
+ return;
1307
+ }
1308
+ // POST = import: { snapshot } -> merge with current section, PUT semantics
1309
+ let body;
1310
+ try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
1311
+ const snap = body?.snapshot;
1312
+ if (!snap || typeof snap !== 'object' || Array.isArray(snap)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: POST requires {"snapshot": {...}}' } }); return; }
1313
+ // #200 leak guard applies to imported content too
1314
+ try {
1315
+ const masked = structuredClone(snap);
1316
+ if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
1317
+ if (masked.webhookActionToken) masked.webhookActionToken = '***';
1318
+ if (masked.notifyWebhook) masked.notifyWebhook = '***';
1319
+ const findings = findSecrets(JSON.stringify(masked));
1320
+ if (findings.length > 0) { json(res, 400, { error: { code: 'secret-in-snapshot', message: 'dsh-key-rotation: snapshot carries a live-looking credential', findings } }); return; }
1321
+ } catch { /* scanning must never block a valid import */ }
1322
+ const settings = ctx.get('settings');
1323
+ if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
1324
+ const desc = descriptorOf(ctx, NS);
1325
+ if (desc === void 0) { json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace missing' } }); return; }
1326
+ const cur = desc.value ?? {};
1327
+ // empty token fields in the file keep the current values (never wipe a secret)
1328
+ const merged = { ...cur, ...snap };
1329
+ if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
1330
+ if (!snap.incidentGitHubToken) merged.incidentGitHubToken = cur.incidentGitHubToken ?? '';
1331
+ try {
1332
+ await settings.replace(NS, merged, desc.revision);
1333
+ const after = descriptorOf(ctx, NS);
1334
+ json(res, 200, { ok: true, revision: after?.revision });
1335
+ } catch (e) {
1336
+ json(res, e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: 'settings-rejected', message: String(e?.message ?? e) } });
1337
+ }
1338
+ },
1339
+ }), 'dsh-key-rotation: snapshot route');
1340
+
1185
1341
  // ── key route: store a key value without leaving the rotation card ──
1186
1342
  //
1187
1343
  // Adding a key used to mean two screens: create the credential elsewhere,
@@ -1496,6 +1652,28 @@ export function apply(ctx, config = {}) {
1496
1652
  if (!action && typeof body?.callback_data === 'string') {
1497
1653
  try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
1498
1654
  }
1655
+ // #222: Telegram update envelope {update_id, callback_query:{data}}
1656
+ if (!action && typeof body?.callback_query?.data === 'string') {
1657
+ try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
1658
+ }
1659
+ // #222: Telegram setWebhook registration helper
1660
+ if (typeof body?.setWebhook === 'object' && body.setWebhook) {
1661
+ const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
1662
+ if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
1663
+ // derive the public URL from request headers; explicit URL wins
1664
+ const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
1665
+ try {
1666
+ const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
1667
+ method: 'POST', headers: { 'content-type': 'application/json' },
1668
+ body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
1669
+ });
1670
+ const hookData = await hookRes.json().catch(() => ({}));
1671
+ json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
1672
+ } catch (e) {
1673
+ json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
1674
+ }
1675
+ return;
1676
+ }
1499
1677
  if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
1500
1678
  const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
1501
1679
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.28",
3
+ "version": "0.7.30",
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",