@goodandready/dsh-key-rotation 0.7.29 → 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
@@ -49,6 +49,11 @@
49
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
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
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.
52
57
 
53
58
  ## Install
54
59
 
@@ -96,6 +101,8 @@ dsh-key-rotation:
96
101
  | `expiryWarnDays` | `7` | Pre-warning horizon (days) for keys with `expiresAt`: webhook + card badge. |
97
102
  | `switchNotify` | `false` | Send a webhook on every key switch (opt-in — can be chatty). |
98
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). |
99
106
  | `providers[].weights` | `[]` | Positional round-robin weights per key (editable in the card, 0.7.29). |
100
107
  | `providers[].costBudgetDaily` / `.costBudgetWeekly` | `0` | Daily / weekly spend budget per provider (0 = off). Warn webhook from 80%. |
101
108
  | `providers[].pauseOnBudget` | `false` | Pause the whole pool for 24 h when a budget is exceeded. |
package/lib/client.js CHANGED
@@ -68,6 +68,8 @@ window.__ModuleLoader__.load({
68
68
  budgetLabel: 'budget:',
69
69
  exportCsv: 'Export CSV',
70
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',
71
73
  snapshotExport: 'Snapshot ⬇',
72
74
  snapshotImport: 'Restore',
73
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.',
@@ -131,6 +133,8 @@ window.__ModuleLoader__.load({
131
133
  budgetLabel: 'бюджет:',
132
134
  exportCsv: 'CSV',
133
135
  weightHint: 'вес в круге: сколько раз ключ участвует в ротации (1 = поровну)',
136
+ retestBroken: 'Re-test',
137
+ retestFail: 'перепроверка не прошла — ключ всё ещё недоступен',
134
138
  snapshotExport: 'Снапшот ⬇',
135
139
  snapshotImport: 'Восстановить',
136
140
  keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
@@ -380,9 +384,21 @@ window.__ModuleLoader__.load({
380
384
  .catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))))
381
385
  .finally(() => setResetting(''));
382
386
  };
383
-
384
-
385
- 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) => {
386
402
  const entryStatus = status[providerId];
387
403
  if (!entryStatus || !ref) return null;
388
404
  return (entryStatus.keys ?? []).find((k) => k.ref === ref) ?? null;
@@ -682,6 +698,10 @@ window.__ModuleLoader__.load({
682
698
  (pc.ok ? '✓' : '✕') + (pc.latencyMs ? ' ' + pc.latencyMs + 'ms' : '')));
683
699
  }
684
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
+ }
685
705
  meta.push(h('span', { key: 'a', className: 'krot-acts' },
686
706
  btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
687
707
  btn('↓', () => moveKey(pIndex, kIndex, 1), { disabled: kIndex === keys.length - 1, title: t('moveDown') }),
@@ -719,6 +739,14 @@ window.__ModuleLoader__.load({
719
739
  return h('p', { className: 'krot-hint', style: { color } }, t('budgetLabel') + ' ' + parts.join(' · '));
720
740
  })()
721
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;
722
750
  // #209: CSV export for this provider's usage (last 7 days)
723
751
  const exportCsv = h('button', { className: 'krot-btn', title: t('exportCsv'),
724
752
  onClick: () => {
@@ -765,8 +793,11 @@ window.__ModuleLoader__.load({
765
793
  btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
766
794
  switchesLine,
767
795
  budgetLine,
796
+ sloLine,
768
797
  exhaustionWarning,
769
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),
770
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),
771
802
  btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
772
803
  btn(testAllProvider === entry.provider ? t('testing') : t('testAll'), () => doTestAll(entry.provider), { disabled: testAllProvider === entry.provider, title: t('testAll') }),
package/lib/index.js CHANGED
@@ -86,6 +86,8 @@ let rotationDisabled = false;
86
86
  const expiryNotifiedAt = new Map();
87
87
  const budgetNotifiedAt = new Map();
88
88
  const switchNotifiedAt = new Map();
89
+ const lowHealthNotifiedAt = new Map();
90
+ const sloNotifiedAt = new Map();
89
91
  const DAY_MS = 86400000;
90
92
 
91
93
  // #216: one webhook per switch, deduped to at most one message per provider
@@ -215,6 +217,8 @@ export const Config = Schema.object({
215
217
  expiryWarnDays: Schema.number().default(7),
216
218
  switchNotify: Schema.boolean().default(false),
217
219
  switchNotifyThrottleMs: Schema.number().default(60000),
220
+ warnBelowHealthy: Schema.number().default(0),
221
+ latencySloMs: Schema.number().default(0),
218
222
  providers: Schema.array(Schema.object({
219
223
  provider: Schema.string().required(),
220
224
  keys: Schema.array(Schema.string()).default([]),
@@ -633,6 +637,55 @@ export function apply(ctx, config = {}) {
633
637
  if ((pool.state.failedUntil.get(ref) ?? 0) < until) pool.state.failedUntil.set(ref, until);
634
638
  }
635
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
+ }
636
689
  }
637
690
  } catch (_) { /* maintenance must never crash the sweep */ }
638
691
  }, 30000);
@@ -776,7 +829,7 @@ export function apply(ctx, config = {}) {
776
829
  const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
777
830
  if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
778
831
  }
779
- 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, 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 };
780
833
  }
781
834
 
782
835
  // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
@@ -1104,6 +1157,7 @@ export function apply(ctx, config = {}) {
1104
1157
  const { poolByRef, providerTags, providerBudgets } = buildRuntime();
1105
1158
  const base = ctx.get('credentials');
1106
1159
  const now = Date.now();
1160
+ const latencySloMs = buildRuntime().latencySloMs;
1107
1161
  const seen = new Set();
1108
1162
  const providers = [];
1109
1163
  for (const pool of poolByRef.values()) {
@@ -1171,6 +1225,12 @@ export function apply(ctx, config = {}) {
1171
1225
  lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
1172
1226
  exhaustionCount: pool.state.exhaustionCount ?? 0,
1173
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,
1174
1234
  events: (pool.state.events ?? []).slice(-50),
1175
1235
  healthScore: computeHealthScore(pool.state),
1176
1236
  // #208: today/week spend + configured budget for the card
@@ -1592,6 +1652,28 @@ export function apply(ctx, config = {}) {
1592
1652
  if (!action && typeof body?.callback_data === 'string') {
1593
1653
  try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
1594
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
+ }
1595
1677
  if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
1596
1678
  const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
1597
1679
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.29",
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",