@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 +14 -0
- package/lib/error-taxonomy.js +1 -1
- package/lib/index.js +16 -5
- package/lib/lifecycle.js +2 -2
- package/lib/ops-analytics.js +1 -1
- package/lib/plugin-updater.js +4 -1
- package/lib/pool-builder.js +12 -0
- package/lib/pool.js +1 -0
- package/lib/rotate.js +39 -10
- package/lib/routes/balance.js +2 -1
- package/lib/routes/config.js +3 -2
- package/lib/routes/guard.js +23 -0
- package/lib/routes/keys.js +8 -7
- package/lib/routes/metrics.js +2 -1
- package/lib/routes/quick-action.js +2 -1
- package/lib/routes/status.js +3 -2
- package/lib/routes/telemetry.js +6 -5
- package/lib/routes/testing.js +4 -3
- package/lib/routes/webhook.js +2 -1
- package/lib/stream-watchdog.js +99 -0
- package/package.json +4 -4
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)
|
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
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|
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/plugin-updater.js
CHANGED
|
@@ -314,7 +314,10 @@ function json(response, statusCode, value) {
|
|
|
314
314
|
|
|
315
315
|
export function registerPluginUpdater(ctx, options) {
|
|
316
316
|
let installing = false
|
|
317
|
-
|
|
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) => {
|
package/lib/pool-builder.js
CHANGED
|
@@ -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
|
|
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,
|
|
440
|
-
recordFailureCategory(pool,
|
|
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 =
|
|
471
|
+
pool.state.lastReason = failCode;
|
|
443
472
|
pool.state.lastSwitchAt = now();
|
|
444
|
-
lastFailure = finishError(
|
|
445
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw before first token ${
|
|
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:
|
|
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
|
|
package/lib/routes/balance.js
CHANGED
|
@@ -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.
|
|
22
|
+
ctx.effect(() => getWebServer(ctx).register({
|
|
22
23
|
kind: 'exact',
|
|
23
24
|
path: BALANCE_PATH,
|
|
24
25
|
handler: async (req, res) => {
|
package/lib/routes/config.js
CHANGED
|
@@ -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.
|
|
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.
|
|
452
|
+
ctx.effect(() => getWebServer(ctx).register({
|
|
452
453
|
kind: 'exact',
|
|
453
454
|
path: CONFIG_IMPORT_PATH,
|
|
454
455
|
handler: handleImport,
|
package/lib/routes/guard.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/routes/keys.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
532
|
+
ctx.effect(() => getWebServer(ctx).register({
|
|
532
533
|
kind: 'exact',
|
|
533
534
|
path: PRESET_LIMITS_PATH,
|
|
534
535
|
handler: async (req, res) => {
|
package/lib/routes/metrics.js
CHANGED
|
@@ -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.
|
|
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.
|
|
13
|
+
ctx.effect(() => getWebServer(ctx).register({
|
|
13
14
|
kind: 'exact',
|
|
14
15
|
path: QUICK_ACTION_PATH,
|
|
15
16
|
handler: async (req, res) => {
|
package/lib/routes/status.js
CHANGED
|
@@ -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.
|
|
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.
|
|
208
|
+
ctx.effect(() => getWebServer(ctx).register({
|
|
208
209
|
kind: 'exact',
|
|
209
210
|
path: HEALTH_PATH,
|
|
210
211
|
handler: async (req, res) => {
|
package/lib/routes/telemetry.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
247
|
+
ctx.effect(() => getWebServer(ctx).register({
|
|
247
248
|
kind: 'exact',
|
|
248
249
|
path: DIAG_PATH,
|
|
249
250
|
handler: async (req, res) => {
|
package/lib/routes/testing.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
129
|
+
ctx.effect(() => getWebServer(ctx).register({
|
|
129
130
|
kind: 'exact',
|
|
130
131
|
path: TEST_MATRIX_PATH,
|
|
131
132
|
handler: async (req, res) => {
|
package/lib/routes/webhook.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|