@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 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 saveSecret = (ref, rowKey) => {
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 buildPool = (base, keys, weights, poolCooldown, poolMax) => {
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 (!(until !== undefined && until > now)) healthy++;
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
- const hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
866
- const present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
867
- if (!present) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
868
- const tail = keyTail(hit.value);
869
- // Check env source
870
- let source = null; try { const d = await base?.describe?.(ref); source = d?.source ?? null; } catch {}
871
- if (!present) {
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.11",
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",