@goodandready/dsh-key-rotation 0.7.28 → 0.7.29
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 +8 -0
- package/lib/client.js +93 -0
- package/lib/index.js +99 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,11 @@
|
|
|
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.
|
|
47
52
|
|
|
48
53
|
## Install
|
|
49
54
|
|
|
@@ -89,6 +94,9 @@ dsh-key-rotation:
|
|
|
89
94
|
| `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
95
|
| `webhookActionToken` | `''` | Bearer token for the interactive webhook callback route. When set, exhaustion webhooks carry action buttons; empty disables them. |
|
|
91
96
|
| `expiryWarnDays` | `7` | Pre-warning horizon (days) for keys with `expiresAt`: webhook + card badge. |
|
|
97
|
+
| `switchNotify` | `false` | Send a webhook on every key switch (opt-in — can be chatty). |
|
|
98
|
+
| `switchNotifyThrottleMs` | `60000` | Minimum gap between switch notifications for the same provider. |
|
|
99
|
+
| `providers[].weights` | `[]` | Positional round-robin weights per key (editable in the card, 0.7.29). |
|
|
92
100
|
| `providers[].costBudgetDaily` / `.costBudgetWeekly` | `0` | Daily / weekly spend budget per provider (0 = off). Warn webhook from 80%. |
|
|
93
101
|
| `providers[].pauseOnBudget` | `false` | Pause the whole pool for 24 h when a budget is exceeded. |
|
|
94
102
|
| `providers[].tags` | `[]` | Free-form labels for a provider pool, surfaced in `GET /status`. |
|
package/lib/client.js
CHANGED
|
@@ -67,6 +67,9 @@ 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
|
+
snapshotExport: 'Snapshot ⬇',
|
|
72
|
+
snapshotImport: 'Restore',
|
|
70
73
|
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
74
|
brokenKey: 'broken (3× AUTH)',
|
|
72
75
|
keyExpired: 'expired',
|
|
@@ -127,6 +130,9 @@ window.__ModuleLoader__.load({
|
|
|
127
130
|
rpmTitle: 'запросов/мин: {u} использовано, {r} осталось',
|
|
128
131
|
budgetLabel: 'бюджет:',
|
|
129
132
|
exportCsv: 'CSV',
|
|
133
|
+
weightHint: 'вес в круге: сколько раз ключ участвует в ротации (1 = поровну)',
|
|
134
|
+
snapshotExport: 'Снапшот ⬇',
|
|
135
|
+
snapshotImport: 'Восстановить',
|
|
130
136
|
keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
|
|
131
137
|
brokenKey: 'сломан (3× AUTH)',
|
|
132
138
|
keyExpired: 'истёк',
|
|
@@ -175,6 +181,24 @@ window.__ModuleLoader__.load({
|
|
|
175
181
|
return byProvider;
|
|
176
182
|
}
|
|
177
183
|
|
|
184
|
+
/** Последний probe-результат по каждому ключу (#219): /sandbox-cache. */
|
|
185
|
+
function useProbeCache() {
|
|
186
|
+
const [cache, setCache] = React.useState({});
|
|
187
|
+
React.useEffect(() => {
|
|
188
|
+
let alive = true;
|
|
189
|
+
const pull = () => {
|
|
190
|
+
fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' } })
|
|
191
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
192
|
+
.then((data) => { if (alive && data) setCache(data); })
|
|
193
|
+
.catch(() => { /* кэш не критичен: карточка работает и без него */ });
|
|
194
|
+
};
|
|
195
|
+
pull();
|
|
196
|
+
const id = setInterval(pull, 4000);
|
|
197
|
+
return () => { alive = false; clearInterval(id); };
|
|
198
|
+
}, []);
|
|
199
|
+
return cache;
|
|
200
|
+
}
|
|
201
|
+
|
|
178
202
|
// formatAgo moved to lib/client-helpers.js for testability — keep local alias for bundle self-containment
|
|
179
203
|
function formatAgo(t, at) {
|
|
180
204
|
if (!at) return '';
|
|
@@ -345,6 +369,7 @@ window.__ModuleLoader__.load({
|
|
|
345
369
|
|
|
346
370
|
const val = draft ?? state.value;
|
|
347
371
|
const status = useRotationStatus();
|
|
372
|
+
const probeCache = useProbeCache();
|
|
348
373
|
const [resetting, setResetting] = React.useState('');
|
|
349
374
|
const doReset = (providerId) => {
|
|
350
375
|
setResetting(providerId);
|
|
@@ -432,6 +457,8 @@ window.__ModuleLoader__.load({
|
|
|
432
457
|
const entry = { ...(providers[pIndex] ?? {}) };
|
|
433
458
|
const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
|
|
434
459
|
entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
|
|
460
|
+
// keep weights aligned with keys (#215): new key gets default weight 1
|
|
461
|
+
if (Array.isArray(entry.weights) && entry.weights.length > 0) entry.weights = [...entry.weights, 1];
|
|
435
462
|
providers[pIndex] = entry;
|
|
436
463
|
return { ...cur, providers };
|
|
437
464
|
});
|
|
@@ -439,6 +466,10 @@ window.__ModuleLoader__.load({
|
|
|
439
466
|
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
440
467
|
stashUndo({ type: 'key', index: pIndex, kIndex, key: next[pIndex]?.keys?.[kIndex] });
|
|
441
468
|
next[pIndex] = { ...next[pIndex], keys: (next[pIndex].keys ?? []).filter((_, i) => i !== kIndex) };
|
|
469
|
+
// weights are positional - drop along with the key (#215)
|
|
470
|
+
if (Array.isArray(next[pIndex].weights) && next[pIndex].weights.length > 0) {
|
|
471
|
+
next[pIndex] = { ...next[pIndex], weights: next[pIndex].weights.filter((_, i) => i !== kIndex) };
|
|
472
|
+
}
|
|
442
473
|
return { ...cur, providers: next };
|
|
443
474
|
}); };
|
|
444
475
|
const removeProvider = (pIndex) => setField((cur) => {
|
|
@@ -458,6 +489,26 @@ window.__ModuleLoader__.load({
|
|
|
458
489
|
keys[kIndex] = keys[target];
|
|
459
490
|
keys[target] = moved;
|
|
460
491
|
entry.keys = keys;
|
|
492
|
+
// weights are positional - swap along with the keys (#215)
|
|
493
|
+
const weights = [...(entry.weights ?? [])];
|
|
494
|
+
if (weights.length > 0) {
|
|
495
|
+
const w = weights[kIndex];
|
|
496
|
+
weights[kIndex] = weights[target];
|
|
497
|
+
weights[target] = w;
|
|
498
|
+
entry.weights = weights;
|
|
499
|
+
}
|
|
500
|
+
providers[pIndex] = entry;
|
|
501
|
+
return { ...cur, providers };
|
|
502
|
+
});
|
|
503
|
+
// #215: set a single key's round-robin weight (integer >= 1)
|
|
504
|
+
const setKeyWeight = (pIndex, kIndex, weight) => setField((cur) => {
|
|
505
|
+
const n = Math.max(1, Math.min(1000, Math.floor(Number(weight) || 1)));
|
|
506
|
+
const providers = [...(cur.providers ?? [])];
|
|
507
|
+
const entry = { ...(providers[pIndex] ?? {}) };
|
|
508
|
+
const weights = [...(entry.weights ?? [])];
|
|
509
|
+
while (weights.length < (entry.keys ?? []).length) weights.push(1);
|
|
510
|
+
weights[kIndex] = n;
|
|
511
|
+
entry.weights = weights;
|
|
461
512
|
providers[pIndex] = entry;
|
|
462
513
|
return { ...cur, providers };
|
|
463
514
|
});
|
|
@@ -549,6 +600,7 @@ window.__ModuleLoader__.load({
|
|
|
549
600
|
h('option', { key: prov.id, value: prov.id }, prov.name + (prov.id !== prov.name ? ' — ' + prov.id : ''))));
|
|
550
601
|
|
|
551
602
|
const keys = entry.keys ?? [];
|
|
603
|
+
const entryWeights = entry.weights ?? [];
|
|
552
604
|
const keyRows = keys.map((key, kIndex) => {
|
|
553
605
|
const st = keyStatus(entry.provider, key);
|
|
554
606
|
const info = keyInfo(entry.provider, key);
|
|
@@ -601,6 +653,12 @@ window.__ModuleLoader__.load({
|
|
|
601
653
|
}
|
|
602
654
|
}
|
|
603
655
|
if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
|
|
656
|
+
// #215: per-key weight input (default 1)
|
|
657
|
+
meta.push(h('input', { key: 'w', type: 'number', min: 1, max: 1000, className: 'krot-in krot-weight',
|
|
658
|
+
value: (entryWeights[kIndex] ?? info?.weight ?? 1),
|
|
659
|
+
title: t('weightHint'),
|
|
660
|
+
onChange: (e) => setKeyWeight(pIndex, kIndex, e.target.value),
|
|
661
|
+
style: { width: '52px', padding: '2px 6px', fontSize: '12px' } }));
|
|
604
662
|
// #210: RPM capacity indicator (only when rpmLimit is active)
|
|
605
663
|
if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
|
|
606
664
|
title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
|
|
@@ -614,6 +672,15 @@ window.__ModuleLoader__.load({
|
|
|
614
672
|
+ (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
|
|
615
673
|
style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
|
|
616
674
|
tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : '✕'));
|
|
675
|
+
// #219: last probe from the sandbox cache, greyed when older than 24h
|
|
676
|
+
else if (probeCache && probeCache[key]) {
|
|
677
|
+
const pc = probeCache[key];
|
|
678
|
+
const stale = Date.now() - (pc.at ?? 0) > 86400000;
|
|
679
|
+
meta.push(h('span', { key: 'pc', className: 'krot-tail',
|
|
680
|
+
title: 'last probe ' + (pc.at ? new Date(pc.at).toLocaleTimeString() : '') + (pc.ok ? ' ok' : ' ' + (pc.code ?? 'fail')),
|
|
681
|
+
style: { opacity: stale ? 0.4 : 0.7, color: pc.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } },
|
|
682
|
+
(pc.ok ? '✓' : '✕') + (pc.latencyMs ? ' ' + pc.latencyMs + 'ms' : '')));
|
|
683
|
+
}
|
|
617
684
|
meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
|
|
618
685
|
meta.push(h('span', { key: 'a', className: 'krot-acts' },
|
|
619
686
|
btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
|
|
@@ -753,6 +820,32 @@ window.__ModuleLoader__.load({
|
|
|
753
820
|
}); } catch (err) { setSecretError(String(err.message || err)); } };
|
|
754
821
|
reader.readAsText(f);
|
|
755
822
|
} }))),
|
|
823
|
+
// #218: full snapshot export/import - one file moves the whole config
|
|
824
|
+
btn(t('snapshotExport'), () => {
|
|
825
|
+
fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' } })
|
|
826
|
+
.then((r) => r.json())
|
|
827
|
+
.then((data) => {
|
|
828
|
+
const blob = new Blob([JSON.stringify(data.snapshot ?? {}, null, 2)], { type: 'application/json' });
|
|
829
|
+
const url = URL.createObjectURL(blob);
|
|
830
|
+
const a = document.createElement('a'); a.href = url; a.download = 'dsh-key-rotation-snapshot.json'; a.click(); URL.revokeObjectURL(url);
|
|
831
|
+
})
|
|
832
|
+
.catch((e) => setSecretError(String(e?.message ?? e)));
|
|
833
|
+
}, {}),
|
|
834
|
+
h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('snapshotImport'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
|
|
835
|
+
const f = e.target.files[0]; if (!f) return;
|
|
836
|
+
const reader2 = new FileReader();
|
|
837
|
+
reader2.onload = () => {
|
|
838
|
+
try {
|
|
839
|
+
const snap = JSON.parse(String(reader2.result));
|
|
840
|
+
if (!snap || typeof snap !== 'object' || Array.isArray(snap)) throw new Error('expected snapshot object');
|
|
841
|
+
fetch('/dsh-key-rotation/snapshot', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ snapshot: snap }) })
|
|
842
|
+
.then((r) => r.json().then((data) => ({ ok: r.ok, data })))
|
|
843
|
+
.then(({ ok, data }) => { if (!ok) throw new Error(data?.error?.message ?? 'import failed'); load(); })
|
|
844
|
+
.catch((err) => setSecretError(t('keyWriteFailed').replace('{msg}', String(err?.message ?? err))));
|
|
845
|
+
} catch (err) { setSecretError(String(err.message || err)); }
|
|
846
|
+
};
|
|
847
|
+
reader2.readAsText(f);
|
|
848
|
+
} })),
|
|
756
849
|
h('p', { className: 'krot-hint' }, t('keyHint')),
|
|
757
850
|
secretError ? h('p', { className: 'krot-err' }, secretError) : null,
|
|
758
851
|
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,28 @@ 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();
|
|
87
89
|
const DAY_MS = 86400000;
|
|
90
|
+
|
|
91
|
+
// #216: one webhook per switch, deduped to at most one message per provider
|
|
92
|
+
// per switchNotifyThrottleMs. Extracted for testability.
|
|
93
|
+
export function notifySwitch(runtime, pool, info, hooks = { webhookSender, now: () => Date.now() }) {
|
|
94
|
+
if (!runtime?.notifyWebhook) return;
|
|
95
|
+
const throttle = Math.max(0, runtime.switchNotifyThrottleMs ?? 60000);
|
|
96
|
+
const last = switchNotifiedAt.get(info.provider) ?? 0;
|
|
97
|
+
const now = hooks.now();
|
|
98
|
+
if (now - last < throttle) return;
|
|
99
|
+
switchNotifiedAt.set(info.provider, now);
|
|
100
|
+
hooks.webhookSender.send(runtime.notifyWebhook, {
|
|
101
|
+
title: `Key switched: ${info.provider}`,
|
|
102
|
+
text: `${info.from} failed (${info.code}) - next key in pool`,
|
|
103
|
+
provider: info.provider,
|
|
104
|
+
kind: 'switch',
|
|
105
|
+
from: info.from,
|
|
106
|
+
code: info.code,
|
|
107
|
+
at: info.at,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
88
110
|
const MAX_EVENTS = 50;
|
|
89
111
|
function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
90
112
|
const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
|
|
@@ -191,6 +213,8 @@ export const Config = Schema.object({
|
|
|
191
213
|
rpmLimit: Schema.number().default(0),
|
|
192
214
|
webhookActionToken: Schema.string().default(''),
|
|
193
215
|
expiryWarnDays: Schema.number().default(7),
|
|
216
|
+
switchNotify: Schema.boolean().default(false),
|
|
217
|
+
switchNotifyThrottleMs: Schema.number().default(60000),
|
|
194
218
|
providers: Schema.array(Schema.object({
|
|
195
219
|
provider: Schema.string().required(),
|
|
196
220
|
keys: Schema.array(Schema.string()).default([]),
|
|
@@ -586,12 +610,20 @@ export function apply(ctx, config = {}) {
|
|
|
586
610
|
if (hit && shouldNotifyDaily(budgetNotifiedAt, pool.base + ':budget', now)) {
|
|
587
611
|
console.warn(`[dsh-key-rotation] ${pool.base}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
|
|
588
612
|
if (runtime.notifyWebhook) {
|
|
613
|
+
// #217: budget webhook gains action buttons when a callback token
|
|
614
|
+
// is configured (the /webhook-action route already knows these ids)
|
|
615
|
+
const token = runtime.webhookActionToken ?? '';
|
|
589
616
|
webhookSender.send(runtime.notifyWebhook, {
|
|
590
617
|
title: `Cost budget: ${pool.base}`,
|
|
591
618
|
text: `day $${daily.toFixed(2)} of $${budget.costBudgetDaily} · week $${weekly.toFixed(2)} of $${budget.costBudgetWeekly}` + (verdict.exceeded || wVerdict.exceeded ? ' · EXCEEDED' : ''),
|
|
592
619
|
provider: pool.base,
|
|
593
620
|
kind: 'budget',
|
|
594
621
|
spend: { daily, weekly },
|
|
622
|
+
actionToken: token || undefined,
|
|
623
|
+
actions: token ? [
|
|
624
|
+
{ id: `pause-${pool.base}`, label: 'Pause 1h' },
|
|
625
|
+
{ id: `reset-${pool.base}`, label: 'Reset cooldown' },
|
|
626
|
+
] : undefined,
|
|
595
627
|
});
|
|
596
628
|
}
|
|
597
629
|
}
|
|
@@ -688,7 +720,8 @@ export function apply(ctx, config = {}) {
|
|
|
688
720
|
if (exp !== undefined) parsedExpiry[refs[i]] = exp;
|
|
689
721
|
}
|
|
690
722
|
}
|
|
691
|
-
return { base, refs,
|
|
723
|
+
return { base, refs, weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
|
|
724
|
+
weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
692
725
|
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry, rpmLimit };
|
|
693
726
|
};
|
|
694
727
|
for (const p of cfg.providers ?? []) {
|
|
@@ -743,7 +776,7 @@ export function apply(ctx, config = {}) {
|
|
|
743
776
|
const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
|
|
744
777
|
if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
|
|
745
778
|
}
|
|
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 };
|
|
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 };
|
|
747
780
|
}
|
|
748
781
|
|
|
749
782
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
@@ -944,7 +977,16 @@ export function apply(ctx, config = {}) {
|
|
|
944
977
|
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
945
978
|
pool.state.lastSwitchAt = Date.now();
|
|
946
979
|
lastFailure = chunk;
|
|
947
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)})
|
|
980
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
981
|
+
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
982
|
+
if (buildRuntime().switchNotify && pool.state.lastUsed) {
|
|
983
|
+
notifySwitch(buildRuntime(), pool, {
|
|
984
|
+
provider: options.provider,
|
|
985
|
+
from: pool.state.lastUsed,
|
|
986
|
+
code: String(code ?? 'UNKNOWN'),
|
|
987
|
+
at: pool.state.lastSwitchAt,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
948
990
|
switching = true;
|
|
949
991
|
break;
|
|
950
992
|
}
|
|
@@ -1107,6 +1149,8 @@ export function apply(ctx, config = {}) {
|
|
|
1107
1149
|
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
1108
1150
|
// #210: RPM capacity snapshot (null when rpmLimit is off)
|
|
1109
1151
|
rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
|
|
1152
|
+
// #215: effective round-robin weight of this key
|
|
1153
|
+
weight: pool.weights?.[pool.refs.indexOf(ref)] ?? 1,
|
|
1110
1154
|
usage: pool.state.usageCounts?.get(ref) ?? 0,
|
|
1111
1155
|
byModel: pool.state.byModel?.get(ref) ? Object.fromEntries(pool.state.byModel.get(ref)) : {},
|
|
1112
1156
|
usageDays: pool.state.usageDays?.get(ref) ? Object.fromEntries(pool.state.usageDays.get(ref)) : {},
|
|
@@ -1182,6 +1226,58 @@ export function apply(ctx, config = {}) {
|
|
|
1182
1226
|
},
|
|
1183
1227
|
}), 'dsh-key-rotation: usage route');
|
|
1184
1228
|
|
|
1229
|
+
// #218: full config snapshot - one JSON file to move between machines.
|
|
1230
|
+
// Secret values never travel: only credential/env names. Token fields are
|
|
1231
|
+
// exported as empty strings; on import they keep existing values when empty.
|
|
1232
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1233
|
+
kind: 'exact',
|
|
1234
|
+
path: SNAPSHOT_PATH,
|
|
1235
|
+
handler: async (req, res) => {
|
|
1236
|
+
if (req.method !== 'GET' && req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'GET (export) or POST (import) only' } }); return; }
|
|
1237
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: snapshot is local-only' } }); return; }
|
|
1238
|
+
if (req.method === 'GET') {
|
|
1239
|
+
const descriptor = descriptorOf(ctx, NS);
|
|
1240
|
+
const value = descriptor?.value ?? {};
|
|
1241
|
+
const exportable = { ...value };
|
|
1242
|
+
// token-shaped fields stay empty in the file; refs are names, not secrets
|
|
1243
|
+
exportable.webhookActionToken = '';
|
|
1244
|
+
if (exportable.incidentGitHubToken) exportable.incidentGitHubToken = '';
|
|
1245
|
+
json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
// POST = import: { snapshot } -> merge with current section, PUT semantics
|
|
1249
|
+
let body;
|
|
1250
|
+
try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
1251
|
+
const snap = body?.snapshot;
|
|
1252
|
+
if (!snap || typeof snap !== 'object' || Array.isArray(snap)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: POST requires {"snapshot": {...}}' } }); return; }
|
|
1253
|
+
// #200 leak guard applies to imported content too
|
|
1254
|
+
try {
|
|
1255
|
+
const masked = structuredClone(snap);
|
|
1256
|
+
if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
|
|
1257
|
+
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
1258
|
+
if (masked.notifyWebhook) masked.notifyWebhook = '***';
|
|
1259
|
+
const findings = findSecrets(JSON.stringify(masked));
|
|
1260
|
+
if (findings.length > 0) { json(res, 400, { error: { code: 'secret-in-snapshot', message: 'dsh-key-rotation: snapshot carries a live-looking credential', findings } }); return; }
|
|
1261
|
+
} catch { /* scanning must never block a valid import */ }
|
|
1262
|
+
const settings = ctx.get('settings');
|
|
1263
|
+
if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
|
|
1264
|
+
const desc = descriptorOf(ctx, NS);
|
|
1265
|
+
if (desc === void 0) { json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace missing' } }); return; }
|
|
1266
|
+
const cur = desc.value ?? {};
|
|
1267
|
+
// empty token fields in the file keep the current values (never wipe a secret)
|
|
1268
|
+
const merged = { ...cur, ...snap };
|
|
1269
|
+
if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
|
|
1270
|
+
if (!snap.incidentGitHubToken) merged.incidentGitHubToken = cur.incidentGitHubToken ?? '';
|
|
1271
|
+
try {
|
|
1272
|
+
await settings.replace(NS, merged, desc.revision);
|
|
1273
|
+
const after = descriptorOf(ctx, NS);
|
|
1274
|
+
json(res, 200, { ok: true, revision: after?.revision });
|
|
1275
|
+
} catch (e) {
|
|
1276
|
+
json(res, e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: 'settings-rejected', message: String(e?.message ?? e) } });
|
|
1277
|
+
}
|
|
1278
|
+
},
|
|
1279
|
+
}), 'dsh-key-rotation: snapshot route');
|
|
1280
|
+
|
|
1185
1281
|
// ── key route: store a key value without leaving the rotation card ──
|
|
1186
1282
|
//
|
|
1187
1283
|
// Adding a key used to mean two screens: create the credential elsewhere,
|
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.29",
|
|
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",
|