@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 +22 -0
- package/lib/error-taxonomy.js +1 -1
- package/lib/index.js +12 -0
- package/lib/ops-analytics.js +1 -1
- package/lib/pool-builder.js +33 -0
- package/lib/pool.js +1 -0
- package/lib/preemptive-yield.js +129 -0
- package/lib/resolver.js +3 -0
- package/lib/rotate.js +48 -10
- package/lib/stream-watchdog.js +99 -0
- package/package.json +1 -1
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)
|
package/lib/error-taxonomy.js
CHANGED
|
@@ -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
|
|
package/lib/ops-analytics.js
CHANGED
|
@@ -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')) {
|
package/lib/pool-builder.js
CHANGED
|
@@ -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
|
|
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,
|
|
440
|
-
recordFailureCategory(pool,
|
|
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 =
|
|
480
|
+
pool.state.lastReason = failCode;
|
|
443
481
|
pool.state.lastSwitchAt = now();
|
|
444
|
-
lastFailure = finishError(
|
|
445
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw before first token ${
|
|
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:
|
|
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.
|
|
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": [
|