@yeaft/webchat-agent 0.1.1069 → 0.1.1070
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 +1 -1
- package/yeaft/config.js +7 -1
- package/yeaft/llm/adapter.js +62 -1
- package/yeaft/llm/anthropic.js +13 -4
- package/yeaft/llm/openai-responses.js +12 -5
- package/yeaft/llm/router.js +10 -2
package/package.json
CHANGED
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);
|
package/yeaft/llm/adapter.js
CHANGED
|
@@ -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
|
|
package/yeaft/llm/anthropic.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/yeaft/llm/router.js
CHANGED
|
@@ -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[],
|
|
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(
|