@yeaft/webchat-agent 1.0.17 → 1.0.18
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/engine.js +127 -96
- package/yeaft/llm/adapter.js +7 -3
- package/yeaft/llm/anthropic.js +16 -4
- package/yeaft/llm/openai-responses.js +22 -12
package/package.json
CHANGED
package/yeaft/engine.js
CHANGED
|
@@ -2294,18 +2294,139 @@ export class Engine {
|
|
|
2294
2294
|
stopReason = event.stopReason;
|
|
2295
2295
|
yield event;
|
|
2296
2296
|
break;
|
|
2297
|
-
case 'error':
|
|
2298
|
-
|
|
2299
|
-
|
|
2297
|
+
case 'error': {
|
|
2298
|
+
const adapterError = event.error instanceof Error
|
|
2299
|
+
? event.error
|
|
2300
|
+
: new Error(String(event.error?.message || event.error || 'LLM stream error'));
|
|
2301
|
+
adapterError.retryable = Boolean(event.retryable);
|
|
2302
|
+
if (event.retryable) {
|
|
2303
|
+
if (adapterError instanceof LLMRateLimitError || adapterError instanceof LLMServerError) {
|
|
2304
|
+
throw adapterError;
|
|
2305
|
+
}
|
|
2306
|
+
throw new LLMServerError(adapterError.message, adapterError.statusCode ?? 0);
|
|
2307
|
+
}
|
|
2308
|
+
if (adapterError instanceof LLMRateLimitError || adapterError instanceof LLMServerError) {
|
|
2309
|
+
const nonRetryableError = new Error(adapterError.message);
|
|
2310
|
+
nonRetryableError.name = adapterError.name || 'LLMStreamError';
|
|
2311
|
+
nonRetryableError.code = adapterError.code;
|
|
2312
|
+
nonRetryableError.statusCode = adapterError.statusCode;
|
|
2313
|
+
nonRetryableError.retryable = false;
|
|
2314
|
+
throw nonRetryableError;
|
|
2315
|
+
}
|
|
2316
|
+
throw adapterError;
|
|
2317
|
+
}
|
|
2300
2318
|
}
|
|
2301
2319
|
}
|
|
2302
2320
|
// Stream completed without throwing — reset the retry counter so
|
|
2303
|
-
// the next turn starts with a clean budget.
|
|
2304
|
-
//
|
|
2305
|
-
// throw), since those are policy-specific and not transport-level.
|
|
2321
|
+
// the next turn starts with a clean budget. In-band adapter errors
|
|
2322
|
+
// are converted to throws above so they share the real error path.
|
|
2306
2323
|
consecutiveRetryableErrors = 0;
|
|
2307
2324
|
} catch (err) {
|
|
2308
2325
|
const latencyMs = Date.now() - startTime;
|
|
2326
|
+
|
|
2327
|
+
const endAttemptTrace = (attemptStopReason) => {
|
|
2328
|
+
this.#trace.endTurn(turnId, {
|
|
2329
|
+
model: currentModel,
|
|
2330
|
+
inputTokens: totalUsage.inputTokens,
|
|
2331
|
+
outputTokens: totalUsage.outputTokens,
|
|
2332
|
+
cacheReadTokens: totalUsage.cacheReadTokens,
|
|
2333
|
+
cacheWriteTokens: totalUsage.cacheWriteTokens,
|
|
2334
|
+
stopReason: attemptStopReason,
|
|
2335
|
+
latencyMs,
|
|
2336
|
+
responseText,
|
|
2337
|
+
systemPrompt,
|
|
2338
|
+
messages: conversationMessages.map(mapDebugMessage),
|
|
2339
|
+
toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
|
|
2340
|
+
usage: {
|
|
2341
|
+
inputTokens: totalUsage.inputTokens || 0,
|
|
2342
|
+
outputTokens: totalUsage.outputTokens || 0,
|
|
2343
|
+
cacheReadTokens: totalUsage.cacheReadTokens || 0,
|
|
2344
|
+
cacheWriteTokens: totalUsage.cacheWriteTokens || 0,
|
|
2345
|
+
totalInputTokens: (totalUsage.inputTokens || 0) + (totalUsage.cacheInputDeltaTokens || 0),
|
|
2346
|
+
totalTokens: (totalUsage.inputTokens || 0) + (totalUsage.cacheInputDeltaTokens || 0) + (totalUsage.outputTokens || 0),
|
|
2347
|
+
},
|
|
2348
|
+
ttfbMs,
|
|
2349
|
+
rawRequest,
|
|
2350
|
+
rawResponse,
|
|
2351
|
+
});
|
|
2352
|
+
};
|
|
2353
|
+
|
|
2354
|
+
// Abort/retry/fallback are not final assistant responses. Handle them
|
|
2355
|
+
// before writing debug loop rows; otherwise transient DeepSeek stream
|
|
2356
|
+
// cuts or user stops show up as bogus `Error: Request aborted` replies.
|
|
2357
|
+
const earlyIsAbort = err instanceof LLMAbortError
|
|
2358
|
+
|| err?.name === 'AbortError'
|
|
2359
|
+
|| err?.name === 'LLMAbortError'
|
|
2360
|
+
|| (signal?.aborted && /abort/i.test(err?.message || ''));
|
|
2361
|
+
if (earlyIsAbort || signal?.aborted) {
|
|
2362
|
+
endAttemptTrace('aborted');
|
|
2363
|
+
yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
|
|
2364
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
|
|
2365
|
+
break;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
if (err instanceof LLMContextError && this.#conversationStore) {
|
|
2369
|
+
const retryConsolidated = await this.#maybeConsolidate();
|
|
2370
|
+
if (retryConsolidated && retryConsolidated.archivedCount > 0) {
|
|
2371
|
+
endAttemptTrace('context_overflow_retry');
|
|
2372
|
+
yield { type: 'consolidate', archivedCount: retryConsolidated.archivedCount, extractedCount: retryConsolidated.extractedCount };
|
|
2373
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'context_overflow_retry', threadId };
|
|
2374
|
+
continue;
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
const earlyIsRateLimit = err instanceof LLMRateLimitError;
|
|
2379
|
+
const earlyIsTransient = err instanceof LLMServerError;
|
|
2380
|
+
if (earlyIsRateLimit || earlyIsTransient) {
|
|
2381
|
+
if (consecutiveRetryableErrors < retryPolicy.maxRetries) {
|
|
2382
|
+
consecutiveRetryableErrors += 1;
|
|
2383
|
+
let delayMs;
|
|
2384
|
+
let reason;
|
|
2385
|
+
if (earlyIsRateLimit && Number.isFinite(err.retryAfterMs) && err.retryAfterMs > 0) {
|
|
2386
|
+
delayMs = Math.min(retryPolicy.maxDelayMs, err.retryAfterMs);
|
|
2387
|
+
reason = 'rate_limit_retry_after';
|
|
2388
|
+
} else if (earlyIsRateLimit) {
|
|
2389
|
+
delayMs = computeBackoffDelay(retryPolicy, consecutiveRetryableErrors);
|
|
2390
|
+
reason = 'rate_limit_backoff';
|
|
2391
|
+
} else {
|
|
2392
|
+
delayMs = computeBackoffDelay(retryPolicy, consecutiveRetryableErrors - 1);
|
|
2393
|
+
reason = err instanceof LLMStreamIdleTimeoutError
|
|
2394
|
+
? 'stream_idle_timeout'
|
|
2395
|
+
: 'transient_backoff';
|
|
2396
|
+
}
|
|
2397
|
+
endAttemptTrace('llm_retry');
|
|
2398
|
+
yield {
|
|
2399
|
+
type: 'llm_retry',
|
|
2400
|
+
attempt: consecutiveRetryableErrors,
|
|
2401
|
+
maxRetries: retryPolicy.maxRetries,
|
|
2402
|
+
delayMs,
|
|
2403
|
+
reason,
|
|
2404
|
+
errorName: err.name,
|
|
2405
|
+
statusCode: err.statusCode ?? null,
|
|
2406
|
+
message: String(err.message || '').slice(0, 300),
|
|
2407
|
+
};
|
|
2408
|
+
const slept = await sleepWithAbort(delayMs, signal);
|
|
2409
|
+
if (!slept || signal?.aborted) {
|
|
2410
|
+
yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
|
|
2411
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
|
|
2412
|
+
break;
|
|
2413
|
+
}
|
|
2414
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'llm_retry', threadId };
|
|
2415
|
+
continue;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
const earlyFallbackModel = this.#config.fallbackModel;
|
|
2420
|
+
if (earlyFallbackModel && earlyFallbackModel !== currentModel
|
|
2421
|
+
&& (earlyIsRateLimit || earlyIsTransient)) {
|
|
2422
|
+
endAttemptTrace('fallback_retry');
|
|
2423
|
+
yield { type: 'fallback', from: currentModel, to: earlyFallbackModel, reason: err.message };
|
|
2424
|
+
currentModel = earlyFallbackModel;
|
|
2425
|
+
consecutiveRetryableErrors = 0;
|
|
2426
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'fallback_retry', threadId };
|
|
2427
|
+
continue;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2309
2430
|
this.#trace.endTurn(turnId, {
|
|
2310
2431
|
model: currentModel,
|
|
2311
2432
|
inputTokens: totalUsage.inputTokens,
|
|
@@ -2367,96 +2488,6 @@ export class Engine {
|
|
|
2367
2488
|
rawResponse,
|
|
2368
2489
|
};
|
|
2369
2490
|
|
|
2370
|
-
// ─── task-325a: abort short-circuit ────────────────
|
|
2371
|
-
// If the adapter threw LLMAbortError, or the signal fired during
|
|
2372
|
-
// stream() (fetch throws AbortError / DOMException), we converge
|
|
2373
|
-
// the state machine on the 'aborted' terminal state — no retry,
|
|
2374
|
-
// no fallback, no persistence. One `aborted` event + one
|
|
2375
|
-
// `turn_end` with stopReason='aborted' and we're done.
|
|
2376
|
-
const isAbort = err instanceof LLMAbortError
|
|
2377
|
-
|| err?.name === 'AbortError'
|
|
2378
|
-
|| err?.name === 'LLMAbortError'
|
|
2379
|
-
|| (signal?.aborted && /abort/i.test(err?.message || ''));
|
|
2380
|
-
if (isAbort || signal?.aborted) {
|
|
2381
|
-
yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
|
|
2382
|
-
yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
|
|
2383
|
-
break;
|
|
2384
|
-
}
|
|
2385
|
-
|
|
2386
|
-
// ─── LLMContextError → force compact → retry ──────
|
|
2387
|
-
if (err instanceof LLMContextError && this.#conversationStore) {
|
|
2388
|
-
const consolidated = await this.#maybeConsolidate();
|
|
2389
|
-
if (consolidated && consolidated.archivedCount > 0) {
|
|
2390
|
-
yield { type: 'consolidate', archivedCount: consolidated.archivedCount, extractedCount: consolidated.extractedCount };
|
|
2391
|
-
yield { type: 'turn_end', turnNumber, stopReason: 'context_overflow_retry', threadId };
|
|
2392
|
-
continue; // retry with fewer messages
|
|
2393
|
-
}
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
|
-
// ─── Rate-limit / transient / stream-idle retry ───
|
|
2397
|
-
// Honour server-supplied Retry-After for 429/529; fall back to
|
|
2398
|
-
// exponential backoff for 5xx, transport failures, and stream-idle
|
|
2399
|
-
// timeouts wrapped as LLMServerError. Counts against
|
|
2400
|
-
// retryPolicy.maxRetries; on exhaustion we fall through to the
|
|
2401
|
-
// fallback-model path (and ultimately the error event) without
|
|
2402
|
-
// further waiting.
|
|
2403
|
-
const isRateLimit = err instanceof LLMRateLimitError;
|
|
2404
|
-
const isTransient = err instanceof LLMServerError;
|
|
2405
|
-
if (isRateLimit || isTransient) {
|
|
2406
|
-
if (consecutiveRetryableErrors < retryPolicy.maxRetries) {
|
|
2407
|
-
consecutiveRetryableErrors += 1;
|
|
2408
|
-
let delayMs;
|
|
2409
|
-
let reason;
|
|
2410
|
-
if (isRateLimit && Number.isFinite(err.retryAfterMs) && err.retryAfterMs > 0) {
|
|
2411
|
-
// Server told us exactly when to come back. Respect it,
|
|
2412
|
-
// but still cap to maxDelayMs so a misconfigured upstream
|
|
2413
|
-
// can't make us hang forever on one turn.
|
|
2414
|
-
delayMs = Math.min(retryPolicy.maxDelayMs, err.retryAfterMs);
|
|
2415
|
-
reason = 'rate_limit_retry_after';
|
|
2416
|
-
} else if (isRateLimit) {
|
|
2417
|
-
// No header — use backoff but start one step higher so the
|
|
2418
|
-
// first retry isn't immediate (rate-limit windows are
|
|
2419
|
-
// usually >= 1s wide).
|
|
2420
|
-
delayMs = computeBackoffDelay(retryPolicy, consecutiveRetryableErrors);
|
|
2421
|
-
reason = 'rate_limit_backoff';
|
|
2422
|
-
} else {
|
|
2423
|
-
delayMs = computeBackoffDelay(retryPolicy, consecutiveRetryableErrors - 1);
|
|
2424
|
-
reason = err instanceof LLMStreamIdleTimeoutError
|
|
2425
|
-
? 'stream_idle_timeout'
|
|
2426
|
-
: 'transient_backoff';
|
|
2427
|
-
}
|
|
2428
|
-
yield {
|
|
2429
|
-
type: 'llm_retry',
|
|
2430
|
-
attempt: consecutiveRetryableErrors,
|
|
2431
|
-
maxRetries: retryPolicy.maxRetries,
|
|
2432
|
-
delayMs,
|
|
2433
|
-
reason,
|
|
2434
|
-
errorName: err.name,
|
|
2435
|
-
statusCode: err.statusCode ?? null,
|
|
2436
|
-
message: String(err.message || '').slice(0, 300),
|
|
2437
|
-
};
|
|
2438
|
-
const slept = await sleepWithAbort(delayMs, signal);
|
|
2439
|
-
if (!slept || signal?.aborted) {
|
|
2440
|
-
yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
|
|
2441
|
-
yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
|
|
2442
|
-
break;
|
|
2443
|
-
}
|
|
2444
|
-
yield { type: 'turn_end', turnNumber, stopReason: 'llm_retry', threadId };
|
|
2445
|
-
continue; // retry the same turn with the same model
|
|
2446
|
-
}
|
|
2447
|
-
// Exhausted — fall through to fallback-model / error paths.
|
|
2448
|
-
}
|
|
2449
|
-
|
|
2450
|
-
// ─── Fallback model ──────────────────────────────
|
|
2451
|
-
const fallbackModel = this.#config.fallbackModel;
|
|
2452
|
-
if (fallbackModel && fallbackModel !== currentModel &&
|
|
2453
|
-
(err instanceof LLMRateLimitError || err instanceof LLMServerError)) {
|
|
2454
|
-
yield { type: 'fallback', from: currentModel, to: fallbackModel, reason: err.message };
|
|
2455
|
-
currentModel = fallbackModel;
|
|
2456
|
-
consecutiveRetryableErrors = 0; // new model, fresh retry budget
|
|
2457
|
-
yield { type: 'turn_end', turnNumber, stopReason: 'fallback_retry', threadId };
|
|
2458
|
-
continue; // retry with fallback model
|
|
2459
|
-
}
|
|
2460
2491
|
|
|
2461
2492
|
const isRetryableError = err instanceof LLMRateLimitError || err instanceof LLMServerError;
|
|
2462
2493
|
const errorEvent = {
|
package/yeaft/llm/adapter.js
CHANGED
|
@@ -240,12 +240,17 @@ export function retryAfterFromResponse(response) {
|
|
|
240
240
|
* prove from the error shape.
|
|
241
241
|
*
|
|
242
242
|
* @param {unknown} err
|
|
243
|
-
* @param {{ providerLabel?: string }} [opts]
|
|
243
|
+
* @param {{ providerLabel?: string, signal?: AbortSignal }} [opts]
|
|
244
244
|
* @returns {Error}
|
|
245
245
|
*/
|
|
246
246
|
export function classifyFetchError(err, opts = {}) {
|
|
247
247
|
if (!(err instanceof Error)) return err instanceof Object ? err : new Error(String(err));
|
|
248
|
-
if (err.name === '
|
|
248
|
+
if (err.name === 'LLMAbortError' || err instanceof LLMAbortError) return new LLMAbortError();
|
|
249
|
+
const label = opts.providerLabel ? `${opts.providerLabel}: ` : '';
|
|
250
|
+
if (err.name === 'AbortError') {
|
|
251
|
+
if (opts.signal?.aborted) return new LLMAbortError();
|
|
252
|
+
return new LLMServerError(`${label}stream aborted unexpectedly: ${err.message || 'AbortError'}`, 0);
|
|
253
|
+
}
|
|
249
254
|
// Anything already classified: keep as-is.
|
|
250
255
|
if (err instanceof LLMRateLimitError
|
|
251
256
|
|| err instanceof LLMAuthError
|
|
@@ -254,7 +259,6 @@ export function classifyFetchError(err, opts = {}) {
|
|
|
254
259
|
|| err instanceof LLMAbortError) {
|
|
255
260
|
return err;
|
|
256
261
|
}
|
|
257
|
-
const label = opts.providerLabel ? `${opts.providerLabel}: ` : '';
|
|
258
262
|
const code = err.cause?.code || err.code || null;
|
|
259
263
|
const transientCodes = new Set([
|
|
260
264
|
'ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'ETIMEDOUT',
|
package/yeaft/llm/anthropic.js
CHANGED
|
@@ -278,7 +278,7 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
278
278
|
signal,
|
|
279
279
|
});
|
|
280
280
|
} catch (err) {
|
|
281
|
-
throw classifyFetchError(err, { providerLabel: 'Anthropic' });
|
|
281
|
+
throw classifyFetchError(err, { providerLabel: 'Anthropic', signal });
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
if (!response.ok) {
|
|
@@ -320,6 +320,8 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
320
320
|
const rawSseBodyChunks = [];
|
|
321
321
|
const responseHeaders = safeHeaders(response);
|
|
322
322
|
const responseStatus = response.status;
|
|
323
|
+
let sawStop = false;
|
|
324
|
+
let sawMessageStart = false;
|
|
323
325
|
|
|
324
326
|
try {
|
|
325
327
|
while (true) {
|
|
@@ -339,7 +341,10 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
339
341
|
for (const line of lines) {
|
|
340
342
|
if (!line.startsWith('data: ')) continue;
|
|
341
343
|
const data = line.slice(6).trim();
|
|
342
|
-
if (data === '[DONE]')
|
|
344
|
+
if (data === '[DONE]') {
|
|
345
|
+
sawStop = true;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
343
348
|
|
|
344
349
|
let event;
|
|
345
350
|
try {
|
|
@@ -439,6 +444,7 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
439
444
|
} else if (type === 'message_delta') {
|
|
440
445
|
const stopReason = event.delta?.stop_reason;
|
|
441
446
|
if (stopReason) {
|
|
447
|
+
sawStop = true;
|
|
442
448
|
yield {
|
|
443
449
|
type: 'stop',
|
|
444
450
|
stopReason: this.#mapStopReason(stopReason),
|
|
@@ -452,7 +458,10 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
452
458
|
outputTokens: event.usage.output_tokens || 0,
|
|
453
459
|
};
|
|
454
460
|
}
|
|
461
|
+
} else if (type === 'message_stop') {
|
|
462
|
+
sawStop = true;
|
|
455
463
|
} else if (type === 'message_start') {
|
|
464
|
+
sawMessageStart = true;
|
|
456
465
|
// Usage from message_start
|
|
457
466
|
if (event.message?.usage) {
|
|
458
467
|
yield {
|
|
@@ -472,8 +481,11 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
472
481
|
}
|
|
473
482
|
}
|
|
474
483
|
}
|
|
484
|
+
if (sawMessageStart && !sawStop) {
|
|
485
|
+
throw new LLMServerError('Anthropic stream ended before stop event', 0);
|
|
486
|
+
}
|
|
475
487
|
} catch (err) {
|
|
476
|
-
throw classifyFetchError(err, { providerLabel: 'Anthropic' });
|
|
488
|
+
throw classifyFetchError(err, { providerLabel: 'Anthropic', signal });
|
|
477
489
|
} finally {
|
|
478
490
|
reader.releaseLock();
|
|
479
491
|
// Emit raw exchange after stream completes (or errors). Body is the
|
|
@@ -527,7 +539,7 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
527
539
|
signal,
|
|
528
540
|
});
|
|
529
541
|
} catch (err) {
|
|
530
|
-
throw classifyFetchError(err, { providerLabel: 'Anthropic' });
|
|
542
|
+
throw classifyFetchError(err, { providerLabel: 'Anthropic', signal });
|
|
531
543
|
}
|
|
532
544
|
|
|
533
545
|
if (!response.ok) {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* - response.function_call_arguments.delta / .done
|
|
18
18
|
* - response.completed (contains final response.usage)
|
|
19
19
|
* - response.incomplete (e.g. max_output_tokens)
|
|
20
|
-
* - response.
|
|
20
|
+
* - response.failed
|
|
21
21
|
* - Usage: only in terminal completed/incomplete events
|
|
22
22
|
*
|
|
23
23
|
* Id contract (agreed with PM):
|
|
@@ -292,8 +292,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
292
292
|
signal,
|
|
293
293
|
});
|
|
294
294
|
} catch (err) {
|
|
295
|
-
|
|
296
|
-
throw classifyFetchError(err, { providerLabel: 'OpenAI' });
|
|
295
|
+
throw classifyFetchError(err, { providerLabel: 'OpenAI', signal });
|
|
297
296
|
}
|
|
298
297
|
|
|
299
298
|
if (!response.ok) {
|
|
@@ -332,6 +331,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
332
331
|
const rawSseBodyChunks = [];
|
|
333
332
|
const responseHeaders = safeHeaders(response);
|
|
334
333
|
const responseStatus = response.status;
|
|
334
|
+
let sawTerminalEvent = false;
|
|
335
335
|
|
|
336
336
|
try {
|
|
337
337
|
while (true) {
|
|
@@ -353,7 +353,8 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
353
353
|
const line = rawLine.trimEnd();
|
|
354
354
|
if (!line.startsWith('data:')) continue;
|
|
355
355
|
const data = line.slice(5).trim();
|
|
356
|
-
if (!data
|
|
356
|
+
if (!data) continue;
|
|
357
|
+
if (data === '[DONE]') continue;
|
|
357
358
|
|
|
358
359
|
let event;
|
|
359
360
|
try {
|
|
@@ -409,6 +410,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
409
410
|
toolCallAccum.delete(idx);
|
|
410
411
|
}
|
|
411
412
|
} else if (type === 'response.completed' || type === 'response.incomplete') {
|
|
413
|
+
sawTerminalEvent = true;
|
|
412
414
|
const respObj = event.response || {};
|
|
413
415
|
|
|
414
416
|
// Fallback: flush any function_call items in the final output that we
|
|
@@ -449,17 +451,26 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
449
451
|
type: 'stop',
|
|
450
452
|
stopReason: this.#mapStopReason(respObj, sawToolCall),
|
|
451
453
|
};
|
|
452
|
-
} else if (type === 'response.error') {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
454
|
+
} else if (type === 'response.failed' || type === 'response.error') {
|
|
455
|
+
sawTerminalEvent = true;
|
|
456
|
+
// Let the engine decide; emit error event. `response.failed` is
|
|
457
|
+
// the official terminal event; keep `response.error` as a legacy
|
|
458
|
+
// compatibility alias for older mocks/proxies.
|
|
459
|
+
const errObj = event.response?.error || event.error || {};
|
|
460
|
+
const code = errObj.code || event.response?.status || type;
|
|
461
|
+
const message = errObj.message || event.message || `${type}: ${code}`;
|
|
462
|
+
const failure = new Error(message);
|
|
463
|
+
failure.code = code;
|
|
464
|
+
yield { type: 'error', error: failure, retryable: false };
|
|
456
465
|
}
|
|
457
466
|
// Other semantic events (output_item.done, content_part.added, etc.) are ignored.
|
|
458
467
|
}
|
|
459
468
|
}
|
|
469
|
+
if (!sawTerminalEvent) {
|
|
470
|
+
throw new LLMServerError('OpenAI stream ended before terminal event', 0);
|
|
471
|
+
}
|
|
460
472
|
} catch (err) {
|
|
461
|
-
|
|
462
|
-
throw classifyFetchError(err, { providerLabel: 'OpenAI' });
|
|
473
|
+
throw classifyFetchError(err, { providerLabel: 'OpenAI', signal });
|
|
463
474
|
} finally {
|
|
464
475
|
try { reader.releaseLock(); } catch { /* noop */ }
|
|
465
476
|
// Emit raw exchange after stream completes (or errors). Body is the
|
|
@@ -523,8 +534,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
523
534
|
signal,
|
|
524
535
|
});
|
|
525
536
|
} catch (err) {
|
|
526
|
-
|
|
527
|
-
throw classifyFetchError(err, { providerLabel: 'OpenAI' });
|
|
537
|
+
throw classifyFetchError(err, { providerLabel: 'OpenAI', signal });
|
|
528
538
|
}
|
|
529
539
|
|
|
530
540
|
if (!response.ok) {
|