@goodandready/dsh-key-rotation 0.7.0 → 0.7.2
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 +46 -1
- package/lib/index.js +118 -15
- package/lib/pool.js +22 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -60,7 +60,13 @@ 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
|
+
brokenKey: 'broken (3× AUTH)',
|
|
64
|
+
exportPools: 'Export',
|
|
65
|
+
importPools: 'Import',
|
|
63
66
|
resetCooldown: 'Reset cooldown',
|
|
67
|
+
testKey: 'Test',
|
|
68
|
+
testOk: 'OK',
|
|
69
|
+
testFail: 'FAIL',
|
|
64
70
|
poolExhausted: 'pool exhausted — all keys cooling',
|
|
65
71
|
resetting: 'Resetting…',
|
|
66
72
|
keyLabel: 'Key {n}',
|
|
@@ -100,7 +106,13 @@ window.__ModuleLoader__.load({
|
|
|
100
106
|
keyFromEnv: 'задан в окружении, отсюда не меняется',
|
|
101
107
|
keyWriteFailed: 'не удалось сохранить ключ: {msg}',
|
|
102
108
|
keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
|
|
109
|
+
brokenKey: 'сломан (3× AUTH)',
|
|
110
|
+
exportPools: 'Экспорт',
|
|
111
|
+
importPools: 'Импорт',
|
|
103
112
|
resetCooldown: 'Сбросить кулдаун',
|
|
113
|
+
testKey: 'Тест',
|
|
114
|
+
testOk: 'OK',
|
|
115
|
+
testFail: 'FAIL',
|
|
104
116
|
poolExhausted: 'пул исчерпан — все ключи остывают',
|
|
105
117
|
resetting: 'Сброс…',
|
|
106
118
|
keyLabel: 'Ключ {n}',
|
|
@@ -266,6 +278,16 @@ window.__ModuleLoader__.load({
|
|
|
266
278
|
.catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))))
|
|
267
279
|
.finally(() => setResetting(''));
|
|
268
280
|
};
|
|
281
|
+
const [testing, setTesting] = React.useState('');
|
|
282
|
+
const [testResult, setTestResult] = React.useState({});
|
|
283
|
+
const doTest = (ref) => {
|
|
284
|
+
setTesting(ref); setTestResult((m) => ({ ...m, [ref]: null }));
|
|
285
|
+
fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) })
|
|
286
|
+
.then((r) => r.json())
|
|
287
|
+
.then((data) => setTestResult((m) => ({ ...m, [ref]: data })))
|
|
288
|
+
.catch((e) => setTestResult((m) => ({ ...m, [ref]: { ok: false, message: String(e?.message ?? e) } })))
|
|
289
|
+
.finally(() => setTesting(''));
|
|
290
|
+
};
|
|
269
291
|
|
|
270
292
|
const keyInfo = (providerId, ref) => {
|
|
271
293
|
const entryStatus = status[providerId];
|
|
@@ -301,7 +323,7 @@ window.__ModuleLoader__.load({
|
|
|
301
323
|
const providerById = new Map(providers.map((p) => [p.id, p.name]));
|
|
302
324
|
|
|
303
325
|
const setField = (fn) => setDraft(fn(val));
|
|
304
|
-
const providerList = Array.isArray(val.providers) ? val.providers : [];
|
|
326
|
+
const providerList = Array.isArray(val.providers) ? val.providers.filter((p) => Array.isArray(p.keys) && p.keys.length > 0) : [];
|
|
305
327
|
|
|
306
328
|
const setProvider = (index, id) => setField((cur) => {
|
|
307
329
|
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
@@ -400,6 +422,7 @@ window.__ModuleLoader__.load({
|
|
|
400
422
|
const keyStatus = (providerId, ref) => {
|
|
401
423
|
const hit = keyInfo(providerId, ref);
|
|
402
424
|
if (!hit) return null;
|
|
425
|
+
if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
|
|
403
426
|
if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
|
|
404
427
|
if (hit.cooldownMsLeft > 0) {
|
|
405
428
|
return {
|
|
@@ -453,6 +476,11 @@ window.__ModuleLoader__.load({
|
|
|
453
476
|
}));
|
|
454
477
|
if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'w' }));
|
|
455
478
|
}
|
|
479
|
+
if (info && typeof info.usage === 'number' && info.usage > 0) meta.push(h('span', { key: 'u', className: 'krot-tail', title: 'requests through this key' }, String(info.usage)));
|
|
480
|
+
if (info && typeof info.cost === 'number' && info.cost > 0) meta.push(h('span', { key: 'c', className: 'krot-tail', title: 'cost' }, '$' + info.cost.toFixed(2)));
|
|
481
|
+
const tr = testResult[key];
|
|
482
|
+
if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail', title: tr.message || (tr.ok ? t('testOk') : t('testFail')) }, tr.ok ? '✓' : '✕'));
|
|
483
|
+
meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
|
|
456
484
|
meta.push(h('span', { key: 'a', className: 'krot-acts' },
|
|
457
485
|
btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
|
|
458
486
|
btn('↓', () => moveKey(pIndex, kIndex, 1), { disabled: kIndex === keys.length - 1, title: t('moveDown') }),
|
|
@@ -487,6 +515,7 @@ window.__ModuleLoader__.load({
|
|
|
487
515
|
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
|
|
488
516
|
switchesLine,
|
|
489
517
|
exhaustionWarning,
|
|
518
|
+
(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),
|
|
490
519
|
(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
520
|
btn(t('resetCooldown'), () => doReset(entry.provider), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
|
|
492
521
|
),
|
|
@@ -512,6 +541,22 @@ window.__ModuleLoader__.load({
|
|
|
512
541
|
providerRows,
|
|
513
542
|
h('div', { className: 'krot-foot' }, btn(t('addProvider'), addProvider, {}), noProviders),
|
|
514
543
|
)),
|
|
544
|
+
h('div', { className: 'krot-foot' }, btn(t('exportPools'), () => {
|
|
545
|
+
const data = JSON.stringify(val.providers ?? [], null, 2);
|
|
546
|
+
const blob = new Blob([data], { type: 'application/json' });
|
|
547
|
+
const url = URL.createObjectURL(blob);
|
|
548
|
+
const a = document.createElement('a'); a.href = url; a.download = 'pools.json'; a.click(); URL.revokeObjectURL(url);
|
|
549
|
+
}, {}), h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('importPools'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
|
|
550
|
+
const f = e.target.files[0]; if (!f) return;
|
|
551
|
+
const reader = new FileReader();
|
|
552
|
+
reader.onload = () => { try { const imp = JSON.parse(String(reader.result)); if (!Array.isArray(imp)) throw new Error('expected array'); setField((cur) => {
|
|
553
|
+
const curProviders = Array.isArray(cur.providers) ? [...cur.providers] : [];
|
|
554
|
+
const map = new Map(curProviders.map((p) => [p.provider, p]));
|
|
555
|
+
for (const p of imp) { if (p && typeof p.provider === 'string') map.set(p.provider, p); }
|
|
556
|
+
return { ...cur, providers: [...map.values()] };
|
|
557
|
+
}); } catch (err) { setSecretError(String(err.message || err)); } };
|
|
558
|
+
reader.readAsText(f);
|
|
559
|
+
} }))),
|
|
515
560
|
h('p', { className: 'krot-hint' }, t('keyHint')),
|
|
516
561
|
secretError ? h('p', { className: 'krot-err' }, secretError) : null,
|
|
517
562
|
state.error ? h('p', { className: 'krot-err' }, state.error) : null,
|
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, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired } from './pool.js';
|
|
36
|
+
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter } from './pool.js';
|
|
37
37
|
|
|
38
38
|
export const name = 'dsh-key-rotation';
|
|
39
39
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
@@ -46,6 +46,7 @@ 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
48
|
const RESET_PATH = '/dsh-key-rotation/reset';
|
|
49
|
+
const TEST_PATH = '/dsh-key-rotation/test';
|
|
49
50
|
|
|
50
51
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
51
52
|
const PIAI_NS = 'llm-pi-ai';
|
|
@@ -78,9 +79,12 @@ export const Config = Schema.object({
|
|
|
78
79
|
switchCodes: Schema.array(Schema.string()).default([...DEFAULT_SWITCH_CODES]),
|
|
79
80
|
cooldownMs: Schema.number().default(60000),
|
|
80
81
|
maxCooldownMs: Schema.number(),
|
|
82
|
+
notifyWebhook: Schema.string().default(''),
|
|
83
|
+
notifyThreshold: Schema.number().default(3),
|
|
81
84
|
providers: Schema.array(Schema.object({
|
|
82
85
|
provider: Schema.string().required(),
|
|
83
86
|
keys: Schema.array(Schema.string()).default([]),
|
|
87
|
+
weights: Schema.array(Schema.number()).default([]),
|
|
84
88
|
cooldownMs: Schema.number(),
|
|
85
89
|
maxCooldownMs: Schema.number(),
|
|
86
90
|
})).default([...DEFAULT_PROVIDERS]),
|
|
@@ -260,6 +264,8 @@ export function apply(ctx, config = {}) {
|
|
|
260
264
|
const switchCodes = new Set(cfg.switchCodes ?? DEFAULT_SWITCH_CODES);
|
|
261
265
|
const cooldownMs = cfg.cooldownMs ?? 60000;
|
|
262
266
|
const maxCooldownMs = cfg.maxCooldownMs ?? undefined;
|
|
267
|
+
const notifyWebhook = cfg.notifyWebhook ?? '';
|
|
268
|
+
const notifyThreshold = cfg.notifyThreshold ?? 3;
|
|
263
269
|
|
|
264
270
|
// ref -> pool (every key env of every configured provider)
|
|
265
271
|
const poolByRef = new Map();
|
|
@@ -286,12 +292,19 @@ export function apply(ctx, config = {}) {
|
|
|
286
292
|
lastExhaustionAt: undefined,
|
|
287
293
|
exhaustionCount: 0,
|
|
288
294
|
events: [],
|
|
295
|
+
usageCounts: new Map(),
|
|
289
296
|
};
|
|
290
297
|
poolState.set(p.provider, state);
|
|
291
298
|
}
|
|
292
299
|
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
293
300
|
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
294
|
-
const
|
|
301
|
+
const weights = Array.isArray(p.weights) ? p.weights : [];
|
|
302
|
+
const weightedRefs = [];
|
|
303
|
+
for (let i = 0; i < refs.length; i++) {
|
|
304
|
+
const w = typeof weights[i] === 'number' && weights[i] > 0 ? Math.floor(weights[i]) : 1;
|
|
305
|
+
for (let k = 0; k < w; k++) weightedRefs.push(refs[i]);
|
|
306
|
+
}
|
|
307
|
+
const pool = { base: p.provider, refs, weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs, state, cooldownMs: poolCooldown, maxCooldownMs: poolMax };
|
|
295
308
|
for (const ref of refs) poolByRef.set(ref, pool);
|
|
296
309
|
for (let i = 1; i < refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
297
310
|
}
|
|
@@ -308,7 +321,11 @@ export function apply(ctx, config = {}) {
|
|
|
308
321
|
}
|
|
309
322
|
}
|
|
310
323
|
|
|
311
|
-
|
|
324
|
+
// auto-cleanup: remove poolState for providers that are now empty or removed
|
|
325
|
+
for (const key of [...poolState.keys()]) {
|
|
326
|
+
if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
|
|
327
|
+
}
|
|
328
|
+
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, poolByRef, providerToPool, cloneIds };
|
|
312
329
|
}
|
|
313
330
|
|
|
314
331
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
@@ -325,27 +342,61 @@ export function apply(ctx, config = {}) {
|
|
|
325
342
|
const pool = poolByRef.get(ref);
|
|
326
343
|
if (!pool) return original(ref);
|
|
327
344
|
const now = Date.now();
|
|
345
|
+
const list = pool.weightedRefs ?? pool.refs;
|
|
328
346
|
const start = pool.state.pointer ?? 0;
|
|
329
|
-
for (let i = 0; i <
|
|
330
|
-
const index = (start + i) %
|
|
331
|
-
const candidate =
|
|
347
|
+
for (let i = 0; i < list.length; i++) {
|
|
348
|
+
const index = (start + i) % list.length;
|
|
349
|
+
const candidate = list[index];
|
|
332
350
|
const until = pool.state.failedUntil.get(candidate);
|
|
333
351
|
if (until !== undefined && until > now) continue;
|
|
352
|
+
// perHour quota check
|
|
353
|
+
if (pool.perHour) {
|
|
354
|
+
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
355
|
+
let win = pool.state.quotaWindows.get(candidate);
|
|
356
|
+
if (!win || now - win.start >= 3600000) win = { count: 0, start: now };
|
|
357
|
+
if (win.count >= pool.perHour) {
|
|
358
|
+
const until = win.start + 3600000;
|
|
359
|
+
if ((pool.state.failedUntil.get(candidate) ?? 0) < until) pool.state.failedUntil.set(candidate, until);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
334
363
|
let hit = await original(candidate);
|
|
335
364
|
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
336
|
-
pool.state.pointer = (index + 1) %
|
|
365
|
+
pool.state.pointer = (index + 1) % list.length;
|
|
337
366
|
pool.state.lastUsed = candidate;
|
|
338
367
|
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
339
368
|
pool.state.failedUntil.delete(candidate);
|
|
369
|
+
if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
|
|
370
|
+
if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
|
|
371
|
+
if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
|
|
372
|
+
pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
|
|
373
|
+
if (pool.perHour) {
|
|
374
|
+
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
375
|
+
let win2 = pool.state.quotaWindows.get(candidate);
|
|
376
|
+
if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
|
|
377
|
+
win2.count++;
|
|
378
|
+
pool.state.quotaWindows.set(candidate, win2);
|
|
379
|
+
}
|
|
340
380
|
return hit;
|
|
341
381
|
}
|
|
342
382
|
// fallback: env var (transient, not persisted)
|
|
343
383
|
const envVal = envValue(candidate);
|
|
344
384
|
if (envVal !== undefined) {
|
|
345
|
-
pool.state.pointer = (index + 1) %
|
|
385
|
+
pool.state.pointer = (index + 1) % list.length;
|
|
346
386
|
pool.state.lastUsed = candidate;
|
|
347
387
|
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
348
388
|
pool.state.failedUntil.delete(candidate);
|
|
389
|
+
if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
|
|
390
|
+
if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
|
|
391
|
+
if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
|
|
392
|
+
pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
|
|
393
|
+
if (pool.perHour) {
|
|
394
|
+
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
395
|
+
let win2 = pool.state.quotaWindows.get(candidate);
|
|
396
|
+
if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
|
|
397
|
+
win2.count++;
|
|
398
|
+
pool.state.quotaWindows.set(candidate, win2);
|
|
399
|
+
}
|
|
349
400
|
return { value: envVal, source: 'env' };
|
|
350
401
|
}
|
|
351
402
|
}
|
|
@@ -367,7 +418,7 @@ export function apply(ctx, config = {}) {
|
|
|
367
418
|
const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
|
|
368
419
|
let lastFailure = null;
|
|
369
420
|
|
|
370
|
-
for (let attempt = 0; attempt < pool.refs.length; attempt++) {
|
|
421
|
+
for (let attempt = 0; attempt < (pool.weightedRefs ?? pool.refs).length; attempt++) {
|
|
371
422
|
let yielded = false;
|
|
372
423
|
let switching = false;
|
|
373
424
|
let inner;
|
|
@@ -375,7 +426,7 @@ export function apply(ctx, config = {}) {
|
|
|
375
426
|
// mark the internal dispatch so the interceptor does not re-rotate
|
|
376
427
|
inner = ctx.llm.stream({ ...options, [MARKER]: true });
|
|
377
428
|
} catch (e) {
|
|
378
|
-
if (pool.state.lastUsed) { const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), pool.
|
|
429
|
+
if (pool.state.lastUsed) { const _retry = parseRetryAfter(String(e?.message ?? '')); const _base = pool.cooldownMs ?? cooldownMs; const _max = pool.maxCooldownMs ?? maxCooldownMs; const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base; const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max); pushEvent(pool, pool.state.lastUsed, e?.code ?? 'TRANSPORT', _b); const _code = String(e?.code ?? ''); if (_code === 'AUTH' || /auth/i.test(String(e?.message ?? ''))) { const _c = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1; pool.state.authFailCounts.set(pool.state.lastUsed, _c); if (_c >= 3) { pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); } } else { pool.state.authFailCounts.delete(pool.state.lastUsed); } }
|
|
379
430
|
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
380
431
|
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
381
432
|
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
@@ -396,10 +447,11 @@ export function apply(ctx, config = {}) {
|
|
|
396
447
|
const failure = chunk.reason?.failure;
|
|
397
448
|
const code = failure?.code;
|
|
398
449
|
const message = failure?.message ?? '';
|
|
450
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
399
451
|
const switchable = !yielded && kind === 'error' &&
|
|
400
|
-
(
|
|
452
|
+
(effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
|
|
401
453
|
if (switchable) {
|
|
402
|
-
if (pool.state.lastUsed) { const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), pool.
|
|
454
|
+
if (pool.state.lastUsed) { const _retry = parseRetryAfter(message); const _base = pool.cooldownMs ?? cooldownMs; const _max = pool.maxCooldownMs ?? maxCooldownMs; const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base; const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max); pushEvent(pool, pool.state.lastUsed, code ?? 'UNKNOWN', _b); const _code2 = String(code ?? ''); if (_code2 === 'AUTH' || /auth/i.test(message)) { const _c2 = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1; pool.state.authFailCounts.set(pool.state.lastUsed, _c2); if (_c2 >= 3) { pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); } } else { pool.state.authFailCounts.delete(pool.state.lastUsed); } }
|
|
403
455
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
404
456
|
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
405
457
|
pool.state.lastSwitchAt = Date.now();
|
|
@@ -408,6 +460,11 @@ export function apply(ctx, config = {}) {
|
|
|
408
460
|
switching = true;
|
|
409
461
|
break;
|
|
410
462
|
}
|
|
463
|
+
// cost tracking if provider returns usage.cost
|
|
464
|
+
if (chunk.usage?.cost != null && pool.state.lastUsed) {
|
|
465
|
+
const c = Number(chunk.usage.cost);
|
|
466
|
+
if (!isNaN(c)) pool.state.costPerKey.set(pool.state.lastUsed, (pool.state.costPerKey.get(pool.state.lastUsed) ?? 0) + c);
|
|
467
|
+
}
|
|
411
468
|
yield chunk;
|
|
412
469
|
return;
|
|
413
470
|
}
|
|
@@ -426,6 +483,15 @@ export function apply(ctx, config = {}) {
|
|
|
426
483
|
pool.state.lastExhaustionAt = Date.now();
|
|
427
484
|
pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
|
|
428
485
|
console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
|
|
486
|
+
// notify webhook if configured and threshold reached
|
|
487
|
+
try {
|
|
488
|
+
const { notifyWebhook, notifyThreshold } = buildRuntime();
|
|
489
|
+
if (notifyWebhook && pool.state.exhaustionCount >= notifyThreshold) {
|
|
490
|
+
const payload = JSON.stringify({ provider: options.provider, exhaustionCount: pool.state.exhaustionCount, at: pool.state.lastExhaustionAt, keys: pool.refs });
|
|
491
|
+
fetch(notifyWebhook, { method: 'POST', headers: { 'content-type': 'application/json' }, body: payload }).catch(()=>{});
|
|
492
|
+
}
|
|
493
|
+
} catch {}
|
|
494
|
+
|
|
429
495
|
yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
|
|
430
496
|
})();
|
|
431
497
|
}
|
|
@@ -494,6 +560,9 @@ export function apply(ctx, config = {}) {
|
|
|
494
560
|
writable,
|
|
495
561
|
active: pool.state.lastUsed === ref,
|
|
496
562
|
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
563
|
+
usage: pool.state.usageCounts?.get(ref) ?? 0,
|
|
564
|
+
cost: pool.state.costPerKey?.get(ref) ?? 0,
|
|
565
|
+
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
497
566
|
});
|
|
498
567
|
}
|
|
499
568
|
providers.push({
|
|
@@ -504,7 +573,7 @@ export function apply(ctx, config = {}) {
|
|
|
504
573
|
lastSwitchAt: pool.state.lastSwitchAt ?? null,
|
|
505
574
|
lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
|
|
506
575
|
exhaustionCount: pool.state.exhaustionCount ?? 0,
|
|
507
|
-
events: (pool.state.events ?? []).slice(-
|
|
576
|
+
events: (pool.state.events ?? []).slice(-50),
|
|
508
577
|
});
|
|
509
578
|
}
|
|
510
579
|
json(res, 200, { providers });
|
|
@@ -594,6 +663,8 @@ export function apply(ctx, config = {}) {
|
|
|
594
663
|
const cleared = st.failedUntil.size;
|
|
595
664
|
st.failedUntil.clear();
|
|
596
665
|
st.failCounts?.clear();
|
|
666
|
+
st.authFailCounts?.clear();
|
|
667
|
+
st.brokenUntil?.clear();
|
|
597
668
|
st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
|
|
598
669
|
json(res, 200, { ok: true, provider, cleared });
|
|
599
670
|
return;
|
|
@@ -604,6 +675,8 @@ export function apply(ctx, config = {}) {
|
|
|
604
675
|
if (st.failedUntil.has(ref) || st.failCounts?.has(ref)) {
|
|
605
676
|
st.failedUntil.delete(ref);
|
|
606
677
|
st.failCounts?.delete(ref);
|
|
678
|
+
st.authFailCounts?.delete(ref);
|
|
679
|
+
st.brokenUntil?.delete(ref);
|
|
607
680
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
608
681
|
found = true; break;
|
|
609
682
|
}
|
|
@@ -617,6 +690,35 @@ export function apply(ctx, config = {}) {
|
|
|
617
690
|
},
|
|
618
691
|
}), 'dsh-key-rotation: reset route');
|
|
619
692
|
|
|
693
|
+
// ── test route: dry-run a single key without rotation ──
|
|
694
|
+
ctx.effect(() => ctx.webServer.register({
|
|
695
|
+
kind: 'exact',
|
|
696
|
+
path: TEST_PATH,
|
|
697
|
+
handler: async (req, res) => {
|
|
698
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
699
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test is local-only' } }); return; }
|
|
700
|
+
let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
701
|
+
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
702
|
+
if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
703
|
+
const base = ctx.get('credentials');
|
|
704
|
+
try {
|
|
705
|
+
const hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
706
|
+
const present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
707
|
+
if (!present) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
|
|
708
|
+
const tail = keyTail(hit.value);
|
|
709
|
+
// Check env source
|
|
710
|
+
let source = null; try { const d = await base?.describe?.(ref); source = d?.source ?? null; } catch {}
|
|
711
|
+
if (!present) {
|
|
712
|
+
const ev = envValue(ref);
|
|
713
|
+
if (ev !== undefined) json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' });
|
|
714
|
+
}
|
|
715
|
+
json(res, 200, { ok: true, ref, tail, source });
|
|
716
|
+
} catch (e) {
|
|
717
|
+
json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
}), 'dsh-key-rotation: test route');
|
|
721
|
+
|
|
620
722
|
// Intercept the llm/stream waterfall: rotate any request whose provider maps
|
|
621
723
|
// to a configured key pool; pass everything else (and internal dispatches)
|
|
622
724
|
// straight through.
|
|
@@ -625,7 +727,7 @@ export function apply(ctx, config = {}) {
|
|
|
625
727
|
const { providerToPool } = buildRuntime();
|
|
626
728
|
const pool = providerToPool.get(options.provider);
|
|
627
729
|
if (!pool) return next();
|
|
628
|
-
console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${pool.refs.length} keys`);
|
|
730
|
+
console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${(pool.weightedRefs ?? pool.refs).length} slots (${pool.refs.length} keys)`);
|
|
629
731
|
return rotate(options, pool);
|
|
630
732
|
});
|
|
631
733
|
|
|
@@ -641,7 +743,8 @@ export function apply(ctx, config = {}) {
|
|
|
641
743
|
if (!pool) return next();
|
|
642
744
|
const code = String(payload?.failure?.code ?? payload?.code ?? '');
|
|
643
745
|
const message = String(payload?.failure?.message ?? payload?.message ?? '');
|
|
644
|
-
const
|
|
746
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
747
|
+
const switchable = effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message);
|
|
645
748
|
if (!switchable) return next();
|
|
646
749
|
const ref = pool.state.lastUsed;
|
|
647
750
|
if (ref) {
|
package/lib/pool.js
CHANGED
|
@@ -154,3 +154,25 @@ export function sweepExpired(poolState, now = Date.now()) {
|
|
|
154
154
|
}
|
|
155
155
|
return cleared;
|
|
156
156
|
}
|
|
157
|
+
|
|
158
|
+
/** Parse Retry-After value from a header string or message.
|
|
159
|
+
* Supports seconds ("60", "Retry-After: 60") and HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT").
|
|
160
|
+
* Returns milliseconds or undefined if not parseable. */
|
|
161
|
+
export function parseRetryAfter(value) {
|
|
162
|
+
if (typeof value !== 'string' || !value) return undefined;
|
|
163
|
+
// Try to extract "Retry-After: <val>" from a larger message
|
|
164
|
+
const m = value.match(/retry-after\s*[:=]\s*(.+)/i);
|
|
165
|
+
const raw = m ? m[1].trim().split(/[\n\r;]/)[0].trim() : value.trim();
|
|
166
|
+
// Seconds
|
|
167
|
+
if (/^\d+$/.test(raw)) {
|
|
168
|
+
const sec = Number(raw);
|
|
169
|
+
if (sec >= 0 && sec <= 86400 * 7) return sec * 1000;
|
|
170
|
+
}
|
|
171
|
+
// HTTP-date
|
|
172
|
+
const ts = Date.parse(raw);
|
|
173
|
+
if (!Number.isNaN(ts)) {
|
|
174
|
+
const diff = ts - Date.now();
|
|
175
|
+
if (diff > 0 && diff < 86400 * 7 * 1000) return diff;
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
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.2",
|
|
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",
|