@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 +15 -0
- package/lib/client.js +127 -3
- package/lib/index.js +181 -3
- package/package.json +1 -1
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
|
-
|
|
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,
|
|
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)})
|
|
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.
|
|
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",
|