@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 +7 -0
- package/lib/client.js +34 -3
- package/lib/index.js +83 -1
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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",
|