@yeaft/webchat-agent 0.1.1069 → 0.1.1071

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.1069",
3
+ "version": "0.1.1071",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/config.js CHANGED
@@ -65,11 +65,14 @@ const DEFAULTS = {
65
65
  // • baseDelayMs / maxDelayMs: exponential backoff bounds used when
66
66
  // the server didn't send a Retry-After header.
67
67
  // • jitterRatio: ± random fraction applied to backoff; 0 disables.
68
+ // • streamIdleTimeoutMs: per-SSE-chunk silence budget. 0 disables the
69
+ // stalled-stream guard; every received chunk refreshes the budget.
68
70
  llmRetry: {
69
71
  maxRetries: 3,
70
72
  baseDelayMs: 1_000,
71
73
  maxDelayMs: 30_000,
72
74
  jitterRatio: 0.25,
75
+ streamIdleTimeoutMs: 110_000,
73
76
  },
74
77
  };
75
78
 
@@ -85,7 +88,7 @@ const DEFAULTS = {
85
88
  *
86
89
  * @param {object | null | undefined} fileConfig
87
90
  * @param {object | null | undefined} overrides
88
- * @returns {{ maxRetries: number, baseDelayMs: number, maxDelayMs: number, jitterRatio: number }}
91
+ * @returns {{ maxRetries: number, baseDelayMs: number, maxDelayMs: number, jitterRatio: number, streamIdleTimeoutMs: number }}
89
92
  */
90
93
  export function normalizeLlmRetry(fileConfig, overrides) {
91
94
  const base = DEFAULTS.llmRetry;
@@ -104,6 +107,9 @@ export function normalizeLlmRetry(fileConfig, overrides) {
104
107
  if (Number.isFinite(src.jitterRatio) && src.jitterRatio >= 0) {
105
108
  out.jitterRatio = Math.min(1, src.jitterRatio);
106
109
  }
110
+ if (Number.isFinite(src.streamIdleTimeoutMs) && src.streamIdleTimeoutMs >= 0) {
111
+ out.streamIdleTimeoutMs = Math.min(600_000, Math.floor(src.streamIdleTimeoutMs));
112
+ }
107
113
  };
108
114
  apply(fileConfig);
109
115
  apply(overrides);
@@ -100,6 +100,15 @@ export class LLMServerError extends Error {
100
100
  }
101
101
  }
102
102
 
103
+ /** Streaming response went idle after the server accepted the request. Retryable. */
104
+ export class LLMStreamIdleTimeoutError extends LLMServerError {
105
+ constructor(message, idleMs) {
106
+ super(message, 0);
107
+ this.name = 'LLMStreamIdleTimeoutError';
108
+ this.idleMs = idleMs;
109
+ }
110
+ }
111
+
103
112
  /** Abort error — signal was aborted. */
104
113
  export class LLMAbortError extends Error {
105
114
  constructor() {
@@ -108,6 +117,57 @@ export class LLMAbortError extends Error {
108
117
  }
109
118
  }
110
119
 
120
+ /**
121
+ * Read one chunk from a Fetch stream with a silence timeout. This is not a
122
+ * total request deadline: every received chunk gets a fresh budget. A caller
123
+ * abort still wins and is classified as LLMAbortError; only an idle stream is
124
+ * converted into a retryable server error.
125
+ *
126
+ * @param {ReadableStreamDefaultReader<Uint8Array>} reader
127
+ * @param {{ signal?: AbortSignal, idleMs?: number, providerLabel?: string }} [opts]
128
+ * @returns {Promise<ReadableStreamReadResult<Uint8Array>>}
129
+ */
130
+ export async function readStreamChunkWithIdleTimeout(reader, opts = {}) {
131
+ const idleMs = Number.isFinite(opts.idleMs) ? Math.max(0, Math.floor(opts.idleMs)) : 0;
132
+ if (idleMs <= 0) return reader.read();
133
+ if (opts.signal?.aborted) throw new LLMAbortError();
134
+
135
+ let timer = null;
136
+ let abortListener = null;
137
+ let settled = false;
138
+ const clear = () => {
139
+ settled = true;
140
+ if (timer) clearTimeout(timer);
141
+ if (opts.signal && abortListener) opts.signal.removeEventListener('abort', abortListener);
142
+ };
143
+ try {
144
+ return await Promise.race([
145
+ reader.read().finally(clear),
146
+ new Promise((_, reject) => {
147
+ timer = setTimeout(() => {
148
+ if (settled) return;
149
+ const err = new LLMStreamIdleTimeoutError(
150
+ `${opts.providerLabel || 'LLM'} stream idle timeout after ${idleMs}ms`,
151
+ idleMs,
152
+ );
153
+ try { Promise.resolve(reader.cancel(err)).catch(() => {}); } catch { /* best-effort: reject below */ }
154
+ reject(err);
155
+ }, idleMs);
156
+ if (timer && typeof timer.unref === 'function') timer.unref();
157
+ if (opts.signal) {
158
+ abortListener = () => {
159
+ if (settled) return;
160
+ reject(new LLMAbortError());
161
+ };
162
+ opts.signal.addEventListener('abort', abortListener, { once: true });
163
+ }
164
+ }),
165
+ ]);
166
+ } finally {
167
+ clear();
168
+ }
169
+ }
170
+
111
171
  // ─── Retry helpers ─────────────────────────────────────────────
112
172
 
113
173
  /**
@@ -319,7 +379,7 @@ export async function createLLMAdapter(config) {
319
379
  // ─── New path: config.json with providers ─────────────
320
380
  if (config.providers && config.providers.length > 0) {
321
381
  const { AdapterRouter } = await import('./router.js');
322
- return new AdapterRouter({ providers: config.providers });
382
+ return new AdapterRouter({ providers: config.providers, llmRetry: config.llmRetry });
323
383
  }
324
384
 
325
385
  // ─── Legacy path: single adapter from env vars ────────
@@ -333,6 +393,7 @@ export async function createLLMAdapter(config) {
333
393
  return new AnthropicAdapter({
334
394
  apiKey: config.apiKey,
335
395
  baseUrl: config.baseUrl || undefined, // AnthropicAdapter has its own default
396
+ streamIdleTimeoutMs: config.llmRetry?.streamIdleTimeoutMs,
336
397
  });
337
398
  }
338
399
 
@@ -15,6 +15,7 @@ import {
15
15
  LLMContextError,
16
16
  LLMServerError,
17
17
  LLMAbortError,
18
+ readStreamChunkWithIdleTimeout,
18
19
  redactRawRequest,
19
20
  safeHeaders,
20
21
  } from './adapter.js';
@@ -65,15 +66,17 @@ export class AnthropicAdapter extends LLMAdapter {
65
66
  #apiKey;
66
67
  #baseUrl;
67
68
  #authHeaderMode;
69
+ #streamIdleTimeoutMs;
68
70
 
69
71
  /**
70
- * @param {{ apiKey: string, baseUrl?: string, authHeaderMode?: 'x-api-key'|'bearer' }} config
72
+ * @param {{ apiKey: string, baseUrl?: string, authHeaderMode?: 'x-api-key'|'bearer', streamIdleTimeoutMs?: number }} config
71
73
  */
72
- constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, authHeaderMode = 'x-api-key' }) {
73
- super({ apiKey, baseUrl });
74
+ constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, authHeaderMode = 'x-api-key', streamIdleTimeoutMs = 0 }) {
75
+ super({ apiKey, baseUrl, streamIdleTimeoutMs });
74
76
  this.#apiKey = apiKey;
75
77
  this.#baseUrl = baseUrl;
76
78
  this.#authHeaderMode = authHeaderMode === 'bearer' ? 'bearer' : 'x-api-key';
79
+ this.#streamIdleTimeoutMs = Number.isFinite(streamIdleTimeoutMs) ? Math.max(0, Math.floor(streamIdleTimeoutMs)) : 0;
77
80
  }
