@goodandready/dsh-key-rotation 0.5.3 → 0.5.5
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/lib/client.js +22 -0
- package/lib/index.js +91 -8
- package/lib/pool.js +34 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -60,6 +60,9 @@ window.__ModuleLoader__.load({
|
|
|
60
60
|
keyFromEnv: 'from the environment, read-only here',
|
|
61
61
|
keyWriteFailed: 'could not store the key: {msg}',
|
|
62
62
|
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.',
|
|
63
|
+
resetCooldown: 'Reset cooldown',
|
|
64
|
+
poolExhausted: 'pool exhausted — all keys cooling',
|
|
65
|
+
resetting: 'Resetting…',
|
|
63
66
|
keyLabel: 'Key {n}',
|
|
64
67
|
};
|
|
65
68
|
const ru = {
|
|
@@ -97,6 +100,9 @@ window.__ModuleLoader__.load({
|
|
|
97
100
|
keyFromEnv: 'задан в окружении, отсюда не меняется',
|
|
98
101
|
keyWriteFailed: 'не удалось сохранить ключ: {msg}',
|
|
99
102
|
keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
|
|
103
|
+
resetCooldown: 'Сбросить кулдаун',
|
|
104
|
+
poolExhausted: 'пул исчерпан — все ключи остывают',
|
|
105
|
+
resetting: 'Сброс…',
|
|
100
106
|
keyLabel: 'Ключ {n}',
|
|
101
107
|
};
|
|
102
108
|
|
|
@@ -250,6 +256,16 @@ window.__ModuleLoader__.load({
|
|
|
250
256
|
// хоста они не приходят, в карточке видны лишь последние символы.
|
|
251
257
|
const [secretDraft, setSecretDraft] = React.useState({});
|
|
252
258
|
const [secretError, setSecretError] = React.useState('');
|
|
259
|
+
const [resetting, setResetting] = React.useState('');
|
|
260
|
+
const doReset = (providerId) => {
|
|
261
|
+
setResetting(providerId);
|
|
262
|
+
setSecretError('');
|
|
263
|
+
fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: providerId }) })
|
|
264
|
+
.then((r) => r.json().then((data) => ({ ok: r.ok, data })))
|
|
265
|
+
.then(({ ok, data }) => { if (!ok) throw new Error(data?.error?.message ?? 'unknown error'); })
|
|
266
|
+
.catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))))
|
|
267
|
+
.finally(() => setResetting(''));
|
|
268
|
+
};
|
|
253
269
|
|
|
254
270
|
const keyInfo = (providerId, ref) => {
|
|
255
271
|
const entryStatus = status[providerId];
|
|
@@ -457,6 +473,9 @@ window.__ModuleLoader__.load({
|
|
|
457
473
|
.replace('{reason}', String(providerStatus.lastReason || '—'))
|
|
458
474
|
.replace('{ago}', formatAgo(t, providerStatus.lastSwitchAt))
|
|
459
475
|
: t('switchesNone'));
|
|
476
|
+
const exhaustionWarning = providerStatus && providerStatus.lastExhaustionAt && (Date.now() - providerStatus.lastExhaustionAt) < 3600000
|
|
477
|
+
? h('p', { className: 'krot-err' }, t('poolExhausted') + ' (' + formatAgo(t, providerStatus.lastExhaustionAt) + ')')
|
|
478
|
+
: null;
|
|
460
479
|
|
|
461
480
|
return h('div', { key: pIndex, className: 'krot-prov' },
|
|
462
481
|
h('div', { className: 'krot-prov-head' },
|
|
@@ -467,6 +486,9 @@ window.__ModuleLoader__.load({
|
|
|
467
486
|
h('div', { className: 'krot-foot' },
|
|
468
487
|
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
|
|
469
488
|
switchesLine,
|
|
489
|
+
exhaustionWarning,
|
|
490
|
+
(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 }, new Date(ev.at).toLocaleTimeString() + ' ' + ev.ref + ' ' + ev.reason + ' cd=' + ev.cooldownMs)) )) : null),
|
|
491
|
+
btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
|
|
470
492
|
),
|
|
471
493
|
);
|
|
472
494
|
});
|
package/lib/index.js
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
// providers: array [{ provider, keys: [envName, ...] }]
|
|
34
34
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
35
|
import Schema from '@deepseek-ai/schemastery';
|
|
36
|
-
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown } from './pool.js';
|
|
36
|
+
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue } from './pool.js';
|
|
37
37
|
|
|
38
38
|
export const name = 'dsh-key-rotation';
|
|
39
39
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
@@ -45,11 +45,18 @@ const NS = 'dsh-key-rotation';
|
|
|
45
45
|
const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
46
46
|
const STATUS_PATH = '/dsh-key-rotation/status';
|
|
47
47
|
const KEY_PATH = '/dsh-key-rotation/key';
|
|
48
|
+
const RESET_PATH = '/dsh-key-rotation/reset';
|
|
48
49
|
|
|
49
50
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
50
51
|
const PIAI_NS = 'llm-pi-ai';
|
|
51
52
|
/** Marker on internally re-dispatched requests so the interceptor does not loop. */
|
|
52
53
|
const MARKER = '__dshKeyRotation';
|
|
54
|
+
const MAX_EVENTS = 50;
|
|
55
|
+
function pushEvent(pool, ref, reason, cooldownMs) {
|
|
56
|
+
const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs };
|
|
57
|
+
pool.state.events.push(ev);
|
|
58
|
+
if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
|
|
59
|
+
}
|
|
53
60
|
|
|
54
61
|
|
|
55
62
|
// Fallback classification by failure message. pi-ai surfaces many real quota /
|
|
@@ -73,6 +80,7 @@ export const Config = Schema.object({
|
|
|
73
80
|
providers: Schema.array(Schema.object({
|
|
74
81
|
provider: Schema.string().required(),
|
|
75
82
|
keys: Schema.array(Schema.string()).default([]),
|
|
83
|
+
cooldownMs: Schema.number(),
|
|
76
84
|
})).default([...DEFAULT_PROVIDERS]),
|
|
77
85
|
});
|
|
78
86
|
|
|
@@ -256,6 +264,7 @@ export function apply(ctx, config = {}) {
|
|
|
256
264
|
if (!state) {
|
|
257
265
|
state = {
|
|
258
266
|
failedUntil: new Map(),
|
|
267
|
+
failCounts: new Map(),
|
|
259
268
|
pointer: 0,
|
|
260
269
|
lastUsed: undefined,
|
|
261
270
|
// Счётчики для карточки настроек: без них о работе ротации можно было
|
|
@@ -263,10 +272,14 @@ export function apply(ctx, config = {}) {
|
|
|
263
272
|
switches: 0,
|
|
264
273
|
lastReason: undefined,
|
|
265
274
|
lastSwitchAt: undefined,
|
|
275
|
+
lastExhaustionAt: undefined,
|
|
276
|
+
exhaustionCount: 0,
|
|
277
|
+
events: [],
|
|
266
278
|
};
|
|
267
279
|
poolState.set(p.provider, state);
|
|
268
280
|
}
|
|
269
|
-
const
|
|
281
|
+
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
282
|
+
const pool = { base: p.provider, refs, state, cooldownMs: poolCooldown };
|
|
270
283
|
for (const ref of refs) poolByRef.set(ref, pool);
|
|
271
284
|
for (let i = 1; i < refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
272
285
|
}
|
|
@@ -306,12 +319,23 @@ export function apply(ctx, config = {}) {
|
|
|
306
319
|
const candidate = pool.refs[index];
|
|
307
320
|
const until = pool.state.failedUntil.get(candidate);
|
|
308
321
|
if (until !== undefined && until > now) continue;
|
|
309
|
-
|
|
322
|
+
let hit = await original(candidate);
|
|
310
323
|
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
311
324
|
pool.state.pointer = (index + 1) % pool.refs.length;
|
|
312
325
|
pool.state.lastUsed = candidate;
|
|
326
|
+
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
327
|
+
pool.state.failedUntil.delete(candidate);
|
|
313
328
|
return hit;
|
|
314
329
|
}
|
|
330
|
+
// fallback: env var (transient, not persisted)
|
|
331
|
+
const envVal = envValue(candidate);
|
|
332
|
+
if (envVal !== undefined) {
|
|
333
|
+
pool.state.pointer = (index + 1) % pool.refs.length;
|
|
334
|
+
pool.state.lastUsed = candidate;
|
|
335
|
+
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
336
|
+
pool.state.failedUntil.delete(candidate);
|
|
337
|
+
return { value: envVal, source: 'env' };
|
|
338
|
+
}
|
|
315
339
|
}
|
|
316
340
|
return original(ref); // everything cooled/missing — surface the base value
|
|
317
341
|
};
|
|
@@ -339,7 +363,7 @@ export function apply(ctx, config = {}) {
|
|
|
339
363
|
// mark the internal dispatch so the interceptor does not re-rotate
|
|
340
364
|
inner = ctx.llm.stream({ ...options, [MARKER]: true });
|
|
341
365
|
} catch (e) {
|
|
342
|
-
if (pool.state.lastUsed)
|
|
366
|
+
if (pool.state.lastUsed) { const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), pool.cooldownMs ?? cooldownMs); pushEvent(pool, pool.state.lastUsed, e?.code ?? 'TRANSPORT', _b); }
|
|
343
367
|
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
344
368
|
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
345
369
|
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
@@ -363,7 +387,7 @@ export function apply(ctx, config = {}) {
|
|
|
363
387
|
const switchable = !yielded && kind === 'error' &&
|
|
364
388
|
(switchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
|
|
365
389
|
if (switchable) {
|
|
366
|
-
if (pool.state.lastUsed)
|
|
390
|
+
if (pool.state.lastUsed) { const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), pool.cooldownMs ?? cooldownMs); pushEvent(pool, pool.state.lastUsed, code ?? 'UNKNOWN', _b); }
|
|
367
391
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
368
392
|
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
369
393
|
pool.state.lastSwitchAt = Date.now();
|
|
@@ -386,6 +410,10 @@ export function apply(ctx, config = {}) {
|
|
|
386
410
|
return; // clean end — served
|
|
387
411
|
}
|
|
388
412
|
|
|
413
|
+
// pool exhausted — all keys cooling or missing
|
|
414
|
+
pool.state.lastExhaustionAt = Date.now();
|
|
415
|
+
pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
|
|
416
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
|
|
389
417
|
yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
|
|
390
418
|
})();
|
|
391
419
|
}
|
|
@@ -427,11 +455,14 @@ export function apply(ctx, config = {}) {
|
|
|
427
455
|
// The resolve patch is installed on this same service, so ask for
|
|
428
456
|
// the exact ref: a pool ref would otherwise round-robin to another
|
|
429
457
|
// key and report a missing name as present.
|
|
430
|
-
|
|
458
|
+
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
431
459
|
present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
432
|
-
// Only the last few characters travel to the browser: enough to
|
|
433
|
-
// tell two keys apart in the card, useless for authenticating.
|
|
434
460
|
if (present) tail = keyTail(hit.value);
|
|
461
|
+
// fallback: env var bootstrapping (issue #7)
|
|
462
|
+
if (!present) {
|
|
463
|
+
const ev = envValue(ref);
|
|
464
|
+
if (ev !== undefined) { present = true; tail = keyTail(ev); source = 'env'; writable = false; }
|
|
465
|
+
}
|
|
435
466
|
} catch {
|
|
436
467
|
present = false;
|
|
437
468
|
}
|
|
@@ -459,6 +490,9 @@ export function apply(ctx, config = {}) {
|
|
|
459
490
|
switches: pool.state.switches ?? 0,
|
|
460
491
|
lastReason: pool.state.lastReason ?? null,
|
|
461
492
|
lastSwitchAt: pool.state.lastSwitchAt ?? null,
|
|
493
|
+
lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
|
|
494
|
+
exhaustionCount: pool.state.exhaustionCount ?? 0,
|
|
495
|
+
events: (pool.state.events ?? []).slice(-20),
|
|
462
496
|
});
|
|
463
497
|
}
|
|
464
498
|
json(res, 200, { providers });
|
|
@@ -522,6 +556,55 @@ export function apply(ctx, config = {}) {
|
|
|
522
556
|
},
|
|
523
557
|
}), 'dsh-key-rotation: key route');
|
|
524
558
|
|
|
559
|
+
// ── reset route: clear cooldown for a provider (or a single ref) ──
|
|
560
|
+
ctx.effect(() => ctx.webServer.register({
|
|
561
|
+
kind: 'exact',
|
|
562
|
+
path: RESET_PATH,
|
|
563
|
+
handler: async (req, res) => {
|
|
564
|
+
if (req.method !== 'POST') {
|
|
565
|
+
json(res, 405, { error: { code: 'method', message: 'POST only' } });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
569
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: reset is local-only' } });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
let body;
|
|
573
|
+
try { body = await readJson(req); } catch (e) {
|
|
574
|
+
json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
|
|
578
|
+
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
579
|
+
if (provider) {
|
|
580
|
+
const st = poolState.get(provider);
|
|
581
|
+
if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
|
|
582
|
+
const cleared = st.failedUntil.size;
|
|
583
|
+
st.failedUntil.clear();
|
|
584
|
+
st.failCounts?.clear();
|
|
585
|
+
st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
|
|
586
|
+
json(res, 200, { ok: true, provider, cleared });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (ref) {
|
|
590
|
+
let found = false;
|
|
591
|
+
for (const st of poolState.values()) {
|
|
592
|
+
if (st.failedUntil.has(ref) || st.failCounts?.has(ref)) {
|
|
593
|
+
st.failedUntil.delete(ref);
|
|
594
|
+
st.failCounts?.delete(ref);
|
|
595
|
+
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
596
|
+
found = true; break;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// idempotent: even if ref was not cooling, report ok if it looks like a valid ref name
|
|
600
|
+
if (!found && !isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
601
|
+
json(res, 200, { ok: true, ref });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: POST requires {"provider": "..."} or {"ref": "..."}' } });
|
|
605
|
+
},
|
|
606
|
+
}), 'dsh-key-rotation: reset route');
|
|
607
|
+
|
|
525
608
|
// Intercept the llm/stream waterfall: rotate any request whose provider maps
|
|
526
609
|
// to a configured key pool; pass everything else (and internal dispatches)
|
|
527
610
|
// straight through.
|
package/lib/pool.js
CHANGED
|
@@ -105,3 +105,37 @@ export function applyCooldown(pool, ref, cooldownMs, now = Date.now()) {
|
|
|
105
105
|
},
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
/** Exponential backoff for a repeatedly failing key.
|
|
110
|
+
* failCount 1 => baseMs, 2 => baseMs*2, 3 => baseMs*4, capped at baseMs*8 (or maxMs).
|
|
111
|
+
* Pure and easily unit-tested. */
|
|
112
|
+
export function computeBackoff(baseMs, failCount, maxMs) {
|
|
113
|
+
const cap = maxMs ?? baseMs * 8;
|
|
114
|
+
if (failCount <= 1) return Math.min(baseMs, cap);
|
|
115
|
+
const backoff = baseMs * (1 << (failCount - 1)); // 2^(n-1)
|
|
116
|
+
return Math.min(backoff, cap);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Record a failure for `ref` in `pool.state`, applying exponential backoff.
|
|
120
|
+
* Mutates pool.state.failedUntil and pool.state.failCounts. Returns the backoff used. */
|
|
121
|
+
export function recordFailure(pool, ref, now, baseMs, maxMs) {
|
|
122
|
+
if (!pool.state.failCounts) pool.state.failCounts = new Map();
|
|
123
|
+
const prev = pool.state.failCounts.get(ref) ?? 0;
|
|
124
|
+
const next = prev + 1;
|
|
125
|
+
pool.state.failCounts.set(ref, next);
|
|
126
|
+
const backoff = computeBackoff(baseMs, next, maxMs);
|
|
127
|
+
pool.state.failedUntil.set(ref, now + backoff);
|
|
128
|
+
return backoff;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Record a success for `ref` — clears its cooldown and resets its fail count. */
|
|
132
|
+
export function recordSuccess(pool, ref) {
|
|
133
|
+
if (pool.state.failCounts) pool.state.failCounts.delete(ref);
|
|
134
|
+
pool.state.failedUntil.delete(ref);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Return env value for ref if present in process.env, else undefined. */
|
|
138
|
+
export function envValue(ref) {
|
|
139
|
+
const v = typeof process !== 'undefined' ? process.env?.[ref] : undefined;
|
|
140
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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",
|