@goodandready/dsh-key-rotation 0.8.84 → 0.8.86

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.86 (2026-11-03)
4
+
5
+ ### Pre-emptive Token & Request Yield before 429 (Block 13 Release)
6
+ - **Pre-emptive Yield Module (Issue #571)**: Introduced `src/preemptive-yield.js` to evaluate active token and request headroom across provider keys using upstream `x-ratelimit-remaining-tokens`, `x-ratelimit-remaining-requests`, and reset window headers (`x-ratelimit-reset-tokens`).
7
+ - **Zero-Round-Trip Proactive Rotation**: Keys with low token balance (`< preemptiveSafetyMarginTokens`, default: 4000) or low request count (`<= preemptiveSafetyMarginRequests`, default: 2) yield execution priority to candidate keys with ample quota before any HTTP `429 Too Many Requests` or `insufficient_quota` failure occurs.
8
+ - **Graceful Degradation & Reset Recovery**: When upstream reset windows expire, capacity automatically restores to full priority. If all candidate keys in a pool are running on low quota, requests proceed using the key with highest remaining tokens or soonest reset time, avoiding false exhaustion.
9
+ - **Config & Schemastery Support**: Added `preemptiveRotationEnabled`, `preemptiveSafetyMarginTokens`, and `preemptiveSafetyMarginRequests` across Schemastery configuration, pool builders, and TypeScript types.
10
+
11
+ ## 0.8.85 (2026-11-03)
12
+
13
+ ### Inter-Token Stall & Zombie Stream Detector (Block 12 Release)
14
+ - **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.
15
+ - **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.
16
+ - **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.
17
+ - **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`.
18
+
19
+ ## 0.8.84 (2026-10-31)
20
+
21
+ ### DSH 0.2.2 Cordis Service Isolation Compatibility (Hotfix Release)
22
+ - **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.
23
+ - **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.
24
+
3
25
  ## 0.8.83 (2026-10-31)
4
26
 
5
27
  ### 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
@@ -137,6 +137,12 @@ const SettingsSchema = Schema.object({
137
137
  warnBelowHealthy: Schema.number().default(0),
138
138
  latencySloMs: Schema.number().default(0),
139
139
  showChatHeaderChip: Schema.boolean().default(true),
140
+ stallDetectionEnabled: Schema.boolean().default(true),
141
+ stallTimeoutMs: Schema.number().default(15000),
142
+ stallInitialTimeoutMs: Schema.number().default(30000),
143
+ preemptiveRotationEnabled: Schema.boolean().default(true),
144
+ preemptiveSafetyMarginTokens: Schema.number().default(4000),
145
+ preemptiveSafetyMarginRequests: Schema.number().default(2),
140
146
  providers: Schema.array(Schema.object({
141
147
  provider: Schema.string().required(),
142
148
  keys: Schema.array(Schema.string()).default([]),
@@ -166,6 +172,12 @@ const SettingsSchema = Schema.object({
166
172
  proactiveRateLimitGuard: Schema.boolean(),
167
173
  rpmLimit: Schema.number(),
168
174
  tpmLimit: Schema.number(),
175
+ stallDetectionEnabled: Schema.boolean(),
176
+ stallTimeoutMs: Schema.number(),
177
+ stallInitialTimeoutMs: Schema.number(),
178
+ preemptiveRotationEnabled: Schema.boolean(),
179
+ preemptiveSafetyMarginTokens: Schema.number(),
180
+ preemptiveSafetyMarginRequests: Schema.number(),
169
181
  })).default([...DEFAULT_PROVIDERS]),
170
182
  });
171
183
 
@@ -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')) {
@@ -41,6 +41,12 @@ export function buildPoolItem({
41
41
  maxQueueWaitMs = null,
42
42
  maxAttempts = null,
43
43
  maxDurationMs = null,
44
+ stallDetectionEnabled = true,
45
+ stallTimeoutMs = 15000,
46
+ stallInitialTimeoutMs = 30000,
47
+ preemptiveRotationEnabled = true,
48
+ preemptiveSafetyMarginTokens = 4000,
49
+ preemptiveSafetyMarginRequests = 2,
44
50
  }) {
45
51
  const seen = new Set();
46
52
  const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
@@ -151,6 +157,15 @@ const weightsMap = {};
151
157
  maxDurationMs: typeof maxDurationMs === 'number' && Number.isFinite(maxDurationMs) && maxDurationMs > 0
152
158
  ? Math.floor(maxDurationMs)
153
159
  : 0,
160
+ stallDetectionEnabled: stallDetectionEnabled ?? true,
161
+ stallTimeoutMs: typeof stallTimeoutMs === 'number' && Number.isFinite(stallTimeoutMs) && stallTimeoutMs > 0 ? Math.floor(stallTimeoutMs) : 15000,
162
+ stallInitialTimeoutMs: typeof stallInitialTimeoutMs === 'number' && Number.isFinite(stallInitialTimeoutMs) && stallInitialTimeoutMs > 0 ? Math.floor(stallInitialTimeoutMs) : 30000,
163
+ preemptiveRotationEnabled: preemptiveRotationEnabled ?? true,
164
+ preemptiveSafetyMarginTokens: typeof preemptiveSafetyMarginTokens === 'number' && Number.isFinite(preemptiveSafetyMarginTokens) && preemptiveSafetyMarginTokens >= 0 ? Math.floor(preemptiveSafetyMarginTokens) : 4000,
165
+ preemptiveSafetyMarginRequests: typeof preemptiveSafetyMarginRequests === 'number' && Number.isFinite(preemptiveSafetyMarginRequests) && preemptiveSafetyMarginRequests >= 0 ? Math.floor(preemptiveSafetyMarginRequests) : 2,
166
+ preemptiveRotationEnabled: preemptiveRotationEnabled ?? true,
167
+ preemptiveSafetyMarginTokens: typeof preemptiveSafetyMarginTokens === 'number' && Number.isFinite(preemptiveSafetyMarginTokens) && preemptiveSafetyMarginTokens >= 0 ? Math.floor(preemptiveSafetyMarginTokens) : 4000,
168
+ preemptiveSafetyMarginRequests: typeof preemptiveSafetyMarginRequests === 'number' && Number.isFinite(preemptiveSafetyMarginRequests) && preemptiveSafetyMarginRequests >= 0 ? Math.floor(preemptiveSafetyMarginRequests) : 2,
154
169
  };
155
170
  }
156
171
 
@@ -198,6 +213,15 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
198
213
  maxQueueWaitMs: p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
199
214
  maxAttempts: poolAttempts,
200
215
  maxDurationMs: poolDuration,
216
+ stallDetectionEnabled: p.stallDetectionEnabled ?? cfg.stallDetectionEnabled ?? true,
217
+ stallTimeoutMs: p.stallTimeoutMs ?? cfg.stallTimeoutMs ?? 15000,
218
+ stallInitialTimeoutMs: p.stallInitialTimeoutMs ?? cfg.stallInitialTimeoutMs ?? 30000,
219
+ preemptiveRotationEnabled: p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
220
+ preemptiveSafetyMarginTokens: p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
221
+ preemptiveSafetyMarginRequests: p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
222
+ preemptiveRotationEnabled: p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
223
+ preemptiveSafetyMarginTokens: p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
224
+ preemptiveSafetyMarginRequests: p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
201
225
  }, provRpm, provTpm, provConcurrency);
202
226
  if (pool) {
203
227
  for (const ref of pool.refs) {
@@ -230,6 +254,15 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
230
254
  maxQueueWaitMs: mp.maxQueueWaitMs ?? p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
231
255
  maxAttempts: typeof mp.maxAttempts === 'number' ? mp.maxAttempts : poolAttempts,
232
256
  maxDurationMs: typeof mp.maxDurationMs === 'number' ? mp.maxDurationMs : poolDuration,
257
+ stallDetectionEnabled: mp.stallDetectionEnabled ?? p.stallDetectionEnabled ?? cfg.stallDetectionEnabled ?? true,
258
+ stallTimeoutMs: mp.stallTimeoutMs ?? p.stallTimeoutMs ?? cfg.stallTimeoutMs ?? 15000,
259
+ stallInitialTimeoutMs: mp.stallInitialTimeoutMs ?? p.stallInitialTimeoutMs ?? cfg.stallInitialTimeoutMs ?? 30000,
260
+ preemptiveRotationEnabled: mp.preemptiveRotationEnabled ?? p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
261
+ preemptiveSafetyMarginTokens: mp.preemptiveSafetyMarginTokens ?? p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
262
+ preemptiveSafetyMarginRequests: mp.preemptiveSafetyMarginRequests ?? p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
263
+ preemptiveRotationEnabled: mp.preemptiveRotationEnabled ?? p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
264
+ preemptiveSafetyMarginTokens: mp.preemptiveSafetyMarginTokens ?? p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
265
+ preemptiveSafetyMarginRequests: mp.preemptiveSafetyMarginRequests ?? p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
233
266
  }, modelRpm, modelTpm, modelConcurrency);
234
267
  if (!mpool) continue;
235
268
  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)
@@ -0,0 +1,129 @@
1
+ // src/preemptive-yield.js — Pre-emptive Token & Request Yield before 429 (#571).
2
+ // Pure module: zero runtime dependencies, strictly < 600 lines.
3
+
4
+ export const DEFAULT_PREEMPTIVE_SAFETY_MARGIN_TOKENS = 4000;
5
+ export const DEFAULT_PREEMPTIVE_SAFETY_MARGIN_REQUESTS = 2;
6
+
7
+ /**
8
+ * Evaluates whether a key has low remaining quota and should yield to other candidates.
9
+ * @param {object} pool - Pool definition carrying state
10
+ * @param {string} ref - Key reference
11
+ * @param {object} [options]
12
+ * @param {number} [options.now] - Current timestamp in ms
13
+ * @param {number} [options.safetyMarginTokens]
14
+ * @param {number} [options.safetyMarginRequests]
15
+ * @param {boolean} [options.enabled]
16
+ * @returns {{ yielding: boolean, reason?: string, remainingTokens?: number, remainingRequests?: number, resetInMs?: number }}
17
+ */
18
+ export function isPreemptiveYielding(pool, ref, options = {}) {
19
+ const enabled = options.enabled ?? pool?.preemptiveRotationEnabled ?? true;
20
+ if (!enabled || !pool || !ref) return { yielding: false };
21
+
22
+ const limits = pool.state?.detectedLimits?.get?.(ref);
23
+ if (!limits) return { yielding: false };
24
+
25
+ const now = options.now ?? Date.now();
26
+ const recordedAt = limits.at || now;
27
+
28
+ // Window reset calculation
29
+ const resetMs = limits.tpmResetMs || limits.rpmResetMs || (typeof limits.resetTokens === 'number' ? limits.resetTokens * 1000 : (typeof limits.resetRequests === 'number' ? limits.resetRequests * 1000 : 0));
30
+
31
+ if (resetMs > 0 && now >= recordedAt + resetMs) {
32
+ // Window has already passed and refreshed upstream
33
+ return { yielding: false };
34
+ }
35
+
36
+ const remainingTokens = limits.remainingTokens ?? limits.tpmRemaining;
37
+ const remainingRequests = limits.remainingRequests ?? limits.rpmRemaining;
38
+
39
+ const marginTokens = options.safetyMarginTokens ?? pool.preemptiveSafetyMarginTokens ?? DEFAULT_PREEMPTIVE_SAFETY_MARGIN_TOKENS;
40
+ const marginRequests = options.safetyMarginRequests ?? pool.preemptiveSafetyMarginRequests ?? DEFAULT_PREEMPTIVE_SAFETY_MARGIN_REQUESTS;
41
+
42
+ const lowTokens = typeof remainingTokens === 'number' && remainingTokens < marginTokens;
43
+ const lowRequests = typeof remainingRequests === 'number' && remainingRequests <= marginRequests;
44
+
45
+ if (lowTokens || lowRequests) {
46
+ const resetInMs = resetMs > 0 ? Math.max(0, (recordedAt + resetMs) - now) : 0;
47
+ return {
48
+ yielding: true,
49
+ reason: lowTokens ? `LOW_REMAINING_TOKENS (${remainingTokens} < ${marginTokens})` : `LOW_REMAINING_REQUESTS (${remainingRequests} <= ${marginRequests})`,
50
+ remainingTokens,
51
+ remainingRequests,
52
+ resetInMs,
53
+ };
54
+ }
55
+
56
+ return { yielding: false, remainingTokens, remainingRequests };
57
+ }
58
+
59
+ /**
60
+ * Reorders candidate attempts list so that keys with ample token/request headroom
61
+ * are attempted first, while yielding keys yield priority.
62
+ * Gracefully degrades if all candidate keys are low on headroom.
63
+ * @param {string[]} attemptList
64
+ * @param {object} pool
65
+ * @param {object} [options]
66
+ * @returns {string[]}
67
+ */
68
+ export function applyPreemptiveYield(attemptList, pool, options = {}) {
69
+ if (!Array.isArray(attemptList) || attemptList.length <= 1 || !pool) {
70
+ return (attemptList ?? []).slice();
71
+ }
72
+
73
+ const enabled = options.enabled ?? pool.preemptiveRotationEnabled ?? true;
74
+ if (!enabled) return attemptList.slice();
75
+
76
+ const now = options.now ?? Date.now();
77
+ const ample = [];
78
+ const yielding = [];
79
+
80
+ for (const ref of attemptList) {
81
+ // Pinned keys stay first if explicitly configured and not overridden
82
+ if (ref === pool.pinnedRef && options.preservePinned !== false) {
83
+ ample.push({ ref, info: { yielding: false } });
84
+ continue;
85
+ }
86
+
87
+ const info = isPreemptiveYielding(pool, ref, { ...options, now });
88
+ if (info.yielding) {
89
+ yielding.push({ ref, info });
90
+ if (options.trace && typeof options.trace.addStep === 'function') {
91
+ options.trace.addStep(ref, 'DEPRIORITIZED', 'PREEMPTIVE_YIELD', info.reason);
92
+ }
93
+ if (options.pushEvent && typeof options.pushEvent === 'function') {
94
+ options.pushEvent(pool, ref, 'PREEMPTIVE_YIELD', info);
95
+ }
96
+ } else {
97
+ ample.push({ ref, info });
98
+ }
99
+ }
100
+
101
+ if (yielding.length === 0) {
102
+ return attemptList.slice();
103
+ }
104
+
105
+ // Record yield counter in pool state
106
+ if (pool.state) {
107
+ pool.state.preemptiveYields = (pool.state.preemptiveYields ?? 0) + yielding.length;
108
+ }
109
+
110
+ // Sort yielding keys among themselves: highest remaining tokens first, then soonest reset
111
+ yielding.sort((a, b) => {
112
+ const ta = a.info.remainingTokens ?? -1;
113
+ const tb = b.info.remainingTokens ?? -1;
114
+ if (ta !== tb) return tb - ta; // Descending tokens
115
+ const ra = a.info.resetInMs ?? Infinity;
116
+ const rb = b.info.resetInMs ?? Infinity;
117
+ return ra - rb; // Ascending reset duration (soonest reset first)
118
+ });
119
+
120
+ if (ample.length === 0) {
121
+ // Graceful degradation: all keys are yielding; pick the best yielding key first
122
+ if (options.logWarn && typeof options.logWarn === 'function') {
123
+ options.logWarn(`[dsh-key-rotation] ${pool.provider ?? '-'}: all candidate keys are yielding (low quota) — proceeding with highest remaining headroom`);
124
+ }
125
+ return yielding.map(y => y.ref);
126
+ }
127
+
128
+ return ample.map(a => a.ref).concat(yielding.map(y => y.ref));
129
+ }
package/lib/resolver.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { bucketAllow, bucketRetryMs, tpmAllow, tpmRetryMs } from './bucket.js';
12
12
  import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked, isKeyBroken, costForDay, costForWeek, budgetVerdict, getSessionSticky, setSessionSticky } from './pool.js';
13
13
  import { decryptSecret } from './crypto-storage.js';
14
+ import { applyPreemptiveYield } from './preemptive-yield.js';
14
15
  import { resolveRefPool, isQuotaManagedRequest, providerOfBase, basePoolForBase } from './pool-index.js';
15
16
  import {
16
17
  isModelQuotaAvailable,
@@ -132,6 +133,8 @@ export function createResolver({
132
133
  lastUsed: pool.state.lastUsed,
133
134
  });
134
135
  }
136
+ // Issue #571: Pre-emptive Token & Request Yield before 429
137
+ list = applyPreemptiveYield(list, pool, { now: Date.now() });
135
138
  // Fail closed: when this request is managed by a model pool that has local
136
139
  // token budgets, an exhausted candidate may not be dispatched and the
137
140
  // original credential must never be used as a bypass. Decided once, before
package/lib/rotate.js CHANGED
@@ -26,6 +26,8 @@ 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';
30
+ import { applyPreemptiveYield } from './preemptive-yield.js';
29
31
  import { penalizeKey } from './rotate-penalty.js';
30
32
  import { recordPromptCache } from './prompt-cache.js';
31
33
  import { recordRequestOutcome, recordFailureCategory } from './ops-analytics.js';
@@ -202,6 +204,14 @@ export function createRotate(deps) {
202
204
  }
203
205
  }
204
206
 
207
+ // Issue #571: Pre-emptive Token & Request Yield before 429
208
+ attemptList = applyPreemptiveYield(attemptList, pool, {
209
+ now: wallNow,
210
+ trace,
211
+ pushEvent,
212
+ logWarn,
213
+ });
214
+
205
215
  const penalizeRef = (targetRef, errCode, errMsg, rawHeaders) => {
206
216
  penalizeKey(targetRef, errCode, errMsg, rawHeaders, {
207
217
  pool,
@@ -310,8 +320,33 @@ export function createRotate(deps) {
310
320
  }
311
321
 
312
322
  const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
323
+ let stallAbort = null;
324
+ if (typeof AbortController !== 'undefined') {
325
+ stallAbort = new AbortController();
326
+ if (budget?.signal) {
327
+ if (budget.signal.aborted) {
328
+ try { stallAbort.abort(budget.signal.reason); } catch {}
329
+ } else {
330
+ budget.signal.addEventListener('abort', () => {
331
+ try { stallAbort?.abort(budget.signal.reason); } catch {}
332
+ }, { once: true });
333
+ }
334
+ }
335
+ }
336
+ const guardedStream = withStreamWatchdog(
337
+ preserveDispatchContext(inner, dispatchStorage, reqStore),
338
+ {
339
+ stallDetectionEnabled: pool.stallDetectionEnabled ?? runtime0.stallDetectionEnabled ?? true,
340
+ stallTimeoutMs: pool.stallTimeoutMs ?? runtime0.stallTimeoutMs ?? 15000,
341
+ stallInitialTimeoutMs: pool.stallInitialTimeoutMs ?? runtime0.stallInitialTimeoutMs ?? 30000,
342
+ abortController: stallAbort,
343
+ onStall: (err) => {
344
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(_pickedRef ?? '?')} stalled (${err.message})`);
345
+ },
346
+ }
347
+ );
313
348
  try {
314
- for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
349
+ for await (const chunk of guardedStream) {
315
350
  if (!reqStore.concurrencyRef && typeof concurrencyTracker?.acquire === 'function') {
316
351
  const fallbackRef = reqStore.pickedRef ?? _pickedRef;
317
352
  if (fallbackRef) {
@@ -434,33 +469,36 @@ export function createRotate(deps) {
434
469
  } catch (e) {
435
470
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
436
471
  const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed ?? attemptList[attempt];
472
+ const isStall = e instanceof StreamStallError || e?.code === 'STREAM_STALLED' || e?.isStall;
437
473
  if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
474
+ const failCode = isStall ? 'STREAM_STALLED' : (e?.code ?? 'TRANSPORT');
475
+ const catCode = isStall ? 'TIMEOUT' : failCode;
438
476
  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());
477
+ penalizeRef(activeRef, catCode, String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
478
+ recordFailureCategory(pool, catCode, String(e?.message ?? e), activeRef, now());
441
479
  pool.state.switches = (pool.state.switches ?? 0) + 1;
442
- pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
480
+ pool.state.lastReason = failCode;
443
481
  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`);
482
+ lastFailure = finishError(failCode, String(e?.message ?? e));
483
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw before first token ${failCode} - failover to next key`);
446
484
  if (switchNotify && activeRef) {
447
485
  notifySwitch(runtime0, pool, {
448
486
  provider: options.provider,
449
487
  from: activeRef,
450
- code: String(e?.code ?? 'TRANSPORT'),
488
+ code: failCode,
451
489
  at: pool.state.lastSwitchAt,
452
490
  });
453
491
  }
454
492
  continue; // Failover to next key!
455
493
  }
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));
494
+ if (yielded && (isStall || isSwitchableError(e, effectiveSwitchCodes))) {
495
+ penalizeRef(activeRef, isStall ? 'TIMEOUT' : (e?.code ?? 'TRANSPORT'), String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
458
496
  }
459
497
  if (!yielded) {
460
498
  for (const prev of preTokenBuffer) yield prev;
461
499
  preTokenBuffer.length = 0;
462
500
  }
463
- yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
501
+ yield finishError(isStall ? 'STREAM_STALLED' : (e?.code ?? 'TRANSPORT'), String(e?.message ?? e));
464
502
  return;
465
503
  }
466
504
 
@@ -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.84",
3
+ "version": "0.8.86",
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": [