@goodandready/dsh-key-rotation 0.7.20 → 0.7.21

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/heal.js ADDED
@@ -0,0 +1,35 @@
1
+ // heal.js — self-healing idle cooldowns.
2
+ // ponytail: pure function, easy to test, no side effects beyond mutation of passed-in state.
3
+
4
+ // Returns array of { ref, poolBase } entries that were healed in this tick.
5
+ // Mutates `pools` (removes from failedUntil, pushes heal event into events).
6
+ // `now` parameter is injectable for tests.
7
+ export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
8
+ if (!Array.isArray(pools) || pools.length === 0) return [];
9
+ if (!Number.isFinite(idleMs) || idleMs <= 0) return [];
10
+ const healed = [];
11
+ for (const pool of pools) {
12
+ if (!pool || !pool.state || !pool.base) continue;
13
+ const fu = pool.state.failedUntil;
14
+ const lu = pool.state.lastUsed;
15
+ if (!fu || fu.size === 0) continue;
16
+ const expiredRefs = [];
17
+ for (const [ref, until] of fu.entries()) {
18
+ if (!Number.isFinite(until)) continue;
19
+ if (until > now) continue; // cooldown still active
20
+ const last = lu ? lu.get(ref) : undefined;
21
+ if (!Number.isFinite(last)) continue; // never used → no signal, skip
22
+ if (now - last < idleMs) continue; // used recently → don't heal
23
+ expiredRefs.push(ref);
24
+ }
25
+ for (const ref of expiredRefs) {
26
+ fu.delete(ref);
27
+ if (Array.isArray(pool.state.events)) {
28
+ pool.state.events.push({ at: now, ref, reason: 'self-heal', cooldownMs: 0, type: 'heal' });
29
+ if (pool.state.events.length > 50) pool.state.events.shift();
30
+ }
31
+ healed.push({ ref, poolBase: pool.base });
32
+ }
33
+ }
34
+ return healed;
35
+ }
@@ -0,0 +1,66 @@
1
+ // histogram.js — per-ref latency ring buffer + percentile.
2
+ // ponytail: ring buffer of fixed size, sort-on-read for percentile, no libraries.
3
+
4
+ export const LATENCY_DEFAULT_WINDOW = 200;
5
+
6
+ export class LatencyHistogram {
7
+ constructor({ window = LATENCY_DEFAULT_WINDOW } = {}) {
8
+ const w = Number.isFinite(window) && window > 0 ? Math.floor(window) : LATENCY_DEFAULT_WINDOW;
9
+ this._window = w;
10
+ this._buffers = new Map(); // ref -> Float64Array of size w, plus index/count
11
+ this._lastAt = new Map(); // ref -> epochMs of last sample
12
+ }
13
+
14
+ record(ref, ms) {
15
+ if (!ref || !Number.isFinite(ms) || ms < 0) return;
16
+ let entry = this._buffers.get(ref);
17
+ if (!entry) {
18
+ entry = { buf: new Float64Array(this._window), head: 0, count: 0 };
19
+ this._buffers.set(ref, entry);
20
+ }
21
+ entry.buf[entry.head] = ms;
22
+ entry.head = (entry.head + 1) % this._window;
23
+ if (entry.count < this._window) entry.count += 1;
24
+ this._lastAt.set(ref, Date.now());
25
+ }
26
+
27
+ // Returns p50/p95/p99 in milliseconds, plus count and lastAt. Sorted copy.
28
+ snapshot(ref) {
29
+ const entry = this._buffers.get(ref);
30
+ const lastAt = this._lastAt.get(ref);
31
+ if (!entry || entry.count === 0) {
32
+ return { count: 0, lastAt: lastAt || null };
33
+ }
34
+ const arr = entry.buf.subarray(0, entry.count);
35
+ const sorted = Array.from(arr).sort((a, b) => a - b);
36
+ const n = sorted.length;
37
+ return {
38
+ count: n,
39
+ lastAt: lastAt || null,
40
+ p50: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.5))],
41
+ p95: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.95))],
42
+ p99: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.99))],
43
+ };
44
+ }
45
+
46
+ // Returns { [ref]: snapshot }
47
+ snapshotAll() {
48
+ const out = {};
49
+ for (const ref of this._buffers.keys()) out[ref] = this.snapshot(ref);
50
+ return out;
51
+ }
52
+
53
+ clear(ref) {
54
+ if (ref) {
55
+ this._buffers.delete(ref);
56
+ this._lastAt.delete(ref);
57
+ } else {
58
+ this._buffers.clear();
59
+ this._lastAt.clear();
60
+ }
61
+ }
62
+
63
+ get size() {
64
+ return this._buffers.size;
65
+ }
66
+ }
package/lib/index.js CHANGED
@@ -49,6 +49,10 @@ const RESET_PATH = '/dsh-key-rotation/reset';
49
49
  const IMPORT_PATH = '/dsh-key-rotation/import';
