@goodandready/dsh-key-rotation 0.8.83 → 0.8.85

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.85 (2026-11-03)
4
+
5
+ ### Inter-Token Stall & Zombie Stream Detector (Block 12 Release)
6
+ - **Watchdog Stream Protection (Issue #569)**: Added `withStreamWatchdog` and `StreamStallError` (`src/stream-watchdog.js`) to continuously monitor active token streams and protect against zombie HTTP connections and provider gateway hangs.
7
+ - **Differentiated Reasoning & Inter-Token Timeouts**: Configurable `stallInitialTimeoutMs` (default: 30s) prevents false positives during initial reasoning generation for DeepSeek R1 and OpenAI o1/o3, while `stallTimeoutMs` (default: 15s) immediately aborts stalled streams when tokens stop arriving mid-response.
8
+ - **Failover & Cooldown Integration (`src/rotate.js`)**: If a stall occurs before any token has yielded, the key is penalized and failover immediately rotates to the next available provider candidate. If a mid-stream stall occurs after partial output, the key is penalized and a structured `STREAM_STALLED` terminal chunk is delivered, keeping the conversation from hanging.
9
+ - **Config & Taxonomy Support**: Added `stallDetectionEnabled`, `stallTimeoutMs`, and `stallInitialTimeoutMs` to Schemastery `SettingsSchema` and `PoolConfig` types, and integrated `STREAM_STALLED` into error taxonomy and 24-hour operations analytics as a recoverable `TIMEOUT`.
10
+
11
+ ## 0.8.84 (2026-10-31)
12
+
13
+ ### DSH 0.2.2 Cordis Service Isolation Compatibility (Hotfix Release)
14
+ - **DSH 0.2.2 Strict Isolation (Issue #567, #568, GitHub #27)**: Resolved Cordis service access without explicit inject by introducing scoped inject guards for `webServer`, `settings`, and optional services across all HTTP routes, lifecycle hooks, and plugin updater.
15
+ - **Zero-Breakage Backward Compatibility**: Retained full compatibility with DSH 0.2.1 and headless environments while ensuring strict adherence to Cordis 0.2.2+ isolation rules.
16
+
3
17
  ## 0.8.83 (2026-10-31)
4
18
 
5
19
  ### TypeScript Migration & Type-Safe Core Architecture (Block 11 Release)
@@ -95,7 +95,7 @@ export function classifyFailure(failureOrPayload) {
95
95
  if (SURFACE_STATUS.has(status)) {
96
96
  return { action: 'surface', code: String(status), soft: false, reason: 'client' };
97
97
  }
98
- if (code === 'TIMEOUT' || code === 'DEADLINE_EXCEEDED' || /timeout/i.test(message)) {
98
+ if (code === 'TIMEOUT' || code === 'DEADLINE_EXCEEDED' || code === 'STREAM_STALLED' || /timeout|stream stalled/i.test(message)) {
99
99
  return { action: 'switch', code: 'TIMEOUT', soft: true, reason: 'timeout' };
100
100
  }
101
101
  if (SOCKET_SWITCH.has(code) || /ECONNRESET|ECONNREFUSED|ETIMEDOUT|socket hang up|premature close/i.test(message)) {
package/lib/index.js CHANGED
@@ -36,6 +36,7 @@ import { CircuitBreaker } from './circuit-breaker.js';
36
36
  import { NotifyQueue } from './notify-queue.js';
37
37
  import { classifyFailure } from './error-taxonomy.js';
38
38
  import { registerOpsRoutes } from './routes-ops.js';
39
+ import { getWebServer } from './routes/guard.js';
39
40
  import { registerPluginUpdater } from './plugin-updater.js';
40
41
  import { notifySwitch, notifyExhaustion, notifyEarlyWarning, pushEvent } from './notify-events.js';
41
42
  import { QUOTA_WINDOW_TYPES } from './quota-window.js';
@@ -136,6 +137,9 @@ const SettingsSchema = Schema.object({
136
137
  warnBelowHealthy: Schema.number().default(0),
137
138
  latencySloMs: Schema.number().default(0),
138
139
  showChatHeaderChip: Schema.boolean().default(true),
140
+ stallDetectionEnabled: Schema.boolean().default(true),
141
+ stallTimeoutMs: Schema.number().default(15000),
142
+ stallInitialTimeoutMs: Schema.number().default(30000),
139
143
  providers: Schema.array(Schema.object({
140
144
  provider: Schema.string().required(),
141
145
  keys: Schema.array(Schema.string()).default([]),
@@ -165,6 +169,9 @@ const SettingsSchema = Schema.object({
165
169
  proactiveRateLimitGuard: Schema.boolean(),
166
170
  rpmLimit: Schema.number(),
167
171
  tpmLimit: Schema.number(),
172
+ stallDetectionEnabled: Schema.boolean(),
173
+ stallTimeoutMs: Schema.number(),
174
+ stallInitialTimeoutMs: Schema.number(),
168
175
  })).default([...DEFAULT_PROVIDERS]),
169
176
  });
170
177
 
@@ -178,7 +185,7 @@ export const Config = typeof Schema.number().volatile === 'function'
178
185
  : SettingsSchema;
179
186
 
180
187
  function registerConfigBridge(ctx, getCloneIds) {
181
- return ctx.webServer.register({
188
+ return getWebServer(ctx).register({
182
189
  kind: 'exact',
183
190
  path: CONFIG_PATH,
184
191
  handler: (req, res) => {
@@ -246,9 +253,11 @@ export function apply(ctx, config = {}) {
246
253
 
247
254
  setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger);
248
255
 
249
- // Dynamic webServer registration (#528)
256
+ // Dynamic webServer registration (#528, #567)
250
257
  const setupWebRoutes = (webCtx = ctx) => {
251
- const target = webCtx?.webServer ? webCtx : ctx;
258
+ const target = getWebServer(webCtx) ? webCtx : ctx;
259
+ const ws = getWebServer(target);
260
+ if (!ws) return;
252
261
  registerConfigBridge(target, () => buildRuntime().cloneIds);
253
262
  registerOpsRoutes(target, {
254
263
  buildRuntime,
@@ -265,7 +274,7 @@ export function apply(ctx, config = {}) {
265
274
  selectionTraceStore,
266
275
  diagBuffer,
267
276
  });
268
- if (typeof target.webServer?.register === 'function') {
277
+ if (typeof ws?.register === 'function') {
269
278
  registerPluginUpdater(target, {
270
279
  endpoint: '/api/dsh-key-rotation/update',
271
280
  packageName: '@goodandready/dsh-key-rotation',
@@ -274,10 +283,12 @@ export function apply(ctx, config = {}) {
274
283
  }
275
284
  };
276
285
 
277
- if (ctx.webServer) {
286
+ if (Object.prototype.hasOwnProperty.call(ctx, 'webServer') && ctx.webServer) {
278
287
  setupWebRoutes(ctx);
279
288
  } else if (typeof ctx.inject === 'function') {
280
289
  ctx.inject(['webServer'], setupWebRoutes);
290
+ } else if (getWebServer(ctx)) {
291
+ setupWebRoutes(ctx);
281
292
  }
282
293
 
283
294
  // Periodic sweep of expired cooldowns
package/lib/lifecycle.js CHANGED
@@ -45,7 +45,7 @@ export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandb
45
45
  await autoUnbreakBrokenKeys(pools, async (ref) => {
46
46
  // A rotating resolver can substitute a healthy neighbor for this
47
47
  // quarantined ref, falsely proving that the broken key recovered.
48
- const credentials = ctx.get?.('credentials') ?? ctx.credentials;
48
+ const credentials = (typeof ctx?.get === 'function' ? ctx.get('credentials') : undefined) ?? (ctx && Object.prototype.hasOwnProperty.call(ctx, 'credentials') ? ctx.credentials : undefined);
49
49
  const resolve = credentials?.__dshKeyRotationOriginalResolve ?? credentials?.resolve;
50
50
  const hit = await resolve?.call(credentials, ref);
51
51
  const raw = typeof hit?.value === 'string' && hit.value.length > 0
@@ -197,7 +197,7 @@ export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, en
197
197
  for (const ref of candidates) {
198
198
  if (disposed) break;
199
199
  try {
200
- const credentials = ctx.get?.('credentials') ?? ctx.credentials;
200
+ const credentials = (typeof ctx?.get === 'function' ? ctx.get('credentials') : undefined) ?? (ctx && Object.prototype.hasOwnProperty.call(ctx, 'credentials') ? ctx.credentials : undefined);
201
201
  const resolve = credentials?.__dshKeyRotationOriginalResolve ?? credentials?.resolve;
202
202
  const hit = await resolve?.call(credentials, ref);
203
203
  const raw = typeof hit?.value === 'string' && hit.value.length > 0 ? hit.value : envValue(ref);
@@ -18,7 +18,7 @@ export function classifyErrorCategory(code, message = '') {
18
18
  if (/^5\d\d$/.test(c) || c === 'SERVER' || c === 'BAD_GATEWAY' || c === 'SERVICE_UNAVAILABLE' || m.includes('internal server error')) {
19
19
  return 'server';
20
20
  }
21
- if (c.includes('TIMEOUT') || c === 'ETIMEDOUT' || c === 'ESOCKETTIMEDOUT' || m.includes('timeout') || m.includes('timed out')) {
21
+ if (c.includes('TIMEOUT') || c.includes('STALL') || c === 'ETIMEDOUT' || c === 'ESOCKETTIMEDOUT' || m.includes('timeout') || m.includes('timed out') || m.includes('stalled')) {
22
22
  return 'timeout';
23
23
  }
24
24
  if (c === 'QUOTA' || c === '402' || c.includes('QUOTA') || m.includes('quota') || m.includes('credit') || m.includes('insufficient_quota')) {
@@ -314,7 +314,10 @@ function json(response, statusCode, value) {
314
314
 
315
315
  export function registerPluginUpdater(ctx, options) {
316
316
  let installing = false
317
- return ctx.webServer.register({
317
+ const viaOwn = ctx && Object.prototype.hasOwnProperty.call(ctx, 'webServer') && typeof ctx.webServer?.register === 'function' ? ctx.webServer : undefined
318
+ const viaGet = typeof ctx?.get === 'function' ? ctx.get('webServer') : undefined
319
+ const ws = viaOwn ?? (typeof viaGet?.register === 'function' ? viaGet : undefined) ?? ctx?.webServer
320
+ return ws.register({
318
321
  kind: 'exact',
319
322
  path: options.endpoint,
320
323
  handler: async (request, response) => {
@@ -41,6 +41,9 @@ export function buildPoolItem({
41
41
  maxQueueWaitMs = null,
42
42
  maxAttempts = null,
43
43
  maxDurationMs = null,
44
+ stallDetectionEnabled = true,
45
+ stallTimeoutMs = 15000,
46
+ stallInitialTimeoutMs = 30000,
44
47
  }) {
45
48
  const seen = new Set();
46
49
  const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
@@ -151,6 +154,9 @@ const weightsMap = {};
151
154
  maxDurationMs: typeof maxDurationMs === 'number' && Number.isFinite(maxDurationMs) && maxDurationMs > 0
152
155
  ? Math.floor(maxDurationMs)
153
156
  : 0,
157
+ stallDetectionEnabled: stallDetectionEnabled ?? true,
158
+ stallTimeoutMs: typeof stallTimeoutMs === 'number' && Number.isFinite(stallTimeoutMs) && stallTimeoutMs > 0 ? Math.floor(stallTimeoutMs) : 15000,
159
+ stallInitialTimeoutMs: typeof stallInitialTimeoutMs === 'number' && Number.isFinite(stallInitialTimeoutMs) && stallInitialTimeoutMs > 0 ? Math.floor(stallInitialTimeoutMs) : 30000,
154
160
  };
155
161
  }
156
162
 
@@ -198,6 +204,9 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
198
204
  maxQueueWaitMs: p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
199
205
  maxAttempts: poolAttempts,
200
206
  maxDurationMs: poolDuration,
207
+ stallDetectionEnabled: p.stallDetectionEnabled ?? cfg.stallDetectionEnabled ?? true,
208
+ stallTimeoutMs: p.stallTimeoutMs ?? cfg.stallTimeoutMs ?? 15000,
209
+ stallInitialTimeoutMs: p.stallInitialTimeoutMs ?? cfg.stallInitialTimeoutMs ?? 30000,
201
210
  }, provRpm, provTpm, provConcurrency);
202
211
  if (pool) {
203
212
  for (const ref of pool.refs) {
@@ -230,6 +239,9 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
230
239
  maxQueueWaitMs: mp.maxQueueWaitMs ?? p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
231
240
  maxAttempts: typeof mp.maxAttempts === 'number' ? mp.maxAttempts : poolAttempts,
232
241
  maxDurationMs: typeof mp.maxDurationMs === 'number' ? mp.maxDurationMs : poolDuration,
242
+ stallDetectionEnabled: mp.stallDetectionEnabled ?? p.stallDetectionEnabled ?? cfg.stallDetectionEnabled ?? true,
243
+ stallTimeoutMs: mp.stallTimeoutMs ?? p.stallTimeoutMs ?? cfg.stallTimeoutMs ?? 15000,
244
+ stallInitialTimeoutMs: mp.stallInitialTimeoutMs ?? p.stallInitialTimeoutMs ?? cfg.stallInitialTimeoutMs ?? 30000,
233
245
  }, modelRpm, modelTpm, modelConcurrency);
234
246
  if (!mpool) continue;
235
247
  mpool.basePool = pool;
package/lib/pool.js CHANGED
@@ -491,6 +491,7 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
491
491
  let rawCode = String(failure.code ?? failure.reason ?? '').trim();
492
492
  let code = rawCode.toUpperCase();
493
493
  if (code && SOCKET_TRANSPORT_CODES.has(code)) code = 'TRANSPORT';
494
+ if (code === 'STREAM_STALLED' || code === 'STALL_TIMEOUT' || code === 'INTER_TOKEN_STALL') code = 'TIMEOUT';
494
495
  const message = String(failure.message ?? failureOrPayload.message ?? '');
495
496
 
496
497
  // Treat numeric failure.code (e.g. code: "429") as HTTP status if status was not set (#545)
package/lib/rotate.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
 
27
27
  import { LOCAL_POOL_EXHAUSTED_CODE, resolveKeyDirectly } from './resolver.js';
28
28
  import { recordStreamSuccessMetrics, preserveDispatchContext } from './stream-metrics.js';
29
+ import { withStreamWatchdog, StreamStallError } from './stream-watchdog.js';
29
30
  import { penalizeKey } from './rotate-penalty.js';
30
31
  import { recordPromptCache } from './prompt-cache.js';
31
32
  import { recordRequestOutcome, recordFailureCategory } from './ops-analytics.js';
@@ -310,8 +311,33 @@ export function createRotate(deps) {
310
311
  }
311
312
 
312
313
  const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
314
+ let stallAbort = null;
315
+ if (typeof AbortController !== 'undefined') {
316
+ stallAbort = new AbortController();
317
+ if (budget?.signal) {
318
+ if (budget.signal.aborted) {
319
+ try { stallAbort.abort(budget.signal.reason); } catch {}
320
+ } else {
321
+ budget.signal.addEventListener('abort', () => {
322
+ try { stallAbort?.abort(budget.signal.reason); } catch {}
323
+ }, { once: true });
324
+ }
325
+ }
326
+ }
327
+ const guardedStream = withStreamWatchdog(
328
+ preserveDispatchContext(inner, dispatchStorage, reqStore),
329
+ {
330
+ stallDetectionEnabled: pool.stallDetectionEnabled ?? runtime0.stallDetectionEnabled ?? true,
331
+ stallTimeoutMs: pool.stallTimeoutMs ?? runtime0.stallTimeoutMs ?? 15000,
332
+ stallInitialTimeoutMs: pool.stallInitialTimeoutMs ?? runtime0.stallInitialTimeoutMs ?? 30000,
333
+ abortController: stallAbort,
334
+ onStall: (err) => {
335
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(_pickedRef ?? '?')} stalled (${err.message})`);
336
+ },
337
+ }
338
+ );
313
339
  try {
314
- for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
340
+ for await (const chunk of guardedStream) {
315
341
  if (!reqStore.concurrencyRef && typeof concurrencyTracker?.acquire === 'function') {
316
342
  const fallbackRef = reqStore.pickedRef ?? _pickedRef;
317
343
  if (fallbackRef) {
@@ -434,33 +460,36 @@ export function createRotate(deps) {
434
460
  } catch (e) {
435
461
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
436
462
  const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed ?? attemptList[attempt];
463
+ const isStall = e instanceof StreamStallError || e?.code === 'STREAM_STALLED' || e?.isStall;
437
464
  if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
465
+ const failCode = isStall ? 'STREAM_STALLED' : (e?.code ?? 'TRANSPORT');
466
+ const catCode = isStall ? 'TIMEOUT' : failCode;
438
467
  preTokenBuffer.length = 0; // Discard metadata from failed attempt (#527)
439
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
440
- recordFailureCategory(pool, e?.code ?? 'TRANSPORT', String(e?.message ?? e), activeRef, now());
468
+ penalizeRef(activeRef, catCode, String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
469
+ recordFailureCategory(pool, catCode, String(e?.message ?? e), activeRef, now());
441
470
  pool.state.switches = (pool.state.switches ?? 0) + 1;
442
- pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
471
+ pool.state.lastReason = failCode;
443
472
  pool.state.lastSwitchAt = now();
444
- lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
445
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw before first token ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
473
+ lastFailure = finishError(failCode, String(e?.message ?? e));
474
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw before first token ${failCode} - failover to next key`);
446
475
  if (switchNotify && activeRef) {
447
476
  notifySwitch(runtime0, pool, {
448
477
  provider: options.provider,
449
478
  from: activeRef,
450
- code: String(e?.code ?? 'TRANSPORT'),
479
+ code: failCode,
451
480
  at: pool.state.lastSwitchAt,
452
481
  });
453
482
  }
454
483
  continue; // Failover to next key!
455
484
  }
456
- if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
457
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
485
+ if (yielded && (isStall || isSwitchableError(e, effectiveSwitchCodes))) {
486
+ penalizeRef(activeRef, isStall ? 'TIMEOUT' : (e?.code ?? 'TRANSPORT'), String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
458
487
  }
459
488
  if (!yielded) {
460
489
  for (const prev of preTokenBuffer) yield prev;
461
490
  preTokenBuffer.length = 0;
462
491
  }
463
- yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
492
+ yield finishError(isStall ? 'STREAM_STALLED' : (e?.code ?? 'TRANSPORT'), String(e?.message ?? e));
464
493
  return;
465
494
  }
466
495
 
@@ -1,3 +1,4 @@
1
+ import { getWebServer } from './guard.js';
1
2
  // lib/ops-balance.js — operational HTTP routes for balance inspection (#495).
2
3
 
3
4
  import {
@@ -18,7 +19,7 @@ export function registerBalanceRoutes(ctx, deps) {
18
19
  buildRuntime,
19
20
  } = deps;
20
21
 
21
- ctx.effect(() => ctx.webServer.register({
22
+ ctx.effect(() => getWebServer(ctx).register({
22
23
  kind: 'exact',
23
24
  path: BALANCE_PATH,
24
25
  handler: async (req, res) => {
@@ -1,3 +1,4 @@
1
+ import { getWebServer } from './guard.js';
1
2
  // lib/ops-config-io.js — full configuration export and import (#504).
2
3
  import crypto from 'node:crypto';
3
4
  import { readFileSync } from 'node:fs';
@@ -187,7 +188,7 @@ export function diffConfigs(currentConfig, incomingConfig, mode = 'merge') {
187
188
 
188
189
  export function registerConfigIoRoutes(ctx, deps) {
189
190
  // ── GET & POST /dsh-key-rotation/export (#552: support POST for safe passphrase transmission) ──
190
- ctx.effect(() => ctx.webServer.register({
191
+ ctx.effect(() => getWebServer(ctx).register({
191
192
  kind: 'exact',
192
193
  path: CONFIG_EXPORT_PATH,
193
194
  handler: async (req, res) => {
@@ -448,7 +449,7 @@ export function registerConfigIoRoutes(ctx, deps) {
448
449
  });
449
450
  };
450
451
 
451
- ctx.effect(() => ctx.webServer.register({
452
+ ctx.effect(() => getWebServer(ctx).register({
452
453
  kind: 'exact',
453
454
  path: CONFIG_IMPORT_PATH,
454
455
  handler: handleImport,
@@ -11,3 +11,26 @@ export {
11
11
  json,
12
12
  readJson,
13
13
  } from '../http-bridge.js';
14
+
15
+ /**
16
+ * Safely resolve webServer from a Cordis context or test mock without
17
+ * triggering Cordis's Proxy trap (`cannot get property "webServer" without inject`)
18
+ * when `webServer` is provided by a sibling plugin and not in static `inject`.
19
+ */
20
+ export function getWebServer(ctx) {
21
+ if (!ctx) return undefined;
22
+ if (Object.prototype.hasOwnProperty.call(ctx, 'webServer') && typeof ctx.webServer?.register === 'function') {
23
+ return ctx.webServer;
24
+ }
25
+ if (typeof ctx.get === 'function') {
26
+ const viaGet = ctx.get('webServer');
27
+ if (typeof viaGet?.register === 'function') return viaGet;
28
+ }
29
+ try {
30
+ const ws = ctx.webServer;
31
+ if (typeof ws?.register === 'function') return ws;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ return undefined;
36
+ }
@@ -1,3 +1,4 @@
1
+ import { getWebServer } from './guard.js';
1
2
  import { decryptSecret, encryptSecret } from '../crypto-storage.js';
2
3
  // lib/ops-keys.js — key store + cooldown reset + import routes (#312 split from routes-ops.js).
3
4
  import {
@@ -156,7 +157,7 @@ export async function discoverUnconfiguredKeys(ctx, runtimePools = []) {
156
157
 
157
158
  export function registerKeyRoutes(ctx, deps) {
158
159
 
159
- ctx.effect(() => ctx.webServer.register({
160
+ ctx.effect(() => getWebServer(ctx).register({
160
161
  kind: 'exact',
161
162
  path: DRAIN_PATH,
162
163
  handler: async (req, res) => {
@@ -219,7 +220,7 @@ ctx.effect(() => ctx.webServer.register({
219
220
  // it is never sent back, only its last few characters are (see the status
220
221
  // route) — and the route is loopback- and same-origin-gated like the config
221
222
  // bridge next to it.
222
- ctx.effect(() => ctx.webServer.register({
223
+ ctx.effect(() => getWebServer(ctx).register({
223
224
  kind: 'exact',
224
225
  path: KEY_PATH,
225
226
  handler: async (req, res) => {
@@ -289,7 +290,7 @@ ctx.effect(() => ctx.webServer.register({
289
290
 
290
291
 
291
292
  // ── reset route: clear cooldown for a provider (or a single ref) ──
292
- ctx.effect(() => ctx.webServer.register({
293
+ ctx.effect(() => getWebServer(ctx).register({
293
294
  kind: 'exact',
294
295
  path: RESET_PATH,
295
296
  handler: async (req, res) => {
@@ -374,7 +375,7 @@ ctx.effect(() => ctx.webServer.register({
374
375
  }), 'dsh-key-rotation: reset route');
375
376
 
376
377
 
377
- ctx.effect(() => ctx.webServer.register({
378
+ ctx.effect(() => getWebServer(ctx).register({
378
379
  kind: 'exact',
379
380
  path: IMPORT_PATH,
380
381
  handler: async (req, res) => {
@@ -411,7 +412,7 @@ ctx.effect(() => ctx.webServer.register({
411
412
  // Health for external panels (Beszel/Uptime)
412
413
 
413
414
  // ── discover unconfigured keys route (#491) ──
414
- ctx.effect(() => ctx.webServer.register({
415
+ ctx.effect(() => getWebServer(ctx).register({
415
416
  kind: 'exact',
416
417
  path: DISCOVER_PATH,
417
418
  handler: async (req, res) => {
@@ -434,7 +435,7 @@ ctx.effect(() => ctx.webServer.register({
434
435
  }), 'dsh-key-rotation: discover route');
435
436
 
436
437
  // ── auto-import discovered keys into pools (#491) ──
437
- ctx.effect(() => ctx.webServer.register({
438
+ ctx.effect(() => getWebServer(ctx).register({
438
439
  kind: 'exact',
439
440
  path: AUTO_IMPORT_PATH,
440
441
  handler: async (req, res) => {
@@ -528,7 +529,7 @@ ctx.effect(() => ctx.webServer.register({
528
529
  },
529
530
  }), 'dsh-key-rotation: auto-import route');
530
531
 
531
- ctx.effect(() => ctx.webServer.register({
532
+ ctx.effect(() => getWebServer(ctx).register({
532
533
  kind: 'exact',
533
534
  path: PRESET_LIMITS_PATH,
534
535
  handler: async (req, res) => {
@@ -1,3 +1,4 @@
1
+ import { getWebServer } from './guard.js';
1
2
  // lib/ops-metrics.js — Prometheus / OpenMetrics export route (#373).
2
3
  import { METRICS_PATH } from './paths.js';
3
4
  import { isTrustedBridgeRequest, isKeyPaused, isKeyRevoked } from '../pool.js';
@@ -10,7 +11,7 @@ import { hasModelQuotaConfig, isModelQuotaAvailable } from '../model-quota.js';
10
11
  export function registerMetricsRoutes(ctx, deps) {
11
12
  const { buildRuntime, poolState } = deps;
12
13
 
13
- ctx.effect(() => ctx.webServer.register({
14
+ ctx.effect(() => getWebServer(ctx).register({
14
15
  kind: 'exact',
15
16
  path: METRICS_PATH,
16
17
  handler: async (req, res) => {
@@ -1,3 +1,4 @@
1
+ import { getWebServer } from './guard.js';
1
2
  import { json, readJson, isTrustedBridgeRequest } from '../http-bridge.js';
2
3
  import { QUICK_ACTION_PATH } from './paths.js';
3
4
  import { keyTail, isKeyPaused } from '../pool.js';
@@ -9,7 +10,7 @@ import { keyTail, isKeyPaused } from '../pool.js';
9
10
  export function registerQuickActionRoutes(ctx, deps) {
10
11
  const { buildRuntime, pushEvent, circuitBreaker } = deps;
11
12
 
12
- ctx.effect(() => ctx.webServer.register({
13
+ ctx.effect(() => getWebServer(ctx).register({
13
14
  kind: 'exact',
14
15
  path: QUICK_ACTION_PATH,
15
16
  handler: async (req, res) => {
@@ -1,3 +1,4 @@
1
+ import { getWebServer } from './guard.js';
1
2
  import { getModelQuotaStatus, isModelQuotaAvailable, hasModelQuotaConfig } from '../model-quota.js';
2
3
  import { providerOfBase, basePoolForBase, modelPoolsForRef } from '../pool-index.js';
3
4
  import { decryptSecret } from '../crypto-storage.js';
@@ -44,7 +45,7 @@ export function registerStatusRoutes(ctx, deps) {
44
45
  // (a typo is otherwise silent), and how often rotation has fired.
45
46
  //
46
47
  // Key VALUES never leave the host — only the boolean fact that one exists.
47
- ctx.effect(() => ctx.webServer.register({
48
+ ctx.effect(() => getWebServer(ctx).register({
48
49
  kind: 'exact',
49
50
  path: STATUS_PATH,
50
51
  handler: async (req, res) => {
@@ -204,7 +205,7 @@ ctx.effect(() => ctx.webServer.register({
204
205
  // #209: usage report - per-key requests/cost over the last N days.
205
206
  // ?format=csv returns text/csv; ?days=N window (1..90, default 7).
206
207
 
207
- ctx.effect(() => ctx.webServer.register({
208
+ ctx.effect(() => getWebServer(ctx).register({
208
209
  kind: 'exact',
209
210
  path: HEALTH_PATH,
210
211
  handler: async (req, res) => {
@@ -1,3 +1,4 @@
1
+ import { getWebServer } from './guard.js';
1
2
  // lib/ops-telemetry.js — usage + snapshot operational routes (#312 split from routes-ops.js).
2
3
  import {
3
4
  json,
@@ -71,7 +72,7 @@ export function registerTelemetryRoutes(ctx, deps) {
71
72
  buildRuntime,
72
73
  } = deps;
73
74
 
74
- ctx.effect(() => ctx.webServer.register({
75
+ ctx.effect(() => getWebServer(ctx).register({
75
76
  kind: 'exact',
76
77
  path: USAGE_PATH,
77
78
  handler: (req, res) => {
@@ -116,7 +117,7 @@ ctx.effect(() => ctx.webServer.register({
116
117
  // Secret values never travel: only credential/env names. Token fields are
117
118
  // exported as empty strings; on import they keep existing values when empty.
118
119
 
119
- ctx.effect(() => ctx.webServer.register({
120
+ ctx.effect(() => getWebServer(ctx).register({
120
121
  kind: 'exact',
121
122
  path: SNAPSHOT_PATH,
122
123
  handler: async (req, res) => {
@@ -162,7 +163,7 @@ ctx.effect(() => ctx.webServer.register({
162
163
  }), 'dsh-key-rotation: snapshot route');
163
164
 
164
165
 
165
- ctx.effect(() => ctx.webServer.register({
166
+ ctx.effect(() => getWebServer(ctx).register({
166
167
  kind: 'exact',
167
168
  path: TRACE_PATH,
168
169
  handler: (req, res) => {
@@ -187,7 +188,7 @@ ctx.effect(() => ctx.webServer.register({
187
188
  }), 'dsh-key-rotation: trace route');
188
189
 
189
190
 
190
- ctx.effect(() => ctx.webServer.register({
191
+ ctx.effect(() => getWebServer(ctx).register({
191
192
  kind: 'exact',
192
193
  path: SIMULATE_PATH,
193
194
  handler: async (req, res) => {
@@ -243,7 +244,7 @@ ctx.effect(() => ctx.webServer.register({
243
244
  },
244
245
  }), 'dsh-key-rotation: simulate route');
245
246
 
246
- ctx.effect(() => ctx.webServer.register({
247
+ ctx.effect(() => getWebServer(ctx).register({
247
248
  kind: 'exact',
248
249
  path: DIAG_PATH,
249
250
  handler: async (req, res) => {
@@ -1,3 +1,4 @@
1
+ import { getWebServer } from './guard.js';
1
2
  // lib/ops-test.js — sandbox test + sandbox cache routes (#312 split from routes-ops.js).
2
3
  import {
3
4
  json,
@@ -29,7 +30,7 @@ export function registerTestRoutes(ctx, deps) {
29
30
  } = deps;
30
31
 
31
32
  // ── test route: dry-run a single key without rotation ──
32
- ctx.effect(() => ctx.webServer.register({
33
+ ctx.effect(() => getWebServer(ctx).register({
33
34
  kind: 'exact',
34
35
  path: TEST_PATH,
35
36
  handler: async (req, res) => {
@@ -105,7 +106,7 @@ ctx.effect(() => ctx.webServer.register({
105
106
  // straight through.
106
107
  // Read-only cache snapshot for clients (badge polling).
107
108
 
108
- ctx.effect(() => ctx.webServer.register({
109
+ ctx.effect(() => getWebServer(ctx).register({
109
110
  kind: 'exact',
110
111
  path: SANDBOX_CACHE_PATH,
111
112
  handler: (req, res) => {
@@ -125,7 +126,7 @@ ctx.effect(() => ctx.webServer.register({
125
126
  // cannot be same-origin, so a shared secret is the gate).
126
127
 
127
128
  // ── test matrix route: batch probe all configured keys (#490) ──
128
- ctx.effect(() => ctx.webServer.register({
129
+ ctx.effect(() => getWebServer(ctx).register({
129
130
  kind: 'exact',
130
131
  path: TEST_MATRIX_PATH,
131
132
  handler: async (req, res) => {
@@ -1,3 +1,4 @@
1
+ import { getWebServer } from './guard.js';
1
2
  import crypto from 'node:crypto';
2
3
  // lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js, #388 telegram secret-token & answerCallbackQuery).
3
4
  import {
@@ -36,7 +37,7 @@ export function registerWebhookActionRoute(ctx, deps) {
36
37
  circuitBreaker,
37
38
  } = deps;
38
39
 
39
- ctx.effect(() => ctx.webServer.register({
40
+ ctx.effect(() => getWebServer(ctx).register({
40
41
  kind: 'exact',
41
42
  path: WEBHOOK_ACTION_PATH,
42
43
  handler: async (req, res) => {
@@ -0,0 +1,99 @@
1
+ // lib/stream-watchdog.js — Inter-token stall & zombie stream detector (#569).
2
+ // Monitors streaming chunk intervals and TTFT to prevent infinite agent hangs.
3
+
4
+ export class StreamStallError extends Error {
5
+ constructor(message, elapsedMs, chunkCount) {
6
+ super(message || `Stream stalled: no tokens received for ${elapsedMs}ms`);
7
+ this.name = 'StreamStallError';
8
+ this.code = 'STREAM_STALLED';
9
+ this.elapsedMs = elapsedMs;
10
+ this.chunkCount = chunkCount;
11
+ this.isStall = true;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Wraps an async iterable stream with an inter-chunk stall watchdog.
17
+ *
18
+ * @param {AsyncIterable<any>} innerStream
19
+ * @param {object} [options]
20
+ * @param {number} [options.stallTimeoutMs=15000]
21
+ * @param {number} [options.stallInitialTimeoutMs=30000]
22
+ * @param {boolean} [options.stallDetectionEnabled=true]
23
+ * @param {AbortController} [options.abortController]
24
+ * @param {Function} [options.onStall]
25
+ * @returns {AsyncGenerator<any>}
26
+ */
27
+ export async function* withStreamWatchdog(innerStream, options = {}) {
28
+ const {
29
+ stallTimeoutMs = 15000,
30
+ stallInitialTimeoutMs = 30000,
31
+ stallDetectionEnabled = true,
32
+ abortController = null,
33
+ onStall = null,
34
+ } = options;
35
+
36
+ if (!stallDetectionEnabled || (!stallTimeoutMs && !stallInitialTimeoutMs)) {
37
+ yield* innerStream;
38
+ return;
39
+ }
40
+
41
+ const iterator = innerStream[Symbol.asyncIterator]();
42
+ let chunksCount = 0;
43
+
44
+ try {
45
+ while (true) {
46
+ const currentTimeout = chunksCount === 0
47
+ ? (stallInitialTimeoutMs || stallTimeoutMs || 30000)
48
+ : (stallTimeoutMs || 15000);
49
+
50
+ let timerId = null;
51
+
52
+ const timerPromise = new Promise((_, reject) => {
53
+ timerId = setTimeout(() => {
54
+ const err = new StreamStallError(
55
+ `Stream stalled: no tokens received for ${currentTimeout}ms (chunks: ${chunksCount})`,
56
+ currentTimeout,
57
+ chunksCount
58
+ );
59
+ if (abortController && typeof abortController.abort === 'function') {
60
+ try { abortController.abort(err); } catch {}
61
+ }
62
+ if (typeof onStall === 'function') {
63
+ try { onStall(err); } catch {}
64
+ }
65
+ reject(err);
66
+ }, currentTimeout);
67
+ });
68
+
69
+ // Suppress unhandled rejection if iterator.next() finishes first
70
+ timerPromise.catch(() => {});
71
+
72
+ let result;
73
+ try {
74
+ result = await Promise.race([
75
+ iterator.next(),
76
+ timerPromise,
77
+ ]);
78
+ } finally {
79
+ if (timerId !== null) {
80
+ clearTimeout(timerId);
81
+ timerId = null;
82
+ }
83
+ }
84
+
85
+ if (result.done) {
86
+ break;
87
+ }
88
+
89
+ chunksCount++;
90
+ yield result.value;
91
+ }
92
+ } finally {
93
+ if (typeof iterator.return === 'function') {
94
+ try {
95
+ await iterator.return();
96
+ } catch {}
97
+ }
98
+ }
99
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.83",
3
+ "version": "0.8.85",
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": [
@@ -63,9 +63,9 @@
63
63
  "node": ">=18"
64
64
  },
65
65
  "peerDependencies": {
66
- "@deepseek-ai/cordis": "^4.0.1",
67
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-alpha.1 <0.2.2-0",
68
- "@deepseek-ai/schemastery": "^3.18.4"
66
+ "@deepseek-ai/cordis": "^4.0.1 || >=4.0.6-alpha.1 <5.0.0-0",
67
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-alpha.1 <0.2.2-0 || >=0.2.2-alpha.1 <0.2.3-0",
68
+ "@deepseek-ai/schemastery": "^3.18.4 || >=3.18.6-alpha.1 <4.0.0-0"
69
69
  },
70
70
  "scripts": {
71
71
  "test": "node tools/test.mjs",