@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 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(() => {
@@ -371,7 +396,12 @@ export function apply(ctx, config = {}) {
371
396
  }
372
397
  return st;
373
398
  };
374
- const buildPool = (base, keys, weights, poolCooldown, poolMax) => {
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 (!(until !== undefined && until > now)) healthy++;
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
- 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) {
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.11",
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",