50
50
  const HEALTH_PATH = '/dsh-key-rotation/health';
51
51
  const TEST_PATH = '/dsh-key-rotation/test';
52
+ const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
53
+ import { LastTestCache, SandboxRunner } from './sandbox.js';
54
+ import { healIdleCooldowns } from './heal.js';
55
+ import { LatencyHistogram } from './histogram.js';
52
56
 
53
57
  /** The llm-pi-ai namespace whose provider profiles map providers to pools. */
54
58
  const PIAI_NS = 'llm-pi-ai';
@@ -70,7 +74,40 @@ function pushEvent(pool, ref, reason, cooldownMs, type) {
70
74
  // treat pre-content failures whose message matches these patterns as
71
75
  // switchable even when the code is not in `switchCodes`.
72
76
 
73
- // Bootstrap key pools. The user configures them in the Settings GUI or via
77
+ // Sandbox-test infrastructure (sandbox.js): in-memory cache + runner.
78
+ let lastTestCacheRunnerCtx = null;
79
+ const lastTestCache = new LastTestCache();
80
+ const latencyHistogram = new LatencyHistogram();
81
+ let sandboxRunner = null;
82
+ function ensureSandboxRunner(ctx) {
83
+ if (sandboxRunner) return sandboxRunner;
84
+ // provider id -> baseUrl (stripped of trailing /) for fetch /models probe
85
+ function resolveBaseUrl(provider) {
86
+ try {
87
+ const ns = ctx.get(PIAI_NS);
88
+ const list = ns && (ns.providers || (ns.config && ns.config.providers) || []);
89
+ if (!Array.isArray(list)) return null;
90
+ // ponytail: match by id OR name OR alias; pick first hit
91
+ const hit = list.find((p) => p && (p.id === provider || p.name === provider || (Array.isArray(p.aliases) && p.aliases.includes(provider))));
92
+ const base = hit && (hit.baseUrl || hit.endpoint || hit.url);
93
+ return base ? String(base) : null;
94
+ } catch (e) {
95
+ return null;
96
+ }
97
+ }
98
+ sandboxRunner = new SandboxRunner({ fetchImpl: globalThis.fetch, resolveBaseUrl });
99
+ return sandboxRunner;
100
+ }
101
+ async function probeRef(ref, key) {
102
+ // ref may be like "PROVIDER/KEY_NAME" — for sandbox we only care about the credential ref
103
+ // (the resolveBaseUrl uses the full provider id; ref can carry any string)
104
+ const runner = ensureSandboxRunner(lastTestCacheRunnerCtx);
105
+ const result = await runner.probeModels(ref, key);
106
+ lastTestCache.set(ref, { ...result, at: Date.now() });
107
+ return result;
108
+ }
109
+
110
+ // // Bootstrap key pools. The user configures them in the Settings GUI or via
74
111
  // the dsh profile bundle config; the plugin itself ships no provider defaults
75
112
  // so it does not bind to any specific installation. Empty array means: until
76
113
  // the user adds a pool, no rotation happens, and every provider falls back to
@@ -87,6 +124,10 @@ export const Config = Schema.object({
87
124
  backupIntervalMs: Schema.number().default(86400000),
88
125
  backupKeep: Schema.number().default(7),
89
126
  rotationScheduleDays: Schema.number().default(0),
127
+ selfHealCooldown: Schema.boolean().default(true),
128
+ selfHealIdleMs: Schema.number().default(3600000),
129
+ latencyEnabled: Schema.boolean().default(true),
130
+ latencyWindow: Schema.number().default(200),
90
131
  rateLimitThreshold: Schema.number().default(0.1),
91
132
  providers: Schema.array(Schema.object({
92
133
  provider: Schema.string().required(),
@@ -255,6 +296,30 @@ export function apply(ctx, config = {}) {
255
296
  // profile does not need a second copy of that package.)
256
297
  let getConfig = () => config;
257
298
  registerConfigBridge(ctx, () => buildRuntime().cloneIds);
299
+ lastTestCacheRunnerCtx = ctx;
300
+ // Cache should not survive profile restarts (apply is called per reload).
301
+ // We deliberately do NOT clear on every apply — that would wipe badges when
302
+ // the user is just typing in the settings card. Re-init only on true reload.
303
+ ensureSandboxRunner(ctx);
304
+
305
+ // Self-healing idle cooldowns: every 60s, lift expired cooldowns for keys
306
+ // that have been idle for selfHealIdleMs (default 1h). ponytail: small
307
+ // interval, low cost; skipped when selfHealCooldown is disabled in config.
308
+ // ponytail: keep handle on the same ctx via closure so buildRuntime() reads
309
+ // fresh config on every tick. Naive but correct: 60s cadence is cheap.
310
+ const selfHealTimer = setInterval(() => {
311
+ const cfg = getConfig();
312
+ if (!cfg || cfg.selfHealCooldown === false) return;
313
+ try {
314
+ const idle = Number.isFinite(cfg.selfHealIdleMs) && cfg.selfHealIdleMs > 0 ? cfg.selfHealIdleMs : 3600000;
315
+ const providers = Array.isArray(cfg.providers) ? cfg.providers : [];
316
+ const pools = providers
317
+ .map((p) => buildRuntime().providerToPool.get(p.provider))
318
+ .filter(Boolean);
319
+ healIdleCooldowns(pools, idle);
320
+ } catch (_) { /* ponytail: never crash the timer */ }
321
+ }, 60000);
322
+ if (typeof selfHealTimer.unref === 'function') selfHealTimer.unref();
258
323
 
259
324
  // Dashboard widget now lives in client.js (mountDashboard, see issue #152).
260
325
  const DASH_HTML = '';
@@ -928,7 +993,7 @@ export function apply(ctx, config = {}) {
928
993
  if (exhausted) exhaustedAny = true;
929
994
  pools[pool.base] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
930
995
  }
931
- json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny });
996
+ json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll() });
932
997
  },
933
998
  }), 'dsh-key-rotation: health');
