@goodandready/dsh-key-rotation 0.7.11 → 0.7.12
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 +44 -1
- package/lib/index.js +64 -14
- package/lib/pool.js +17 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -39,6 +39,7 @@ window.__ModuleLoader__.load({
|
|
|
39
39
|
addProvider: '+ Add provider',
|
|
40
40
|
desc: 'Per-provider API key rotation. For each provider, list its API keys (env names, stored in DSH credentials). The plugin routes a model through that provider\u2019s keys in order and switches to the next on a quota/rate-limit failure.',
|
|
41
41
|
cooldown: 'Cooldown after failure (ms)',
|
|
42
|
+
scheduleDays: 'Rotation schedule (days, 0=off)',
|
|
42
43
|
switchCodes: 'Switch codes (comma-separated)',
|
|
43
44
|
providersTitle: 'Providers and their keys',
|
|
44
45
|
save: 'Save',
|
|
@@ -63,6 +64,8 @@ window.__ModuleLoader__.load({
|
|
|
63
64
|
keyWriteFailed: 'could not store the key: {msg}',
|
|
64
65
|
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.',
|
|
65
66
|
brokenKey: 'broken (3× AUTH)',
|
|
67
|
+
keyExpired: 'expired',
|
|
68
|
+
keyExpiringSoon: 'expires in {n} d',
|
|
66
69
|
exportPools: 'Export',
|
|
67
70
|
exportOne: '⬇',
|
|
68
71
|
usedAgo: '{ago} ago',
|
|
@@ -92,6 +95,7 @@ window.__ModuleLoader__.load({
|
|
|
92
95
|
addProvider: '+ Добавить провайдера',
|
|
93
96
|
desc: 'Ротация API-ключей по провайдерам. Для каждого провайдера укажите его API-ключи (имена env, хранятся в учётных данных DSH). Плагин ведёт модель по ключам провайдера по порядку и переключается на следующий при исчерпании квоты/превышении лимита.',
|
|
94
97
|
cooldown: 'Задержка после сбоя (мс)',
|
|
98
|
+
scheduleDays: 'Расписание ротации (дней, 0=выкл)',
|
|
95
99
|
switchCodes: 'Коды переключения (через запятую)',
|
|
96
100
|
providersTitle: 'Провайдеры и их ключи',
|
|
97
101
|
save: 'Сохранить',
|
|
@@ -116,6 +120,8 @@ window.__ModuleLoader__.load({
|
|
|
116
120
|
keyWriteFailed: 'не удалось сохранить ключ: {msg}',
|
|
117
121
|
keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
|
|
118
122
|
brokenKey: 'сломан (3× AUTH)',
|
|
123
|
+
keyExpired: 'истёк',
|
|
124
|
+
keyExpiringSoon: 'истекает через {n} д',
|
|
119
125
|
exportPools: 'Экспорт',
|
|
120
126
|
exportOne: '⬇',
|
|
121
127
|
usedAgo: '{ago} назад',
|
|
@@ -347,10 +353,34 @@ window.__ModuleLoader__.load({
|
|
|
347
353
|
return (entryStatus.keys ?? []).find((k) => k.ref === ref) ?? null;
|
|
348
354
|
};
|
|
349
355
|
|
|
350
|
-
const
|
|
356
|
+
const [validating, setValidating] = React.useState('');
|
|
357
|
+
const [validationResult, setValidationResult] = React.useState({});
|
|
358
|
+
const validateBeforeSave = (ref, value) => {
|
|
359
|
+
setValidating(ref);
|
|
360
|
+
return fetch('/dsh-key-rotation/test', {
|
|
361
|
+
method: 'POST',
|
|
362
|
+
headers: { 'content-type': 'application/json' },
|
|
363
|
+
body: JSON.stringify({ ref, value }),
|
|
364
|
+
})
|
|
365
|
+
.then((r) => r.json())
|
|
366
|
+
.then((data) => { setValidationResult((m) => ({ ...m, [ref]: data })); return data; })
|
|
367
|
+
.catch(() => null)
|
|
368
|
+
.finally(() => setValidating(''));
|
|
369
|
+
};
|
|
370
|
+
const saveSecret = async (ref, rowKey) => {
|
|
351
371
|
const value = secretDraft[rowKey];
|
|
352
372
|
if (!value) return;
|
|
353
373
|
setSecretError('');
|
|
374
|
+
// Pre-save validation (issue #118)
|
|
375
|
+
setValidating(ref);
|
|
376
|
+
const vres = await validateBeforeSave(ref, value);
|
|
377
|
+
setValidating('');
|
|
378
|
+
if (vres && vres.ok === false && vres.code === 'no-credential') {
|
|
379
|
+
// No credential yet is fine for a new key being saved
|
|
380
|
+
} else if (vres && !vres.ok) {
|
|
381
|
+
setSecretError(t('keyWriteFailed').replace('{msg}', vres.message || 'validation failed'));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
354
384
|
fetch('/dsh-key-rotation/key', {
|
|
355
385
|
method: 'PUT',
|
|
356
386
|
headers: { 'content-type': 'application/json' },
|
|
@@ -476,6 +506,11 @@ window.__ModuleLoader__.load({
|
|
|
476
506
|
const keyStatus = (providerId, ref) => {
|
|
477
507
|
const hit = keyInfo(providerId, ref);
|
|
478
508
|
if (!hit) return null;
|
|
509
|
+
if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired') };
|
|
510
|
+
if (hit.expiresAt && !hit.expired) {
|
|
511
|
+
const days = Math.ceil((hit.expiresAt - Date.now()) / 86400000);
|
|
512
|
+
if (days <= 7) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
|
|
513
|
+
}
|
|
479
514
|
if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
|
|
480
515
|
if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
|
|
481
516
|
if (hit.cooldownMsLeft > 0) {
|
|
@@ -590,6 +625,13 @@ window.__ModuleLoader__.load({
|
|
|
590
625
|
});
|
|
591
626
|
}, { title: 'Sort by usage' }),
|
|
592
627
|
h('select', { className: 'krot-in', value: entry.provider, onChange: (e) => setProvider(pIndex, e.target.value) }, options),
|
|
628
|
+
(() => {
|
|
629
|
+
const ps = status[entry.provider];
|
|
630
|
+
const score = ps && typeof ps.healthScore === 'number' ? ps.healthScore : null;
|
|
631
|
+
if (score === null) return null;
|
|
632
|
+
const color = score > 80 ? 'var(--dsw-alias-state-success-primary)' : score >= 50 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-error-primary)';
|
|
633
|
+
return h('span', { className: 'krot-tail', title: 'health score', style: { flex: 'none', color, fontWeight: 700 } }, String(score));
|
|
634
|
+
})(),
|
|
593
635
|
(() => { const ps = status[entry.provider]; const tot = ps && typeof ps.totalUsage === 'number' ? ps.totalUsage : null; return tot !== null ? h('span', { className: 'krot-tail', title: 'total requests', style: { flex: 'none' } }, String(tot)) : null; })(),
|
|
594
636
|
btn('✕', () => removeProvider(pIndex), { title: t('removeProvider') }),
|
|
595
637
|
),
|
|
@@ -613,6 +655,7 @@ window.__ModuleLoader__.load({
|
|
|
613
655
|
return h('div', { className: 'krot' },
|
|
614
656
|
h('p', { className: 'krot-hint' }, t('desc')),
|
|
615
657
|
field(t('cooldown'), textInput(String(val.cooldownMs ?? 60000), (v) => setField((cur) => ({ ...cur, cooldownMs: Number(v) || 0 })))),
|
|
658
|
+
field(t('scheduleDays'), textInput(String(val.rotationScheduleDays ?? 0), (v) => setField((cur) => ({ ...cur, rotationScheduleDays: Number(v) || 0 })))),
|
|
616
659
|
field(t('codesTitle'), h('div', { className: 'krot-codes' }, codeList.map((code) => h('label', { key: code, className: 'krot-code' },
|
|
617
660
|
h('input', {
|
|
618
661
|
type: 'checkbox',
|
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, parseRetryAfter } from './pool.js';
|
|
36
|
+
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter, computeHealthScore } from './pool.js';
|
|
37
37
|
|
|
38
38
|
export const name = 'dsh-key-rotation';
|
|
39
39
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
@@ -86,10 +86,12 @@ export const Config = Schema.object({
|
|
|
86
86
|
backupDir: Schema.string().default(''),
|
|
87
87
|
backupIntervalMs: Schema.number().default(86400000),
|
|
88
88
|
backupKeep: Schema.number().default(7),
|
|
89
|
+
rotationScheduleDays: Schema.number().default(0),
|
|
89
90
|
providers: Schema.array(Schema.object({
|
|
90
91
|
provider: Schema.string().required(),
|
|
91
92
|
keys: Schema.array(Schema.string()).default([]),
|
|
92
93
|
weights: Schema.array(Schema.number()).default([]),
|
|
94
|
+
expiresAt: Schema.array(Schema.union([Schema.number(), Schema.string()])).default([]),
|
|
93
95
|
models: Schema.dict(Schema.object({
|
|
94
96
|
keys: Schema.array(Schema.string()).default([]),
|
|
95
97
|
weights: Schema.array(Schema.number()).default([]),
|
|
@@ -318,6 +320,29 @@ export function apply(ctx, config = {}) {
|
|
|
318
320
|
return () => clearInterval(id);
|
|
319
321
|
} catch { return () => {}; }
|
|
320
322
|
}, 'dsh-key-rotation: persist stats');
|
|
323
|
+
// Rotation schedule: shift pointer every N days
|
|
324
|
+
ctx.effect(() => {
|
|
325
|
+
const { rotationScheduleDays } = buildRuntime();
|
|
326
|
+
if (!rotationScheduleDays || rotationScheduleDays <= 0) return;
|
|
327
|
+
const intervalMs = Math.min(rotationScheduleDays * 86400000, 2147483647);
|
|
328
|
+
const id = setInterval(() => {
|
|
329
|
+
try {
|
|
330
|
+
const rt = buildRuntime();
|
|
331
|
+
let shifted = 0;
|
|
332
|
+
for (const pool of rt.poolByRef.values()) {
|
|
333
|
+
if (pool.refs.length < 2) continue;
|
|
334
|
+
const oldPtr = pool.state.pointer ?? 0;
|
|
335
|
+
pool.state.pointer = (oldPtr + 1) % pool.refs.length;
|
|
336
|
+
shifted++;
|
|
337
|
+
console.warn(`[dsh-key-rotation] ${pool.base}: scheduled rotation -> ${pool.refs[pool.state.pointer]} (day ${rotationScheduleDays})`);
|
|
338
|
+
}
|
|
339
|
+
if (shifted) console.warn(`[dsh-key-rotation] schedule: rotated ${shifted} pools`);
|
|
340
|
+
} catch (e) {
|
|
341
|
+
console.warn('[dsh-key-rotation] schedule error:', String(e?.message ?? e));
|
|
342
|
+
}
|
|
343
|
+
}, intervalMs);
|
|
344
|
+
return () => clearInterval(id);
|
|
345
|
+
}, 'dsh-key-rotation: rotation schedule');
|
|
321
346
|
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
322
347
|
ctx.effect(() => {
|
|
323
348
|
const id = setInterval(() => {
|
|
@@ -371,7 +396,12 @@ export function apply(ctx, config = {}) {
|
|
|
371
396
|
}
|
|
372
397
|
return st;
|
|
373
398
|
};
|
|
374
|
-
const
|
|
399
|
+
const parseExpiry = (v) => {
|
|
400
|
+
if (typeof v === 'number' && v > 0) return v;
|
|
401
|
+
if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
|
|
402
|
+
return undefined;
|
|
403
|
+
};
|
|
404
|
+
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt) => {
|
|
375
405
|
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
|
|
376
406
|
if (refs.length === 0) return null;
|
|
377
407
|
const w = Array.isArray(weights) ? weights : [];
|
|
@@ -380,8 +410,15 @@ export function apply(ctx, config = {}) {
|
|
|
380
410
|
const ww = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
381
411
|
for (let k = 0; k < ww; k++) weightedRefs.push(refs[i]);
|
|
382
412
|
}
|
|
413
|
+
const parsedExpiry = {};
|
|
414
|
+
if (Array.isArray(expiresAt)) {
|
|
415
|
+
for (let i = 0; i < refs.length; i++) {
|
|
416
|
+
const exp = parseExpiry(expiresAt[i]);
|
|
417
|
+
if (exp !== undefined) parsedExpiry[refs[i]] = exp;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
383
420
|
return { base, refs, weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
384
|
-
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax };
|
|
421
|
+
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry };
|
|
385
422
|
};
|
|
386
423
|
for (const p of cfg.providers ?? []) {
|
|
387
424
|
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
@@ -421,7 +458,7 @@ export function apply(ctx, config = {}) {
|
|
|
421
458
|
for (const key of [...poolState.keys()]) {
|
|
422
459
|
if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
|
|
423
460
|
}
|
|
424
|
-
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, backupDir, backupIntervalMs, backupKeep, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
461
|
+
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
425
462
|
}
|
|
426
463
|
|
|
427
464
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
@@ -445,6 +482,7 @@ export function apply(ctx, config = {}) {
|
|
|
445
482
|
const candidate = list[index];
|
|
446
483
|
const until = pool.state.failedUntil.get(candidate);
|
|
447
484
|
if (until !== undefined && until > now) continue;
|
|
485
|
+
if (pool.expiresAt?.[candidate] !== undefined && now >= pool.expiresAt[candidate]) continue;
|
|
448
486
|
// perHour quota check
|
|
449
487
|
if (pool.perHour) {
|
|
450
488
|
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
@@ -659,6 +697,8 @@ export function apply(ctx, config = {}) {
|
|
|
659
697
|
usage: pool.state.usageCounts?.get(ref) ?? 0,
|
|
660
698
|
cost: pool.state.costPerKey?.get(ref) ?? 0,
|
|
661
699
|
lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
|
|
700
|
+
expiresAt: pool.expiresAt?.[ref] ?? null,
|
|
701
|
+
expired: pool.expiresAt?.[ref] !== undefined && now >= pool.expiresAt[ref],
|
|
662
702
|
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
663
703
|
});
|
|
664
704
|
}
|
|
@@ -672,6 +712,7 @@ export function apply(ctx, config = {}) {
|
|
|
672
712
|
exhaustionCount: pool.state.exhaustionCount ?? 0,
|
|
673
713
|
totalUsage: [...(pool.state.usageCounts?.values() ?? [])].reduce((a, b) => a + b, 0),
|
|
674
714
|
events: (pool.state.events ?? []).slice(-50),
|
|
715
|
+
healthScore: computeHealthScore(pool.state),
|
|
675
716
|
});
|
|
676
717
|
}
|
|
677
718
|
json(res, 200, { providers });
|
|
@@ -839,12 +880,15 @@ export function apply(ctx, config = {}) {
|
|
|
839
880
|
let healthy = 0;
|
|
840
881
|
for (const ref of pool.refs) {
|
|
841
882
|
const until = pool.state.failedUntil.get(ref);
|
|
842
|
-
if (
|
|
883
|
+
if (until !== undefined && until > now) continue;
|
|
884
|
+
const exp = pool.expiresAt?.[ref];
|
|
885
|
+
if (exp !== undefined && now >= exp) continue;
|
|
886
|
+
healthy++;
|
|
843
887
|
}
|
|
844
888
|
const total = pool.refs.length;
|
|
845
889
|
const exhausted = healthy === 0 && total > 0;
|
|
846
890
|
if (exhausted) exhaustedAny = true;
|
|
847
|
-
pools[pool.base] = { healthy, total, exhausted };
|
|
891
|
+
pools[pool.base] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
|
|
848
892
|
}
|
|
849
893
|
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny });
|
|
850
894
|
},
|
|
@@ -860,17 +904,23 @@ export function apply(ctx, config = {}) {
|
|
|
860
904
|
let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
861
905
|
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
862
906
|
if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
907
|
+
// Optional value for pre-save validation (issue #118)
|
|
908
|
+
const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
|
|
863
909
|
const base = ctx.get('credentials');
|
|
864
910
|
try {
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
const
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
911
|
+
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
912
|
+
let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
913
|
+
// Pre-save validation: check the provided value directly (issue #118)
|
|
914
|
+
const effectiveValue = testValue || hit?.value;
|
|
915
|
+
const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
|
|
916
|
+
const tail = valid ? keyTail(effectiveValue) : '';
|
|
917
|
+
let source = null;
|
|
918
|
+
try { const d = await base?.describe?.(ref); source = d?.source ?? null; } catch {}
|
|
919
|
+
if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
|
|
920
|
+
if (!present && testValue) { source = 'pre-save'; }
|
|
921
|
+
else if (!present) {
|
|
872
922
|
const ev = envValue(ref);
|
|
873
|
-
if (ev !== undefined) json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' });
|
|
923
|
+
if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
|
|
874
924
|
}
|
|
875
925
|
json(res, 200, { ok: true, ref, tail, source });
|
|
876
926
|
} catch (e) {
|
package/lib/pool.js
CHANGED
|
@@ -183,3 +183,20 @@ export function selectPool(modelPoolByProvider, providerToPool, provider, model)
|
|
|
183
183
|
const byModel = modelPoolByProvider && modelPoolByProvider.get(provider);
|
|
184
184
|
return (byModel && byModel.get(model)) || (providerToPool && providerToPool.get(provider)) || null;
|
|
185
185
|
}
|
|
186
|
+
|
|
187
|
+
/** Parse an expiry value (timestamp ms or ISO date string) to epoch ms. */
|
|
188
|
+
export function parseExpiry(v) {
|
|
189
|
+
if (typeof v === 'number' && v > 0) return v;
|
|
190
|
+
if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Compute a 0..100 health score for a pool based on its runtime state.
|
|
195
|
+
* Deductions: switches * 5, exhaustions * 10, broken keys * 15. */
|
|
196
|
+
export function computeHealthScore(state) {
|
|
197
|
+
if (!state || typeof state !== 'object') return 100;
|
|
198
|
+
const switches = state.switches ?? 0;
|
|
199
|
+
const exhaustions = state.exhaustionCount ?? 0;
|
|
200
|
+
const broken = state.brokenUntil ? state.brokenUntil.size : 0;
|
|
201
|
+
return Math.max(0, Math.min(100, 100 - (switches * 5) - (exhaustions * 10) - (broken * 15)));
|
|
202
|
+
}
|
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.12",
|
|
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",
|