78
81
 
79
82
  #headers() {
@@ -291,7 +294,11 @@ export class AnthropicAdapter extends LLMAdapter {
291
294
 
292
295
  try {
293
296
  while (true) {
294
- const { done, value } = await reader.read();
297
+ const { done, value } = await readStreamChunkWithIdleTimeout(reader, {
298
+ signal,
299
+ idleMs: this.#streamIdleTimeoutMs,
300
+ providerLabel: 'Anthropic',
301
+ });
295
302
  if (done) break;
296
303
 
297
304
  const chunkText = decoder.decode(value, { stream: true });
@@ -436,6 +443,8 @@ export class AnthropicAdapter extends LLMAdapter {
436
443
  }
437
444
  }
438
445
  }
446
+ } catch (err) {
447
+ throw classifyFetchError(err, { providerLabel: 'Anthropic' });
439
448
  } finally {
440
449
  reader.releaseLock();
441
450
  // Emit raw exchange after stream completes (or errors). Body is the
@@ -34,6 +34,7 @@ import {
34
34
  LLMAbortError,
35
35
  classifyFetchError,
36
36
  retryAfterFromResponse,
37
+ readStreamChunkWithIdleTimeout,
37
38
  redactRawRequest,
38
39
  safeHeaders,
39
40
  } from './adapter.js';
@@ -67,14 +68,16 @@ function effortForResponses(effort) {
67
68
  export class OpenAIResponsesAdapter extends LLMAdapter {
68
69
  #apiKey;
69
70
  #baseUrl;
71
+ #streamIdleTimeoutMs;
70
72
 
71
73
  /**
72
- * @param {{ apiKey: string, baseUrl?: string }} config
74
+ * @param {{ apiKey: string, baseUrl?: string, streamIdleTimeoutMs?: number }} config
73
75
  */
74
- constructor({ apiKey, baseUrl = DEFAULT_BASE_URL }) {
75
- super({ apiKey, baseUrl });
76
+ constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, streamIdleTimeoutMs = 0 }) {
77
+ super({ apiKey, baseUrl, streamIdleTimeoutMs });
76
78
  this.#apiKey = apiKey;
77
79
  this.#baseUrl = (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
80
+ this.#streamIdleTimeoutMs = Number.isFinite(streamIdleTimeoutMs) ? Math.max(0, Math.floor(streamIdleTimeoutMs)) : 0;
78
81
  }
79
82
 
80
83
  /** Expose baseUrl for testing. */
@@ -332,7 +335,11 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
332
335
 
333
336
  try {
334
337
  while (true) {
335
- const { done, value } = await reader.read();
338
+ const { done, value } = await readStreamChunkWithIdleTimeout(reader, {
339
+ signal,
340
+ idleMs: this.#streamIdleTimeoutMs,
341
+ providerLabel: 'OpenAI',
342
+ });
336
343
  if (done) break;
337
344
  const chunkText = decoder.decode(value, { stream: true });
338
345
  buffer += chunkText;
@@ -452,7 +459,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
452
459
  }
453
460
  } catch (err) {
454
461
  if (err?.name === 'AbortError') throw new LLMAbortError();
455
- throw err;
462
+ throw classifyFetchError(err, { providerLabel: 'OpenAI' });
456
463
  } finally {
457
464
  try { reader.releaseLock(); } catch { /* noop */ }
458
465
  // Emit raw exchange after stream completes (or errors). Body is the
@@ -241,12 +241,18 @@ export class AdapterRouter extends LLMAdapter {
241
241
  /** @type {object[]} raw providers array */
242
242
  #providers;
243
243
 
244
+ /** @type {number} per-SSE-chunk silence budget; <= 0 disables the guard */
245
+ #streamIdleTimeoutMs;
246
+
244
247
  /**
245
- * @param {{ providers: object[], config?: object }} params
248
+ * @param {{ providers: object[], llmRetry?: { streamIdleTimeoutMs?: number } }} params
246
249
  * @param {object[]} params.providers — Array of { name, baseUrl, apiKey, protocol?, models[] }
247
250
  */
248
- constructor({ providers }) {
251
+ constructor({ providers, llmRetry = {} }) {
249
252
  super();
253
+ this.#streamIdleTimeoutMs = Number.isFinite(llmRetry.streamIdleTimeoutMs)
254
+ ? Math.max(0, Math.floor(llmRetry.streamIdleTimeoutMs))
255
+ : 0;
250
256
  this.#providers = [];
251
257
  this.#modelToProvider = new Map();
252
258
  this.#adapterCache = new Map();
@@ -466,6 +472,7 @@ export class AdapterRouter extends LLMAdapter {
466
472
  apiKey,
467
473
  baseUrl: provider.baseUrl,
468
474
  authHeaderMode: anthropicAuthHeaderMode,
475
+ streamIdleTimeoutMs: this.#streamIdleTimeoutMs,
469
476
  });
470
477
  } else if (protocol === 'openai-responses') {
471
478
  // OpenAI Responses API (/v1/responses) — canonical OpenAI-compatible path.
@@ -473,6 +480,7 @@ export class AdapterRouter extends LLMAdapter {
473
480
  adapter = new OpenAIResponsesAdapter({
474
481
  apiKey,
475
482
  baseUrl: provider.baseUrl,
483
+ streamIdleTimeoutMs: this.#streamIdleTimeoutMs,
476
484
  });
477
485
  } else {
478
486
  throw new Error(