934
999
 
@@ -944,11 +1009,11 @@ export function apply(ctx, config = {}) {
944
1009
  if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
945
1010
  // Optional value for pre-save validation (issue #118)
946
1011
  const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
1012
+ const probe = body?.probe === 'models' || body?.probe === 'chat' ? body.probe : undefined;
947
1013
  const base = ctx.get('credentials');
948
1014
  try {
949
1015
  let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
950
1016
  let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
951
- // Pre-save validation: check the provided value directly (issue #118)
952
1017
  const effectiveValue = testValue || hit?.value;
953
1018
  const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
954
1019
  const tail = valid ? keyTail(effectiveValue) : '';
@@ -960,6 +1025,16 @@ export function apply(ctx, config = {}) {
960
1025
  const ev = envValue(ref);
961
1026
  if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
962
1027
  }
1028
+ // sandbox probe (models is free; chat is hook-only, see sandbox.js)
1029
+ if (probe) {
1030
+ const keyForProbe = effectiveValue;
1031
+ const runner = ensureSandboxRunner(ctx);
1032
+ const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
1033
+ const cached = { ...result, at: Date.now() };
1034
+ lastTestCache.set(ref, cached);
1035
+ json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount });
1036
+ return;
1037
+ }
963
1038
  json(res, 200, { ok: true, ref, tail, source });
