@goodandready/dsh-key-rotation 0.7.11 → 0.7.13
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 +68 -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(() => {
|
|
@@ -349,6 +374,10 @@ export function apply(ctx, config = {}) {
|
|
|
349
374
|
const maxCooldownMs = cfg.maxCooldownMs ?? undefined;
|
|
350
375
|
const notifyWebhook = cfg.notifyWebhook ?? '';
|
|
351
376
|
const notifyThreshold = cfg.notifyThreshold ?? 3;
|
|
377
|
+
const backupDir = cfg.backupDir ?? '';
|
|
378
|
+
const backupIntervalMs = cfg.backupIntervalMs ?? 86400000;
|
|
379
|
+
const backupKeep = cfg.backupKeep ?? 7;
|
|
380
|
+
const rotationScheduleDays = cfg.rotationScheduleDays ?? 0;
|
|
352
381
|
|
|
353
382
|
// ref -> pool (every key env of every configured provider)
|
|
354
383
|
const poolByRef = new Map();
|
|
@@ -371,7 +400,12 @@ export function apply(ctx, config = {}) {
|
|
|
371
400
|
}
|
|
372
401
|
return st;
|
|
373
402
|
};
|
|
374
|
-
const
|
|
403
|
+
const parseExpiry = (v) => {
|
|
404
|
+
if (typeof v === 'number' && v > 0) return v;
|
|
405
|
+
if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
|
|
406
|
+
return undefined;
|
|
407
|
+
};
|
|
408
|
+
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt) => {
|
|
375
409
|
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
|
|
376
410
|
if (refs.length === 0) return null;
|
|
377
411
|
const w = Array.isArray(weights) ? weights : [];
|
|
@@ -380,8 +414,15 @@ export function apply(ctx, config = {}) {
|
|
|
380
414
|
const ww = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
381
415
|
for (let k = 0; k < ww; k++) weightedRefs.push(refs[i]);
|
|
382
416
|
}
|
|
417
|
+
const parsedExpiry = {};
|
|
418
|
+
if (Array.isArray(expiresAt)) {
|
|
419
|
+
for (let i = 0; i < refs.length; i++) {
|
|
420
|
+
const exp = parseExpiry(expiresAt[i]);
|
|
421
|
+
if (exp !== undefined) parsedExpiry[refs[i]] = exp;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
383
424
|
return { base, refs, weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
384
|
-
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax };
|
|
425
|
+
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry };
|
|
385
426
|
};
|
|
386
427
|
for (const p of cfg.providers ?? []) {
|
|
387
428
|
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
@@ -421,7 +462,7 @@ export function apply(ctx, config = {}) {
|
|
|
421
462
|
for (const key of [...poolState.keys()]) {
|
|
422
463
|
if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
|
|
423
464
|
}
|
|
424
|
-
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, backupDir, backupIntervalMs, backupKeep, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
465
|
+
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
425
466
|
}
|
|
426
467
|
|
|
427
468
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
@@ -445,6 +486,7 @@ export function apply(ctx, config = {}) {
|
|
|
445
486
|
const candidate = list[index];
|
|
446
487
|
const until = pool.state.failedUntil.get(candidate);
|
|
447
488
|
if (until !== undefined && until > now) continue;
|
|
489
|
+
if (pool.expiresAt?.[candidate] !== undefined && now >= pool.expiresAt[candidate]) continue;
|
|
448
490
|
// perHour quota check
|
|
449
491
|
if (pool.perHour) {
|
|
450
492
|
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
@@ -659,6 +701,8 @@ export function apply(ctx, config = {}) {
|
|
|
659
701
|
usage: pool.state.usageCounts?.get(ref) ?? 0,
|
|
660
702
|
cost: pool.state.costPerKey?.get(ref) ?? 0,
|
|
661
703
|
lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
|
|
704
|
+
expiresAt: pool.expiresAt?.[ref] ?? null,
|
|
705
|
+
expired: pool.expiresAt?.[ref] !== undefined && now >= pool.expiresAt[ref],
|
|
662
706
|
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
663
707
|
});
|
|
664
708
|
}
|
|
@@ -672,6 +716,7 @@ export function apply(ctx, config = {}) {
|
|
|
672
716
|
exhaustionCount: pool.state.exhaustionCount ?? 0,
|
|
673
717
|
totalUsage: [...(pool.state.usageCounts?.values() ?? [])].reduce((a, b) => a + b, 0),
|
|
674
718
|
events: (pool.state.events ?? []).slice(-50),
|
|
719
|
+
healthScore: computeHealthScore(pool.state),
|
|
675
720
|
});
|
|
676
721
|
}
|
|
677
722
|
json(res, 200, { providers });
|
|
@@ -839,12 +884,15 @@ export function apply(ctx, config = {}) {
|
|
|
839
884
|
let healthy = 0;
|
|
840
885
|
for (const ref of pool.refs) {
|
|
841
886
|
const until = pool.state.failedUntil.get(ref);
|
|
842
|
-
if (
|
|
887
|
+
if (until !== undefined && until > now) continue;
|
|
888
|
+
const exp = pool.expiresAt?.[ref];
|
|
889
|
+
if (exp !== undefined && now >= exp) continue;
|
|
890
|
+
healthy++;
|
|
843
891
|
}
|
|
844
892
|
const total = pool.refs.length;
|
|
845
893
|
const exhausted = healthy === 0 && total > 0;
|
|
846
894
|
if (exhausted) exhaustedAny = true;
|
|
847
|
-
pools[pool.base] = { healthy, total, exhausted };
|
|
895
|
+
pools[pool.base] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
|
|
848
896
|
}
|
|
849
897
|
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny });
|
|
850
898
|
},
|
|
@@ -860,17 +908,23 @@ export function apply(ctx, config = {}) {
|
|
|
860
908
|
let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
861
909
|
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
862
910
|
if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
911
|
+
// Optional value for pre-save validation (issue #118)
|
|
912
|
+
const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
|
|
863
913
|
const base = ctx.get('credentials');
|
|
864
914
|
try {
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
const
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
915
|
+
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
916
|
+
let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
917
|
+
// Pre-save validation: check the provided value directly (issue #118)
|
|
918
|
+
const effectiveValue = testValue || hit?.value;
|
|
919
|
+
const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
|
|
920
|
+
const tail = valid ? keyTail(effectiveValue) : '';
|
|
921
|
+
let source = null;
|
|
922
|
+
try { const d = await base?.describe?.(ref); source = d?.source ?? null; } catch {}
|
|
923
|
+
if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
|
|
924
|
+
if (!present && testValue) { source = 'pre-save'; }
|
|
925
|
+
else if (!present) {
|
|
872
926
|
const ev = envValue(ref);
|
|
873
|
-
if (ev !== undefined) json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' });
|
|
927
|
+
if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
|
|
874
928
|
}
|
|
875
929
|
json(res, 200, { ok: true, ref, tail, source });
|
|
876
930
|
} 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.13",
|
|
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",
|