@goodandready/dsh-key-rotation 0.8.40 → 0.8.41

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.41 - 2026-10-01
4
+
5
+ ### Fixed
6
+ - **Reproducible CI and core compatibility matrix (#424)**:
7
+ - Added explicit test `devDependencies` (`@deepseek-ai/schemastery`, `@deepseek-ai/cordis`, `@deepseek-ai/dsh-llm`, `semver`) to `package.json` to ensure clean checkouts (`npm ci`) reproduce all unit tests without depending on global or ambient node_modules.
8
+ - Updated modern core detection in `test/support/real-core.mjs` to properly identify DSH `0.2.0-rc.1`, `0.2.0-rc.2`, and future releases as modern loaders rather than misclassifying them as legacy.
9
+ - **Preserved exponential backoff across credential selection (#410)**:
10
+ - Removed premature deletion of `failCounts`, `authFailCounts`, and `brokenUntil` during resolver selection in `lib/resolver.js` (`settle()`).
11
+ - Quarantine backoff state is now strictly retained until upstream requests finish successfully via `recordSuccess()`, preventing flapping keys from resetting to first-level retry backoff on every dispatch.
12
+ - **Cascade failover on open circuit breaker (#413)**:
13
+ - When a primary provider's circuit breaker is open (`breaker.acquire()` returns null), `lib/rotate.js` now evaluates `pickCascadeFallback` before terminating, seamlessly routing traffic to healthy fallback providers.
14
+ - `pickCascadeFallback` in `lib/cascade.js` now validates circuit breaker state, skipping fallback providers whose circuits are open and failing closed with `CIRCUIT_OPEN` only when all cascade options are exhausted.
15
+ - **Plaintext secret decryption for diagnostic probes and self-healing (#415)**:
16
+ - Added `decryptSecret` decryption to `lib/ops-test.js` (`/dsh-key-rotation/test` route) and `lib/lifecycle.js` (`autoUnbreakBrokenKeys`), preventing encrypted ciphertext credentials (`enc:v1:...`) from being sent to upstream LLM probe endpoints.
17
+ - Probe success now cleanses `revokedRefs` alongside `failedUntil` and `failCounts`.
18
+ - **Fan-out reset across all model pools and shared credentials (#416)**:
19
+ - Key reset (`POST /dsh-key-rotation/reset` with `ref`) now fans out across ALL pools in `poolState` containing the ref rather than halting after the first match, clearing `failedUntil`, `failCounts`, `authFailCounts`, `brokenUntil`, `tokenUsage`, and `revokedRefs`.
20
+ - Provider reset (`provider`) and webhook actions (`pause-*`, `reset-*`) now fan out across base pools and all model sub-pools (`provider::*`), resetting circuit breakers and all associated quotas.
21
+
3
22
  ## 0.8.40 - 2026-09-30
4
23
 
5
24
  ### Fixed
package/lib/cascade.js CHANGED
@@ -5,7 +5,7 @@ import { isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
5
5
 
6
6
  export const CASCADE_MAX_DEPTH = 3;
7
7
 
8
- export function pickCascadeFallback(provider, cfg, pools, currentModel, modelPoolByProvider) {
8
+ export function pickCascadeFallback(provider, cfg, pools, currentModel, modelPoolByProvider, circuitBreaker) {
9
9
  const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
10
10
  const modelPools = modelPoolByProvider || cfg?.modelPoolByProvider || cfg?.index?.modelPoolByProvider || null;
11
11
  const provPools = pools || cfg?.providerToPool || cfg?.index?.providerToPool || null;
@@ -14,6 +14,11 @@ export function pickCascadeFallback(provider, cfg, pools, currentModel, modelPoo
14
14
  const fb = typeof entry === 'string' ? { provider: entry } : entry;
15
15
  if (!fb || !fb.provider || fb.provider === provider) continue;
16
16
 
17
+ const breaker = circuitBreaker || cfg?.circuitBreaker || null;
18
+ if (breaker && typeof breaker.canRequest === 'function' && !breaker.canRequest(fb.provider)) {
19
+ continue;
20
+ }
21
+
17
22
  const mappedModel = (fb.modelMapping && currentModel && fb.modelMapping[currentModel]) || fb.model || currentModel || null;
18
23
  let pool = null;
19
24
  if (modelPools && mappedModel) {
package/lib/lifecycle.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // lib/lifecycle.js — background timers and lifecycle effects
2
2
  import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
3
+ import { decryptSecret } from './crypto-storage.js';
3
4
  import { StatePersistence, resolveStatePath } from './persistence.js';
4
5
  import path from 'node:path';
5
6
  import { envValue } from './pool.js';
@@ -45,8 +46,9 @@ export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandb
45
46
  const credentials = ctx.get?.('credentials') ?? ctx.credentials;
46
47
  const resolve = credentials?.__dshKeyRotationOriginalResolve ?? credentials?.resolve;
47
48
  const hit = await resolve?.call(credentials, ref);
48
- const value = typeof hit?.value === 'string' && hit.value.length > 0
49
+ const raw = typeof hit?.value === 'string' && hit.value.length > 0
49
50
  ? hit.value : envValue(ref);
51
+ const value = raw ? decryptSecret(raw) : raw;
50
52
  if (!value || !isActive()) return { ok: false };
51
53
  return runner.probeModels(ref, value);
52
54
  }, Date.now(), { isActive });
package/lib/ops-keys.js CHANGED
@@ -124,16 +124,27 @@ ctx.effect(() => ctx.webServer.register({
124
124
  const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
125
125
  const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
126
126
  if (provider) {
127
- const st = poolState.get(provider);
128
- if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
129
- const cleared = st.failedUntil.size;
130
- st.failedUntil.clear();
131
- st.failCounts?.clear();
132
- st.authFailCounts?.clear();
133
- st.brokenUntil?.clear();
134
- st.revokedRefs?.clear();
135
- st.tokenUsage?.clear();
136
- st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
127
+ let found = false;
128
+ let cleared = 0;
129
+ for (const [key, st] of poolState.entries()) {
130
+ if (key === provider || key.startsWith(provider + '::')) {
131
+ found = true;
132
+ cleared += st.failedUntil ? st.failedUntil.size : 0;
133
+ st.failedUntil?.clear();
134
+ st.failCounts?.clear();
135
+ st.authFailCounts?.clear();
136
+ st.brokenUntil?.clear();
137
+ st.revokedRefs?.clear();
138
+ st.tokenUsage?.clear();
139
+ st.switches = 0;
140
+ st.lastReason = undefined;
141
+ st.lastSwitchAt = undefined;
142
+ }
143
+ }
144
+ if (!found) {
145
+ json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } });
146
+ return;
147
+ }
137
148
  let circuitReset = false;
138
149
  const br = circuitBreaker ?? buildRuntime().breaker;
139
150
  if (br) {
@@ -146,7 +157,15 @@ ctx.effect(() => ctx.webServer.register({
146
157
  if (ref) {
147
158
  let found = false;
148
159
  for (const st of poolState.values()) {
149
- if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.authFailCounts?.has(ref) || st.brokenUntil?.has(ref) || st.tokenUsage?.has(ref)) {
160
+ if (
161
+ st.failedUntil?.has(ref) ||
162
+ st.failCounts?.has(ref) ||
163
+ st.authFailCounts?.has(ref) ||
164
+ st.brokenUntil?.has(ref) ||
165
+ st.revokedRefs?.has(ref) ||
166
+ st.tokenUsage?.has(ref) ||
167
+ st.lastUsed === ref
168
+ ) {
150
169
  st.failedUntil?.delete(ref);
151
170
  st.failCounts?.delete(ref);
152
171
  st.authFailCounts?.delete(ref);
@@ -154,11 +173,14 @@ ctx.effect(() => ctx.webServer.register({
154
173
  st.revokedRefs?.delete(ref);
155
174
  st.tokenUsage?.delete(ref);
156
175
  if (st.lastUsed === ref) st.lastUsed = undefined;
157
- found = true; break;
176
+ found = true;
158
177
  }
159
178
  }
160
179
  // idempotent: even if ref was not cooling, report ok if it looks like a valid ref name
161
- if (!found && !isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
180
+ if (!found && !isValidRef(ref)) {
181
+ json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
182
+ return;
183
+ }
162
184
  json(res, 200, { ok: true, ref });
163
185
  return;
164
186
  }
package/lib/ops-test.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  TEST_PATH,
15
15
  SANDBOX_CACHE_PATH,
16
16
  } from './ops-paths.js';
17
+ import { decryptSecret } from './crypto-storage.js';
17
18
 
18
19
  /**
19
20
  * @param {object} ctx cordis context
@@ -43,7 +44,8 @@ ctx.effect(() => ctx.webServer.register({
43
44
  try {
44
45
  let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
45
46
  let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
46
- const effectiveValue = testValue || hit?.value;
47
+ const rawValue = testValue || hit?.value;
48
+ const effectiveValue = rawValue ? decryptSecret(rawValue) : rawValue;
47
49
  const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
48
50
  const tail = valid ? keyTail(effectiveValue) : '';
49
51
  let source = null;
@@ -63,11 +65,12 @@ ctx.effect(() => ctx.webServer.register({
63
65
  lastTestCache.set(ref, cached);
64
66
  if (cached.ok) {
65
67
  for (const st of poolState.values()) {
66
- if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref)) {
68
+ if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref) || st.revokedRefs?.has(ref)) {
67
69
  st.failedUntil?.delete(ref);
68
70
  st.failCounts?.delete(ref);
69
71
  st.authFailCounts?.delete(ref);
70
72
  st.brokenUntil?.delete(ref);
73
+ st.revokedRefs?.delete(ref);
71
74
  }
72
75
  }
73
76
  }
@@ -153,24 +153,50 @@ ctx.effect(() => ctx.webServer.register({
153
153
  }
154
154
  if (action.startsWith('pause-')) {
155
155
  const until = Date.now() + 3600000; // 1h pause
156
- for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
157
- // also pause every key currently healthy
156
+ let found = false;
157
+ for (const [key, s] of poolState.entries()) {
158
+ if (key === provider || key.startsWith(provider + '::')) {
159
+ found = true;
160
+ for (const ref of (s.failedUntil ? [...s.failedUntil.keys()] : [])) {
161
+ s.failedUntil.set(ref, Math.max(s.failedUntil.get(ref) ?? 0, until));
162
+ }
163
+ }
164
+ }
158
165
  for (const p of buildRuntime().poolByRef.values()) {
159
- if (p.base !== provider) continue;
160
- for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
166
+ if (p.provider === provider || p.base === provider || p.base?.startsWith(provider + '::')) {
167
+ found = true;
168
+ for (const ref of p.refs) p.state?.failedUntil?.set(ref, Math.max(p.state?.failedUntil?.get(ref) ?? 0, until));
169
+ }
170
+ }
171
+ if (!found) {
172
+ await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
173
+ return;
161
174
  }
162
175
  (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
163
176
  await respond(200, { ok: true, action, provider, until }, `Pool ${provider} paused 1h`);
164
177
  return;
165
178
  }
166
- const cleared = st.failedUntil.size;
167
- st.failedUntil.clear();
168
- st.failCounts?.clear();
169
- st.authFailCounts?.clear();
170
- st.brokenUntil?.clear();
171
- st.switches = 0;
172
- st.lastReason = undefined;
173
- st.lastSwitchAt = undefined;
179
+ let found = false;
180
+ let cleared = 0;
181
+ for (const [key, s] of poolState.entries()) {
182
+ if (key === provider || key.startsWith(provider + '::')) {
183
+ found = true;
184
+ cleared += s.failedUntil ? s.failedUntil.size : 0;
185
+ s.failedUntil?.clear();
186
+ s.failCounts?.clear();
187
+ s.authFailCounts?.clear();
188
+ s.brokenUntil?.clear();
189
+ s.revokedRefs?.clear();
190
+ s.tokenUsage?.clear();
191
+ s.switches = 0;
192
+ s.lastReason = undefined;
193
+ s.lastSwitchAt = undefined;
194
+ }
195
+ }
196
+ if (!found) {
197
+ await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
198
+ return;
199
+ }
174
200
  let circuitReset = false;
175
201
  const br = circuitBreaker ?? buildRuntime().breaker;
176
202
  if (br) {
package/lib/resolver.js CHANGED
@@ -237,10 +237,6 @@ function settle(pool, candidate, now, onPicked, concurrencyRef) {
237
237
  if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
238
238
  pool.state.lastUsedAt.set(candidate, now);
239
239
  onPicked?.(pool, candidate, { concurrencyRef });
240
- pool.state.failCounts?.delete(candidate);
241
- pool.state.failedUntil.delete(candidate);
242
- pool.state.authFailCounts?.delete(candidate);
243
- pool.state.brokenUntil?.delete(candidate);
244
240
  if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
245
241
  pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
246
242
  if (pool.perHour) {
package/lib/rotate.js CHANGED
@@ -192,7 +192,30 @@ export function createRotate(deps) {
192
192
  for (let attempt = 0; attempt < attemptList.length; attempt++) {
193
193
  activePermit = breaker ? breaker.acquire(options.provider) : null;
194
194
  if (breaker && !activePermit) {
195
- logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
195
+ logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — trying cascade fallback`);
196
+ const runtimeQ = buildRuntime();
197
+ const maxDepth = runtimeQ.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
198
+ const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
199
+ if (currentDepth < maxDepth && Array.isArray(runtimeQ.cascade) && runtimeQ.cascade.length > 0) {
200
+ const fb = pickCascadeFallback(options.provider, runtimeQ, runtimeQ.providerToPool, options.model, runtimeQ.modelPoolByProvider, breaker);
201
+ if (fb && fb.pool && fb.pool !== pool) {
202
+ pool.state.lastReason = 'CASCADE';
203
+ pool.state.lastSwitchAt = now();
204
+ pool.state.cascadeCount = (pool.state.cascadeCount ?? 0) + 1;
205
+ pushEvent(pool, reqStore.pickedRef ?? 'pool', 'CASCADE_TRIGGERED', 0);
206
+ const innerCascade = rotate({
207
+ ...options,
208
+ provider: fb.provider,
209
+ model: fb.model || options.model,
210
+ __isCascade: true,
211
+ __cascadeDepth: currentDepth + 1,
212
+ }, fb.pool);
213
+ for await (const chunk of innerCascade) {
214
+ yield chunk;
215
+ }
216
+ return;
217
+ }
218
+ }
196
219
  yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
197
220
  return;
198
221
  }
@@ -425,7 +448,7 @@ export function createRotate(deps) {
425
448
  const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
426
449
  if (currentDepth < maxDepth && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
427
450
  const pools = runtime.providerToPool;
428
- const fb = pickCascadeFallback(options.provider, runtime, pools, options.model, runtime.modelPoolByProvider);
451
+ const fb = pickCascadeFallback(options.provider, runtime, pools, options.model, runtime.modelPoolByProvider, circuitBreaker);
429
452
  if (fb && fb.pool && fb.pool !== pool) {
430
453
  logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider} (model: ${fb.model || options.model || 'default'})`);
431
454
  pool.state.lastReason = 'CASCADE';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.40",
3
+ "version": "0.8.41",
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": [
@@ -69,7 +69,11 @@
69
69
  },
70
70
  "devDependencies": {
71
71
  "react": "18.3.1",
72
- "react-test-renderer": "18.3.1"
72
+ "react-test-renderer": "18.3.1",
73
+ "@deepseek-ai/cordis": "^4.0.1",
74
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
75
+ "@deepseek-ai/schemastery": "^3.18.4",
76
+ "semver": "^7.6.3"
73
77
  },
74
78
  "dependencies": {
75
79
  "undici": "^6.29.0"