964
1039
  } catch (e) {
965
1040
  json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
@@ -970,6 +1045,16 @@ export function apply(ctx, config = {}) {
970
1045
  // Intercept the llm/stream waterfall: rotate any request whose provider maps
971
1046
  // to a configured key pool; pass everything else (and internal dispatches)
972
1047
  // straight through.
1048
+ // Read-only cache snapshot for clients (badge polling).
1049
+ ctx.effect(() => ctx.webServer.register({
1050
+ kind: 'exact',
1051
+ path: SANDBOX_CACHE_PATH,
1052
+ handler: (req, res) => {
1053
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
1054
+ json(res, 200, lastTestCache.snapshot());
1055
+ },
1056
+ }), 'dsh-key-rotation: sandbox cache');
1057
+
973
1058
  ctx.on('llm/stream', (options, next) => {
974
1059
  if (options[MARKER]) return next();
975
1060
  const { providerToPool, modelPoolByProvider } = buildRuntime();
package/lib/sandbox.js ADDED
@@ -0,0 +1,117 @@
1
+ // sandbox.js — probe sandbox-test runner + last-test cache.
2
+ // Ponytail-mode (full): simplest correct path.
3
+ // YAGNI: chat completions is a hook (not-implemented).
4
+ // In-memory only; restart dsh-web = clear cache.
5
+
6
+ export const PROBE_MODELS_TIMEOUT_MS = 5000;
7
+ export const PROBE_RETRY_DELAY_MS = 1000;
8
+ export const LAST_TEST_MAX = 200;
9
+
10
+ export class LastTestCache {
11
+ constructor(max = LAST_TEST_MAX) {
12
+ this._max = max;
13
+ this._data = new Map();
14
+ }
15
+
16
+ set(ref, result) {
17
+ if (!ref || !result) return;
18
+ if (this._data.has(ref)) this._data.delete(ref);
19
+ this._data.set(ref, result);
20
+ while (this._data.size > this._max) {
21
+ const first = this._data.keys().next().value;
22
+ if (first === undefined) break;
23
+ this._data.delete(first);
24
+ }
25
+ }
26
+
27
+ get(ref) {
28
+ return this._data.get(ref);
29
+ }
30
+
31
+ snapshot() {
32
+ const out = {};
33
+ for (const [k, v] of this._data) out[k] = v;
34
+ return out;
35
+ }
36
+
37
+ clear() {
38
+ this._data.clear();
39
+ }
40
+
41
+ get size() {
42
+ return this._data.size;
43
+ }
44
+ }
45
+
46
+ function classifyStatus(status) {
47
+ if (status === 401 || status === 403) return 'auth';
48
+ if (status === 404) return 'not-found';
49
+ if (status === 429) return 'rate-limit';
50
+ if (status >= 500 && status < 600) return 'server';
51
+ return `http-${status}`;
52
+ }
53
+
54
+ export class SandboxRunner {
55
+ constructor({ fetchImpl, resolveBaseUrl, log = () => {} } = {}) {
56
+ if (typeof fetchImpl !== 'function') throw new Error('sandbox: fetchImpl required');
57
+ if (typeof resolveBaseUrl !== 'function') throw new Error('sandbox: resolveBaseUrl required');
58
+ this._fetch = fetchImpl;
59
+ this._resolveBaseUrl = resolveBaseUrl;
60
+ this._log = log;
61
+ }
62
+
63
+ async probeModels(ref, key) {
64
+ if (!ref || typeof key !== 'string' || key.length === 0) {
65
+ return { ok: false, code: 'no-credential', latencyMs: 0 };
66
+ }
67
+ const baseUrl = await this._resolveBaseUrl(ref);
68
+ if (!baseUrl) {
69
+ return { ok: false, code: 'no-baseurl', latencyMs: 0 };
70
+ }
71
+ const url = `${baseUrl.replace(/\/+$/, '')}/models`;
72
+ const started = Date.now();
73
+ const ctrl = new AbortController();
74
+ const timer = setTimeout(() => ctrl.abort(), PROBE_MODELS_TIMEOUT_MS);
75
+ const doFetch = () => this._fetch(url, {
76
+ method: 'GET',
77
+ headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
78
+ signal: ctrl.signal,
79
+ });
80
+ try {
81
+ let res;
82
+ try {
83
+ res = await doFetch();
84
+ } catch (e) {
85
+ if (e && e.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
86
+ return { ok: false, code: 'network', latencyMs: Date.now() - started };
87
+ }
88
+ // ponytail: 1 retry on 5xx — naive; classifier refines if needed
89
+ if (res.status >= 500 && res.status < 600) {
90
+ await new Promise((r) => setTimeout(r, PROBE_RETRY_DELAY_MS));
91
+ if (ctrl.signal.aborted) return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
92
+ try {
93
+ res = await doFetch();
94
+ } catch (e) {
95
+ if (e && e.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
96
+ return { ok: false, code: 'network', latencyMs: Date.now() - started };
97
+ }
98
+ }
99
+ const status = res.status;
100
+ if (status >= 200 && status < 300) {
101
+ let modelsCount = 0;
102
+ try {
103
+ const body = await res.json();
104
+ modelsCount = Array.isArray(body && body.data) ? body.data.length : 0;
105
+ } catch (_) { /* not json */ }
106
+ return { ok: true, code: 'ok', latencyMs: Date.now() - started, modelsCount };
107
+ }
108
+ return { ok: false, code: classifyStatus(status), latencyMs: Date.now() - started };
109
+ } finally {
110
+ clearTimeout(timer);
111
+ }
112
+ }
113
+
114
+ async probeChat(_ref, _key) {
115
+ return { ok: false, code: 'not-implemented', latencyMs: 0 };
116
+ }
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.20",
3
+ "version": "0.7.21",
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",
@@ -53,6 +53,6 @@
53
53
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
54
54
  },
55
55
  "scripts": {
56
- "test": "node --test test/*.test.js"
56
+ "test": "node --test test/*.test.js test/*.test.mjs"
57
57
  }
58
58
  }