@goodandready/dsh-key-rotation 0.8.12 → 0.8.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.
@@ -0,0 +1,122 @@
1
+ // lib/lifecycle.js — background timers and lifecycle effects
2
+ import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
3
+ import { StatePersistence, resolveStatePath } from './persistence.js';
4
+ import path from 'node:path';
5
+
6
+ export function setupIdleHealEffect(ctx, getConfig, buildRuntime) {
7
+ return ctx.effect(() => {
8
+ const cfg = getConfig();
9
+ if (!cfg || cfg.selfHealCooldown === false) return () => {};
10
+ const timer = setInterval(() => {
11
+ try {
12
+ const c = getConfig();
13
+ if (!c || c.selfHealCooldown === false) return;
14
+ const idle = Number.isFinite(c.selfHealIdleMs) && c.selfHealIdleMs > 0 ? c.selfHealIdleMs : 3600000;
15
+ const providers = Array.isArray(c.providers) ? c.providers : [];
16
+ const pools = providers
17
+ .map((p) => buildRuntime().providerToPool.get(p.provider))
18
+ .filter(Boolean);
19
+ healIdleCooldowns(pools, idle);
20
+ } catch (_) { /* ponytail: never crash the timer */ }
21
+ }, 60000);
22
+ if (typeof timer.unref === 'function') timer.unref();
23
+ return () => clearInterval(timer);
24
+ }, 'dsh-key-rotation: self-healing idle');
25
+ }
26
+
27
+ export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger) {
28
+ return ctx.effect(() => {
29
+ const cfg = getConfig();
30
+ const intervalMin = cfg?.selfHealingIntervalMinutes ?? 30;
31
+ if (!intervalMin || intervalMin <= 0) return () => {};
32
+ const intervalMs = intervalMin * 60 * 1000;
33
+ const timer = setInterval(async () => {
34
+ try {
35
+ const c = getConfig();
36
+ if (!c || !c.selfHealingIntervalMinutes || c.selfHealingIntervalMinutes <= 0) return;
37
+ const { pools } = buildRuntime();
38
+ const runner = ensureSandboxRunner(ctx);
39
+ await autoUnbreakBrokenKeys(pools, async (ref) => {
40
+ let val = (await ctx.credentials?.resolve?.(ref))?.value;
41
+ if (!val) return { ok: false };
42
+ return runner.probeModels(ref, val);
43
+ });
44
+ } catch (e) { logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
45
+ }, intervalMs);
46
+ if (typeof timer.unref === 'function') timer.unref();
47
+ return () => clearInterval(timer);
48
+ }, 'dsh-key-rotation: auto-unbreak');
49
+ }
50
+
51
+ export function setupPersistence(ctx, { cfg0, poolState, moduleBreaker, verboseLoggingOn, logger }) {
52
+ let statePersistence = null;
53
+ try {
54
+ const hostDirs = [
55
+ process.env.DSH_HOME,
56
+ process.cwd(),
57
+ ].filter((d) => typeof d === 'string' && d.length > 0);
58
+ const resolvedPath = resolveStatePath({
59
+ configuredPath: cfg0.persistencePath,
60
+ dataDir: hostDirs[0],
61
+ });
62
+ if (cfg0.persistenceEnabled !== false && resolvedPath) {
63
+ statePersistence = new StatePersistence({ filePath: resolvedPath });
64
+ statePersistence.load().then((snap) => {
65
+ if (!snap) return;
66
+ try {
67
+ StatePersistence.restorePools(poolState, snap);
68
+ if (moduleBreaker && snap.circuit) moduleBreaker.restore(snap.circuit);
69
+ if (verboseLoggingOn?.()) {
70
+ logger?.warn?.(`[dsh-key-rotation] restored ${Object.keys(snap.pools ?? {}).length} pool state(s) from ${path.basename(resolvedPath)}`);
71
+ }
72
+ } catch (e) {
73
+ logger?.warn?.('[dsh-key-rotation] persistence restore failed', e?.message ?? e);
74
+ }
75
+ }).catch(() => {});
76
+ } else if (cfg0.persistenceEnabled !== false && !resolvedPath) {
77
+ logger?.warn?.('[dsh-key-rotation] persistence disabled: no data directory');
78
+ }
79
+ } catch (e) {
80
+ logger?.warn?.('[dsh-key-rotation] persistence init failed', e?.message ?? e);
81
+ }
82
+
83
+ function persistenceSnapshot() {
84
+ if (!statePersistence) return null;
85
+ return StatePersistence.serialize({
86
+ poolState,
87
+ circuitSnapshot: moduleBreaker ? moduleBreaker.snapshot() : {},
88
+ quotaSnapshot: {},
89
+ });
90
+ }
91
+
92
+ function schedulePersist() {
93
+ if (!statePersistence) return;
94
+ const snap = persistenceSnapshot();
95
+ if (snap) statePersistence.save(snap);
96
+ }
97
+
98
+ ctx.effect(() => {
99
+ const timer = setInterval(() => {
100
+ try { schedulePersist(); }
101
+ catch (e) { logger?.warn?.('[dsh-key-rotation] periodic persist failed', e); }
102
+ }, 15000);
103
+ if (typeof timer.unref === 'function') timer.unref();
104
+ return () => {
105
+ clearInterval(timer);
106
+ try {
107
+ if (statePersistence) {
108
+ const snap = persistenceSnapshot();
109
+ if (snap) {
110
+ statePersistence.save(snap);
111
+ void statePersistence.flush();
112
+ }
113
+ statePersistence.dispose();
114
+ }
115
+ } catch (e) {
116
+ logger?.warn?.('[dsh-key-rotation] dispose persist failed', e);
117
+ }
118
+ };
119
+ }, 'dsh-key-rotation: state persistence');
120
+
121
+ return { schedulePersist, persistenceSnapshot };
122
+ }
package/lib/logger.js ADDED
@@ -0,0 +1,13 @@
1
+ // lib/logger.js — safe Cordis logger wrapper
2
+ const noop = () => {};
3
+ const noopLogger = { warn: noop, info: noop, error: noop, debug: noop, log: noop };
4
+
5
+ export function getLogger(ctx, scope = 'dsh-key-rotation') {
6
+ if (typeof ctx?.logger === 'function') {
7
+ return ctx.logger(scope);
8
+ }
9
+ if (ctx?.logger && typeof ctx.logger.warn === 'function') {
10
+ return ctx.logger;
11
+ }
12
+ return noopLogger;
13
+ }
package/lib/ops-status.js CHANGED
@@ -143,7 +143,7 @@ ctx.effect(() => ctx.webServer.register({
143
143
  proactiveRateLimitGuard: pool.proactiveRateLimitGuard ?? runtime.proactiveRateLimitGuard ?? true,
144
144
  });
145
145
  } catch (e) {
146
- console.warn(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
146
+ (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
147
147
  providers.push({ provider: pool.base, keys: [], statusError: String(e?.message ?? e) });
148
148
  }
149
149
  }
@@ -64,7 +64,7 @@ ctx.effect(() => ctx.webServer.register({
64
64
  try {
65
65
  if (action === 'disable-rotation') {
66
66
  setRotationDisabled(true);
67
- console.warn('[dsh-key-rotation] rotation DISABLED via webhook action');
67
+ (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.('[dsh-key-rotation] rotation DISABLED via webhook action');
68
68
  json(res, 200, { ok: true, action });
69
69
  return;
70
70
  }
@@ -84,7 +84,7 @@ ctx.effect(() => ctx.webServer.register({
84
84
  if (p.base !== provider) continue;
85
85
  for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
86
86
  }
87
- console.warn(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
87
+ (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
88
88
  json(res, 200, { ok: true, action, provider, until: Date.now() + 3600000 });
89
89
  return;
90
90
  }
@@ -102,7 +102,7 @@ ctx.effect(() => ctx.webServer.register({
102
102
  if (typeof br.reset === 'function') { br.reset(provider); circuitReset = true; }
103
103
  else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
104
104
  }
105
- console.warn(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
105
+ (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
106
106
  json(res, 200, { ok: true, action, provider, cleared, circuitReset });
107
107
  return;
108
108
  }
@@ -0,0 +1,86 @@
1
+ // lib/pool-builder.js — provider & per-model pool assembly and cleanup
2
+ import { bucketSweep } from './bucket.js';
3
+
4
+ export function parseExpiry(v) {
5
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
6
+ if (typeof v === 'string' && v.length > 0) {
7
+ const t = Date.parse(v);
8
+ if (!Number.isNaN(t)) return t;
9
+ }
10
+ return undefined;
11
+ }
12
+
13
+ export function buildPoolItem({
14
+ base,
15
+ keys,
16
+ weights,
17
+ poolCooldown,
18
+ poolMax,
19
+ expiresAt,
20
+ poolStrategy,
21
+ poolGuard,
22
+ rpmLimit,
23
+ makeState,
24
+ }) {
25
+ const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
26
+ if (refs.length === 0) return null;
27
+ const w = Array.isArray(weights) ? weights : [];
28
+ const weightedRefs = [];
29
+ for (let i = 0; i < refs.length; i++) {
30
+ const ww = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
31
+ for (let k = 0; k < ww; k++) weightedRefs.push(refs[i]);
32
+ }
33
+ const parsedExpiry = {};
34
+ if (Array.isArray(expiresAt)) {
35
+ for (let i = 0; i < refs.length; i++) {
36
+ const exp = parseExpiry(expiresAt[i]);
37
+ if (exp !== undefined) parsedExpiry[refs[i]] = exp;
38
+ }
39
+ }
40
+ return {
41
+ base,
42
+ refs,
43
+ weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
44
+ weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
45
+ state: makeState(base),
46
+ cooldownMs: poolCooldown,
47
+ maxCooldownMs: poolMax,
48
+ expiresAt: parsedExpiry,
49
+ rpmLimit,
50
+ routingStrategy: poolStrategy,
51
+ proactiveRateLimitGuard: poolGuard,
52
+ };
53
+ }
54
+
55
+ export function cleanupRemovedProviders({
56
+ cfg,
57
+ poolState,
58
+ poolByRef,
59
+ providerToPool,
60
+ expectedClones,
61
+ moduleBreaker,
62
+ lowHealthNotifiedAt,
63
+ budgetNotifiedAt,
64
+ }) {
65
+ // auto-cleanup: remove poolState for providers that are now empty or removed
66
+ for (const key of [...poolState.keys()]) {
67
+ if (![...poolByRef.values()].some((p) => p.base === key)) {
68
+ poolState.delete(key);
69
+ lowHealthNotifiedAt?.delete?.(key);
70
+ budgetNotifiedAt?.delete?.(key + ':budget');
71
+ }
72
+ }
73
+ // #192: drop RPM windows for refs that no longer belong to any pool
74
+ for (const st of poolState.values()) {
75
+ if (st.rpmWindows) bucketSweep(st.rpmWindows, new Set(poolByRef.keys()));
76
+ }
77
+ // drop breaker entries for removed providers
78
+ if (moduleBreaker) {
79
+ for (const key of Object.keys(moduleBreaker.snapshot())) {
80
+ if (![...providerToPool.keys()].includes(key) && !expectedClones.has(key)) {
81
+ const still = (cfg.providers ?? []).some((p) => p.provider === key);
82
+ if (!still) moduleBreaker.reset(key);
83
+ }
84
+ }
85
+ }
86
+ }
package/lib/rotate.js CHANGED
@@ -33,9 +33,12 @@ export function createRotate(deps) {
33
33
  setRotateStartMs,
34
34
  quotaStore,
35
35
  circuitBreaker,
36
+ logger,
36
37
  now = nowMono,
37
38
  } = deps;
38
39
 
40
+ const logWarn = (msg) => (logger?.warn ? logger.warn(msg) : null);
41
+
39
42
  function rotate(options, pool) {
40
43
  return (async function* () {
41
44
  const runtime0 = buildRuntime();
@@ -93,7 +96,7 @@ export function createRotate(deps) {
93
96
 
94
97
  // #260: fail fast when provider circuit is open
95
98
  if (circuitBreaker && !circuitBreaker.canRequest(options.provider)) {
96
- console.warn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
99
+ logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
97
100
  yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
98
101
  return;
99
102
  }
@@ -116,7 +119,7 @@ export function createRotate(deps) {
116
119
  penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
117
120
  lastFailure = finishError(e?.code ?? 'TRANSPORT',
118
121
  `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
119
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
122
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
120
123
  continue;
121
124
  }
122
125
 
@@ -146,7 +149,7 @@ export function createRotate(deps) {
146
149
  pool.state.lastReason = String(code ?? 'UNKNOWN');
147
150
  pool.state.lastSwitchAt = now();
148
151
  lastFailure = chunk;
149
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
152
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
150
153
  // #216: per-switch webhook (opt-in switchNotify), deduped per provider
151
154
  if (switchNotify && activeRef) {
152
155
  notifySwitch(runtime0, pool, {
@@ -206,7 +209,7 @@ export function createRotate(deps) {
206
209
  const effCool = Math.min(cool, maxCool ?? cool);
207
210
  recordFailure(pool, activeRef, now(), effCool, maxCool);
208
211
  pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
209
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
212
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
210
213
  }
211
214
  }
212
215
  // #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
@@ -229,7 +232,7 @@ export function createRotate(deps) {
229
232
  pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
230
233
  pool.state.lastSwitchAt = now();
231
234
  lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
232
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
235
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
233
236
  if (switchNotify && activeRef) {
234
237
  notifySwitch(runtime0, pool, {
235
238
  provider: options.provider,
@@ -256,7 +259,7 @@ export function createRotate(deps) {
256
259
  // pool exhausted — all keys cooling or missing
257
260
  pool.state.lastExhaustionAt = now();
258
261
  pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
259
- console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
262
+ logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
260
263
  const runtime = buildRuntime();
261
264
  // notify via extracted helper (see notifyExhaustion above)
262
265
  notifyExhaustion(runtime, pool, { provider: options.provider });
@@ -266,7 +269,7 @@ export function createRotate(deps) {
266
269
  const pools = runtime.providerToPool;
267
270
  const fb = pickCascadeFallback(options.provider, runtime, pools);
268
271
  if (fb && fb.pool && fb.pool !== pool) {
269
- console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
272
+ logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
270
273
  pool.state.lastReason = 'CASCADE';
271
274
  pool.state.lastSwitchAt = now();
272
275
  // Re-dispatch on the fallback pool (depth-1 via __isCascade guard)
@@ -0,0 +1,55 @@
1
+ // lib/sandbox-service.js — sandbox runner and cache service
2
+ import { LastTestCache, SandboxRunner } from './sandbox.js';
3
+
4
+ export function createSandboxService({ getRuntime }) {
5
+ let sandboxRunner = null;
6
+ let defaultCtx = null;
7
+ const lastTestCache = new LastTestCache();
8
+
9
+ function ensureSandboxRunner(ctx) {
10
+ if (ctx) defaultCtx = ctx;
11
+ if (sandboxRunner) return sandboxRunner;
12
+ function resolveBaseUrl(providerOrRef) {
13
+ try {
14
+ let provider = providerOrRef;
15
+ const rt = typeof getRuntime === 'function' ? getRuntime() : null;
16
+ const pool = rt?.poolByRef?.get(providerOrRef);
17
+ if (pool?.base) provider = pool.base;
18
+ else if (pool?.provider) provider = pool.provider;
19
+
20
+ const c = ctx || defaultCtx;
21
+ const pInfo = c?.llm?.getProvider?.(provider);
22
+ if (pInfo && (pInfo.baseUrl || pInfo.endpoint || pInfo.url)) {
23
+ return String(pInfo.baseUrl || pInfo.endpoint || pInfo.url);
24
+ }
25
+ for (const info of (c?.llm?.listProviders?.() || [])) {
26
+ if (info && (info.id === provider || info.name === provider)) {
27
+ const u = info.baseUrl || info.endpoint || info.url;
28
+ if (u) return String(u);
29
+ }
30
+ }
31
+ const ns = c?.get ? c.get('llm-pi-ai') : null;
32
+ const list = ns && (ns.providers || (ns.config && ns.config.providers) || []);
33
+ if (Array.isArray(list)) {
34
+ const hit = list.find((p) => p && (p.id === provider || p.name === provider || (Array.isArray(p.aliases) && p.aliases.includes(provider))));
35
+ const base = hit && (hit.baseUrl || hit.endpoint || hit.url);
36
+ if (base) return String(base);
37
+ }
38
+ return null;
39
+ } catch (_) {
40
+ return null;
41
+ }
42
+ }
43
+ sandboxRunner = new SandboxRunner({ fetchImpl: globalThis.fetch, resolveBaseUrl });
44
+ return sandboxRunner;
45
+ }
46
+
47
+ async function probeRef(ref, key) {
48
+ const runner = ensureSandboxRunner();
49
+ const result = await runner.probeModels(ref, key);
50
+ lastTestCache.set(ref, { ...result, at: Date.now() });
51
+ return result;
52
+ }
53
+
54
+ return { ensureSandboxRunner, probeRef, lastTestCache };
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
4
4
  "packageManager": "pnpm@10.33.2",
5
5
  "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.",
6
6
  "keywords": [
@@ -33,6 +33,9 @@
33
33
  "lib",
34
34
  "cordis.patch.yml",
35
35
  "README.md",
36
+ "README.zh.md",
37
+ "README.ru.md",
38
+ "CHANGELOG.md",
36
39
  "LICENSE"
37
40
  ],
38
41
  "dsh": {