mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
* fix: complete Z.ai web browser transport * refactor: address Z.ai review feedback * test(zai-web): reconcile the #8014 endpoint guard with the chats/new + signed flow Rebasing onto release/v3.8.49 pulled in #8503, which repointed CHAT_URL to /api/v2/chat/completions and added an endpoint probe. This branch already targets v2, so the executor conflict resolved to this branch's superset (NEW_CHAT_URL + signature constants alongside the same v2 CHAT_URL). The two tests needed adapting, because #8503's assertions assume the pre-rework flow: - executor-zai-web.test.ts: the completion URL now carries the request signature as a query string, so an exact-equality check on the endpoint can never match. Assert the v2 prefix instead. - zai-web-chat-endpoint-8014-probe.test.ts: the probe drove the executor with a bare cookie credential and no captcha proof, which now routes through the browser transport — fetch was never called and the probe captured nothing. Supplied a direct-path credential, and matched on pathname across all requests (the executor also probes the homepage for the frontend version and calls /api/v1/chats/new first). The guard's intent is unchanged and slightly strengthened: it now asserts no request reaches the stale unversioned path and that exactly one completions request is issued, against v2. 54/54 across the zai suites; typecheck:core and eslint clean. * fix(zai-web): surface upstream error frames instead of finishing empty Reported on this PR: HTTP 200, `out=0`, stream "complete", no content and no diagnosis. Cause. HTTP-level failures are already handled — fetchUpstream turns any !ok response into a makeErrorResult with the sanitized body. The gap is a 200 whose SSE body carries an error payload: parseZaiFrame returns null for it, drainSseDeltas drops it, and buildZaiStreamingBody then closes with an empty assistant message + stop + [DONE]. The caller reads that as a successful empty completion, so a rejected signature, an expired captcha and a stale token all look identical — which is why this had to be diagnosed by reading code rather than logs. Hard Rule #6. Fix. parseZaiFrame now classifies an affirmatively error-shaped frame (`error` at the top level or under `data`, string or {detail|message|msg}) as a terminal delta, checked before the delta paths so it cannot fall through to the "no usable delta" null. The stream emits it as `[Z.ai error] <message>`, matching the mid-stream convention the other web executors already use (zed-hosted's createErrorChunk) — the 200 is on the wire, so the status cannot change, but the caller must not be left reading a blank success. Content streamed before the failure is preserved. Message goes through sanitizeErrorMessage (Rule #12). Deliberately NOT changed: a contentless frame still parses to null. That is live-validated behaviour, not an oversight — z.ai emits phase frames with no delta_content, and executor-zai-web.test.ts pins it ("returns null for frames with no usable delta"). Treating "nothing parseable arrived" as a failure would invent policy on top of an observed protocol and risk false errors on the happy path, so this only adds recognition of explicit error frames. Tests (TDD, RED then GREEN): zai-web-silent-empty-repro.test.ts — 7 cases. Error frame classified and terminal; surfaced through the stream with the upstream's own text; surfaced after partial content without losing it; plus a REGRESSION GUARD that contentless/phase-only frames are still skipped, and two controls that the happy path and reasoning-only output are untouched. The guard and controls passed before the fix; the four error cases did not. 94/94 across the zai + stream suites; typecheck:core, eslint and check:file-size clean. * refactor(sse): extract the zai-web transports so the complexity ratchet holds The v3.8.49 merge-train rebaseline (#8686) set the ceiling to the tip's own measurement, leaving zero headroom, so this branch's +5 cyclomatic / +3 cognitive own-growth had nowhere to sit once rebased onto it. Eight violations, all in code this branch introduces, resolved by extraction — no behaviour change: - `execute` (152 lines, complexity 25, cognitive 20) now delegates to `resolveZaiRequest()` for the four client-error rejections and to a `fetchViaSignedApi()` method for the CAPTCHA/signature path, so it reads as "validate, pick a transport, shape the response". - `fetchThroughBrowser` (126 lines, cognitive 16) hands its image decoding to `resolveZaiBrowserAttachments()`, its Playwright options to `buildZaiBrowserChatOptions()`, and its call-log payload to `buildZaiBrowserAuditBody()`. - `configureZaiBrowserEffort` (cognitive 35 — the worst of the set) repeated a wrap-and-relabel try/catch four times inside an if/else. `runStage`, which already existed one function below, is now module-scoped and reused, and the toggle collapses to `checked !== config.enabled` (same four cases). - `validateWebCookieProvider` (complexity 19) moves its can-we-probe-this cascade into `resolveWebCookieProbe()`, which returns either a rejection or the URL + headers to use. - `acquireBrowserContext`'s creation closure (complexity 17) hands cookie and localStorage seeding to `seedContextSession()`. That last extraction also clears a violation that predates this branch — `acquireBrowserContext` was already over the 80-line ceiling — so cyclomatic lands at 2187 against a baseline of 2188. Verified: check:complexity-ratchets green both metrics; typecheck:core clean; ESLint clean on all four files; 85 tests across the zai-web, web-cookie validation, browser-pool and model-test-runner suites pass. * fix(zai-web): surface upstream errors on the non-streaming path collectZaiNonStreaming ignored delta.error — a 200 whose SSE body carries an error frame (rejected signature, expired captcha, stale token) came back as a successful empty completion. Now it throws on an error frame, matching the streaming path's [Z.ai error] convention; the caller's existing try/catch returns makeErrorResult(502) instead of an empty 200. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: backryun <busan011@ormbiz.co.kr> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
806 lines
26 KiB
TypeScript
806 lines
26 KiB
TypeScript
import { trackPendingRequest } from "@/lib/usageDb";
|
|
import { STREAM_IDLE_TIMEOUT_MS } from "../config/constants.ts";
|
|
import { FORMATS } from "../translator/formats.ts";
|
|
import { PENDING_REQUEST_CLEARED_MARKER } from "./stream.ts";
|
|
import { createStreamContentWatcher, type StreamContentWatcher } from "./streamReadiness.ts";
|
|
|
|
// Stream handler with disconnect detection - shared for all providers
|
|
|
|
// Default budget for the pipeWithDisconnect raw-upstream stall watchdog.
|
|
// Inherits STREAM_IDLE_TIMEOUT_MS so a single env knob still governs the
|
|
// max time we tolerate silence from upstream. Reasoning models (Claude
|
|
// thinking, Kiro EventStream binary frames) emit zero post-transform
|
|
// output for long stretches while raw bytes keep arriving — measuring
|
|
// stall on the transform output false-positives on those streams, so
|
|
// the watchdog must track upstream byte activity instead. Ported from
|
|
// decolua/9router#1243.
|
|
const DEFAULT_STREAM_STALL_TIMEOUT_MS = STREAM_IDLE_TIMEOUT_MS;
|
|
|
|
type StreamDisconnectEvent = {
|
|
reason: string;
|
|
duration: number;
|
|
};
|
|
|
|
type StreamErrorEvent = {
|
|
error: unknown;
|
|
message: string;
|
|
statusCode: number;
|
|
duration: number;
|
|
};
|
|
|
|
type StreamControllerOptions = {
|
|
onDisconnect?: (event: StreamDisconnectEvent) => boolean | void;
|
|
onError?: (event: StreamErrorEvent) => boolean | void;
|
|
provider?: string;
|
|
model?: string;
|
|
connectionId?: string | null;
|
|
clientResponseFormat?: string | null;
|
|
clientAbortSignal?: AbortSignal | null;
|
|
};
|
|
|
|
type StreamController = ReturnType<typeof createStreamController>;
|
|
|
|
type StreamErrorStatusKind = "rate_limit" | "authentication" | "permission" | "client" | "server";
|
|
|
|
type StreamErrorStatusMapping = {
|
|
responses: {
|
|
type: string;
|
|
code: string;
|
|
};
|
|
claude: {
|
|
type: string;
|
|
};
|
|
};
|
|
|
|
function isResponsesClientFormat(clientResponseFormat?: string | null): boolean {
|
|
return (
|
|
clientResponseFormat === FORMATS.OPENAI_RESPONSES ||
|
|
clientResponseFormat === FORMATS.OPENAI_RESPONSE
|
|
);
|
|
}
|
|
|
|
function getStreamErrorStatusKind(statusCode: number): StreamErrorStatusKind {
|
|
if (statusCode === 429) return "rate_limit";
|
|
if (statusCode === 401) return "authentication";
|
|
if (statusCode === 403) return "permission";
|
|
if (statusCode >= 400 && statusCode < 500) return "client";
|
|
return "server";
|
|
}
|
|
|
|
function getStreamErrorStatusMapping(statusCode: number): StreamErrorStatusMapping {
|
|
switch (getStreamErrorStatusKind(statusCode)) {
|
|
case "rate_limit":
|
|
return {
|
|
responses: { type: "rate_limit_error", code: "rate_limit_exceeded" },
|
|
claude: { type: "rate_limit_error" },
|
|
};
|
|
case "authentication":
|
|
return {
|
|
responses: { type: "authentication_error", code: "invalid_authentication" },
|
|
claude: { type: "authentication_error" },
|
|
};
|
|
case "permission":
|
|
return {
|
|
responses: { type: "authentication_error", code: "permission_denied" },
|
|
claude: { type: "permission_error" },
|
|
};
|
|
case "client":
|
|
return {
|
|
responses: { type: "invalid_request_error", code: "bad_request" },
|
|
claude: { type: "invalid_request_error" },
|
|
};
|
|
case "server":
|
|
return {
|
|
responses: { type: "server_error", code: "server_error" },
|
|
claude: { type: "api_error" },
|
|
};
|
|
default:
|
|
return {
|
|
responses: { type: "server_error", code: "server_error" },
|
|
claude: { type: "api_error" },
|
|
};
|
|
}
|
|
}
|
|
|
|
function encodeSseEvent(
|
|
data: unknown,
|
|
{
|
|
event,
|
|
includeDone = false,
|
|
}: {
|
|
event?: string;
|
|
includeDone?: boolean;
|
|
} = {}
|
|
) {
|
|
if (event && /[\r\n]/.test(event)) {
|
|
throw new Error("SSE event names must not contain newlines");
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
const prefix = event ? `event: ${event}\n` : "";
|
|
const chunks = [encoder.encode(`${prefix}data: ${JSON.stringify(data)}\n\n`)];
|
|
if (includeDone) {
|
|
chunks.push(encoder.encode("data: [DONE]\n\n"));
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
// Get HH:MM:SS timestamp
|
|
function getTimeString() {
|
|
return new Date().toLocaleTimeString("en-US", {
|
|
hour12: false,
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
}
|
|
|
|
function isPendingRequestClearedError(error: unknown): boolean {
|
|
return (
|
|
!!error &&
|
|
typeof error === "object" &&
|
|
(error as Record<string, unknown>)[PENDING_REQUEST_CLEARED_MARKER] === true
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A client disconnect — the caller aborted the request or closed the SSE
|
|
* connection — is NOT a provider failure. It surfaces either as an
|
|
* AbortError/ResponseAborted, or, when OmniRoute then tries to enqueue another
|
|
* chunk into the now-closed response stream, as a "Controller is already closed"
|
|
* TypeError. Treating any of these as an upstream error wrongly cools down the
|
|
* account/connection, so the stream error path uses this to skip the provider
|
|
* failover/cooldown (the chatgpt-web / codex / antigravity executors already
|
|
* guard client aborts the same way).
|
|
*/
|
|
export function isClientDisconnectError(error: unknown): boolean {
|
|
if (!error || typeof error !== "object") return false;
|
|
const name = (error as { name?: unknown }).name;
|
|
if (name === "AbortError" || name === "ResponseAborted") return true;
|
|
const message = (error as { message?: unknown }).message;
|
|
return typeof message === "string" && /Controller is already closed/i.test(message);
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
if (error instanceof Error && error.message) return error.message;
|
|
if (typeof error === "string" && error.trim().length > 0) return error;
|
|
return "Upstream stream error";
|
|
}
|
|
|
|
function getErrorStatusCode(error: unknown): number {
|
|
const errorName =
|
|
error && typeof error === "object" && typeof (error as { name?: unknown }).name === "string"
|
|
? (error as { name: string }).name
|
|
: "";
|
|
if (errorName === "TimeoutError" || errorName === "BodyTimeoutError") {
|
|
return 504;
|
|
}
|
|
if (error && typeof error === "object" && "statusCode" in error) {
|
|
const statusCode = Number((error as { statusCode?: unknown }).statusCode);
|
|
if (Number.isFinite(statusCode) && statusCode >= 400 && statusCode <= 599) {
|
|
return statusCode;
|
|
}
|
|
}
|
|
return 502;
|
|
}
|
|
|
|
function isDeadlineAbortReason(reason: unknown): reason is Error {
|
|
return (
|
|
reason instanceof Error &&
|
|
(reason.name === "TimeoutError" || reason.name === "BodyTimeoutError")
|
|
);
|
|
}
|
|
|
|
function hasClientTerminalSseMarker(text: string, clientResponseFormat?: string | null): boolean {
|
|
if (/(?:^|\r?\n)data:\s*\[DONE\]\s*(?:\r?\n|$)/.test(text)) {
|
|
return true;
|
|
}
|
|
|
|
if (isResponsesClientFormat(clientResponseFormat)) {
|
|
return (
|
|
/(?:^|\r?\n)event:\s*response\.completed\s*(?:\r?\n|$)/.test(text) ||
|
|
/"type"\s*:\s*"response\.completed"/.test(text)
|
|
);
|
|
}
|
|
|
|
if (clientResponseFormat === FORMATS.CLAUDE) {
|
|
return (
|
|
/(?:^|\r?\n)event:\s*message_stop\s*(?:\r?\n|$)/.test(text) ||
|
|
/"type"\s*:\s*"message_stop"/.test(text)
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Create stream controller with abort and disconnect detection
|
|
* @param {object} options
|
|
* @param {function} options.onDisconnect - Callback when client disconnects
|
|
* @param {object} options.log - Logger instance
|
|
* @param {string} options.provider - Provider name
|
|
* @param {string} options.model - Model name
|
|
*/
|
|
/** @param {StreamControllerOptions} options */
|
|
export function createStreamController({
|
|
onDisconnect,
|
|
onError,
|
|
provider,
|
|
model,
|
|
connectionId,
|
|
clientResponseFormat,
|
|
clientAbortSignal,
|
|
}: StreamControllerOptions = {}) {
|
|
const abortController = new AbortController();
|
|
const startTime = Date.now();
|
|
let disconnected = false;
|
|
let clientTerminalSeen = false;
|
|
let pendingRequestCleared = false;
|
|
let cleanupClientAbortSignal: (() => void) | null = null;
|
|
|
|
const logStream = (status) => {
|
|
const duration = Date.now() - startTime;
|
|
const p = provider?.toUpperCase() || "UNKNOWN";
|
|
console.log(
|
|
`[${getTimeString()}] 🌊 [STREAM] ${p} | ${model || "unknown"} | ${duration}ms | ${status}`
|
|
);
|
|
};
|
|
|
|
const clearPendingRequest = (error?: unknown) => {
|
|
if (pendingRequestCleared) return;
|
|
if (
|
|
error &&
|
|
typeof error === "object" &&
|
|
(error as Record<string, unknown>)[PENDING_REQUEST_CLEARED_MARKER] === true
|
|
) {
|
|
pendingRequestCleared = true;
|
|
return;
|
|
}
|
|
|
|
pendingRequestCleared = true;
|
|
if (!model && !provider && !connectionId) return;
|
|
try {
|
|
trackPendingRequest(model || "", provider || "", connectionId ?? null, false);
|
|
} catch (e) {
|
|
console.error(
|
|
`[${getTimeString()}] [streamHandler] trackPendingRequest decrement failed — counter may drift`,
|
|
e
|
|
);
|
|
}
|
|
};
|
|
|
|
const cleanupClientAbortListener = () => {
|
|
if (!cleanupClientAbortSignal) return;
|
|
cleanupClientAbortSignal();
|
|
cleanupClientAbortSignal = null;
|
|
};
|
|
|
|
const getClientAbortReason = () => {
|
|
const reason = clientAbortSignal?.reason;
|
|
if (typeof reason === "string" && reason.trim().length > 0) {
|
|
return reason;
|
|
}
|
|
if (reason instanceof Error && reason.message) {
|
|
return reason.message;
|
|
}
|
|
return "request_signal_aborted";
|
|
};
|
|
|
|
const controller = {
|
|
signal: abortController.signal,
|
|
startTime,
|
|
|
|
isConnected: () => !disconnected,
|
|
|
|
// Call when client disconnects
|
|
handleDisconnect: (reason = "client_closed") => {
|
|
if (disconnected) return;
|
|
if (clientTerminalSeen) {
|
|
controller.handleComplete();
|
|
return;
|
|
}
|
|
disconnected = true;
|
|
cleanupClientAbortListener();
|
|
|
|
logStream(`disconnect: ${reason}`);
|
|
|
|
// Decrement pending request counter — the TransformStream flush() won't
|
|
// fire when the client aborts mid-stream, so we must clean up here.
|
|
clearPendingRequest();
|
|
|
|
abortController.abort(reason);
|
|
|
|
onDisconnect?.({ reason, duration: Date.now() - startTime });
|
|
},
|
|
|
|
// Call when stream completes normally
|
|
handleComplete: () => {
|
|
if (disconnected) return;
|
|
disconnected = true;
|
|
cleanupClientAbortListener();
|
|
|
|
logStream("complete");
|
|
},
|
|
|
|
markClientTerminalSeen: () => {
|
|
clientTerminalSeen = true;
|
|
},
|
|
|
|
// Call on error
|
|
handleError: (error: unknown) => {
|
|
cleanupClientAbortListener();
|
|
|
|
// A client disconnect is not a provider failure. If the client already went away
|
|
// (disconnected) or the error is a client abort / "Controller is already closed",
|
|
// skip the onError failover/cooldown path — otherwise one cancelled request marks
|
|
// the upstream connection unavailable.
|
|
if (disconnected || isClientDisconnectError(error)) {
|
|
clearPendingRequest(error);
|
|
logStream(disconnected ? "client_disconnect (post-abort)" : "client_disconnect");
|
|
return;
|
|
}
|
|
|
|
const alreadyCleared = isPendingRequestClearedError(error);
|
|
let handled = false;
|
|
if (!alreadyCleared) {
|
|
try {
|
|
handled =
|
|
onError?.({
|
|
error,
|
|
message: getErrorMessage(error),
|
|
statusCode: getErrorStatusCode(error),
|
|
duration: Date.now() - startTime,
|
|
}) === true;
|
|
} catch (e) {
|
|
console.debug(`[STREAM-HANDLER] onError callback error:`, e);
|
|
}
|
|
}
|
|
|
|
if (!handled) {
|
|
clearPendingRequest(error);
|
|
} else {
|
|
pendingRequestCleared = true;
|
|
}
|
|
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
logStream("aborted");
|
|
return;
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
logStream(`error: ${error.message}`);
|
|
return;
|
|
}
|
|
logStream("error: unknown");
|
|
},
|
|
|
|
abort: () => {
|
|
cleanupClientAbortListener();
|
|
abortController.abort();
|
|
},
|
|
clientResponseFormat,
|
|
};
|
|
|
|
if (clientAbortSignal && typeof clientAbortSignal.addEventListener === "function") {
|
|
const handleClientAbort = () => {
|
|
const reason = clientAbortSignal.reason;
|
|
if (isDeadlineAbortReason(reason)) {
|
|
// An AbortSignal can represent an OmniRoute-owned deadline as well as
|
|
// a caller disconnect. Preserve deadline failures as 504; classifying
|
|
// them as client disconnects writes a misleading 499 to the call log.
|
|
abortController.abort(reason);
|
|
controller.handleError(reason);
|
|
return;
|
|
}
|
|
controller.handleDisconnect(getClientAbortReason());
|
|
};
|
|
if (clientAbortSignal.aborted) {
|
|
queueMicrotask(handleClientAbort);
|
|
} else {
|
|
clientAbortSignal.addEventListener("abort", handleClientAbort, { once: true });
|
|
cleanupClientAbortSignal = () => {
|
|
clientAbortSignal.removeEventListener("abort", handleClientAbort);
|
|
};
|
|
}
|
|
}
|
|
|
|
return controller;
|
|
}
|
|
|
|
export function buildStreamErrorChunks(
|
|
errorMsg: string,
|
|
statusCode: number,
|
|
clientResponseFormat?: string | null
|
|
) {
|
|
const statusMapping = getStreamErrorStatusMapping(statusCode);
|
|
|
|
if (isResponsesClientFormat(clientResponseFormat)) {
|
|
const errorEvent = {
|
|
type: "response.failed",
|
|
response: {
|
|
id: null,
|
|
status: "failed",
|
|
error: {
|
|
message: errorMsg,
|
|
type: statusMapping.responses.type,
|
|
code: statusMapping.responses.code,
|
|
},
|
|
},
|
|
};
|
|
|
|
return encodeSseEvent(errorEvent, { event: "response.failed" });
|
|
}
|
|
|
|
if (clientResponseFormat === FORMATS.CLAUDE) {
|
|
const errorEvent = {
|
|
type: "error",
|
|
error: {
|
|
type: statusMapping.claude.type,
|
|
message: errorMsg,
|
|
},
|
|
};
|
|
|
|
// #7699 — emit message_stop after event:error so Anthropic SDK / Claude Code
|
|
// see a proper terminal frame instead of a silent mid-response close.
|
|
// Without message_stop, clients report "Connection closed mid-response."
|
|
return [
|
|
...encodeSseEvent(errorEvent, { event: "error" }),
|
|
...encodeSseEvent({ type: "message_stop" }, { event: "message_stop" }),
|
|
];
|
|
}
|
|
|
|
const errorEvent = {
|
|
object: "chat.completion.chunk",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: "error",
|
|
},
|
|
],
|
|
error: {
|
|
message: errorMsg,
|
|
type: statusMapping.responses.type,
|
|
code: statusMapping.responses.code,
|
|
},
|
|
};
|
|
|
|
return encodeSseEvent(errorEvent, { includeDone: true });
|
|
}
|
|
|
|
/**
|
|
* Minimal `writable` half used by `pipeWithDisconnect`. The real writable is
|
|
* driven entirely by the upstream-piped readable, so the writer only needs an
|
|
* `abort()` hook for `createDisconnectAwareStream`'s `cancel()` path.
|
|
*
|
|
* `abort()` returns `Promise<void>` to match the native
|
|
* `WritableStreamDefaultWriter.abort()` contract — `cancel()` (and any caller
|
|
* that awaits the writer) gets a real thenable instead of `undefined`, which
|
|
* keeps abort/error handling clean. Ported from decolua/9router@6b624af4.
|
|
*/
|
|
export function createNoopAbortWritable(): {
|
|
getWriter: () => { abort: () => Promise<void> };
|
|
} {
|
|
return { getWriter: () => ({ abort: () => Promise.resolve() }) };
|
|
}
|
|
|
|
/**
|
|
* Create transform stream with disconnect detection
|
|
* Wraps existing transform stream and adds abort capability
|
|
*/
|
|
/**
|
|
* Why a finished upstream stream should still be reported as a failure, or null
|
|
* when the close was clean. Two distinct silent-close shapes:
|
|
*
|
|
* - **#7699, no terminal marker.** Scoped to Claude (`/v1/messages`), which is
|
|
* the issue's real scope: Anthropic's SSE spec permits a mid-stream
|
|
* `event: error`, and Claude clients treat a stream ending without
|
|
* `message_stop` as an error. For every other format (plain OpenAI chat
|
|
* completions included) a done-without-recognized-marker close is NOT
|
|
* necessarily a drop — many formats have no `[DONE]` equivalent — so
|
|
* synthesising an error there would be a false positive.
|
|
*
|
|
* - **#8649, no content at all.** The stream terminated properly and carried no
|
|
* model output. Unlike the marker case this is not format-dependent: a
|
|
* completed stream with zero content is a failure everywhere, and it is the
|
|
* streaming twin of the non-streaming `isEmptyContentResponse` check. Only
|
|
* applies to bodies that actually looked like SSE, and terminal states where
|
|
* emptiness is legitimate (length / tool_calls / content_filter / max_tokens /
|
|
* tool_use) are excluded by the watcher.
|
|
*/
|
|
function resolveSilentCloseReason(input: {
|
|
bytesWereForwarded: boolean;
|
|
clientTerminalSeen: boolean;
|
|
clientResponseFormat?: string | null;
|
|
contentWatcher: StreamContentWatcher;
|
|
}): string | null {
|
|
if (!input.bytesWereForwarded) return null;
|
|
|
|
if (!input.clientTerminalSeen && input.clientResponseFormat === FORMATS.CLAUDE) {
|
|
return "Upstream stream ended without a terminal marker";
|
|
}
|
|
|
|
const watcher = input.contentWatcher;
|
|
if (watcher.sawSseFrame() && !watcher.sawContent() && !watcher.sawLegitEmptyTerminal()) {
|
|
return "Provider returned empty content";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function createDisconnectAwareStream(transformStream, streamController) {
|
|
const reader = transformStream.readable.getReader();
|
|
const writer = transformStream.writable.getWriter();
|
|
const terminalDecoder = new TextDecoder();
|
|
const contentDecoder = new TextDecoder();
|
|
const contentWatcher = createStreamContentWatcher();
|
|
let terminalTail = "";
|
|
let clientTerminalSeen = false;
|
|
let bytesWereForwarded = false;
|
|
|
|
const noteClientChunk = (chunk: unknown) => {
|
|
if (!(chunk instanceof Uint8Array)) return;
|
|
bytesWereForwarded = true;
|
|
// Runs past clientTerminalSeen: the frame that carries the terminal marker
|
|
// can carry the only content too, and #8649 needs the whole stream scanned.
|
|
contentWatcher.note(contentDecoder.decode(chunk, { stream: true }));
|
|
if (clientTerminalSeen) return;
|
|
|
|
terminalTail += terminalDecoder.decode(chunk, { stream: true });
|
|
if (terminalTail.length > 4096) {
|
|
terminalTail = terminalTail.slice(-4096);
|
|
}
|
|
clientTerminalSeen = hasClientTerminalSseMarker(
|
|
terminalTail,
|
|
streamController.clientResponseFormat
|
|
);
|
|
if (clientTerminalSeen) {
|
|
streamController.markClientTerminalSeen?.();
|
|
}
|
|
};
|
|
|
|
return new ReadableStream(
|
|
{
|
|
async pull(controller) {
|
|
if (!streamController.isConnected()) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
contentWatcher.finish();
|
|
const silentCloseReason = resolveSilentCloseReason({
|
|
bytesWereForwarded,
|
|
clientTerminalSeen,
|
|
clientResponseFormat: streamController.clientResponseFormat,
|
|
contentWatcher,
|
|
});
|
|
|
|
if (silentCloseReason) {
|
|
streamController.handleError(
|
|
Object.assign(new Error(silentCloseReason), { statusCode: 502 })
|
|
);
|
|
try {
|
|
for (const chunk of buildStreamErrorChunks(
|
|
silentCloseReason,
|
|
502,
|
|
streamController.clientResponseFormat
|
|
)) {
|
|
controller.enqueue(chunk);
|
|
}
|
|
} catch {
|
|
// downstream may have closed; original error already recorded
|
|
}
|
|
} else {
|
|
streamController.handleComplete();
|
|
}
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
// Expected: downstream may have already closed
|
|
}
|
|
return;
|
|
}
|
|
controller.enqueue(value);
|
|
noteClientChunk(value);
|
|
} catch (error) {
|
|
if (!streamController.isConnected()) {
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
// Expected: downstream may have already closed
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (clientTerminalSeen) {
|
|
streamController.handleComplete();
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
// Expected: downstream may have already closed
|
|
}
|
|
return;
|
|
}
|
|
|
|
streamController.handleError(error);
|
|
|
|
// T35: Encapsulate mid-stream errors as SSE events instead of abruptly aborting
|
|
// This prevents TransferEncodingError on the client side
|
|
const errorMsg = getErrorMessage(error);
|
|
const statusCode = getErrorStatusCode(error);
|
|
|
|
try {
|
|
for (const chunk of buildStreamErrorChunks(
|
|
errorMsg,
|
|
statusCode,
|
|
streamController.clientResponseFormat
|
|
)) {
|
|
controller.enqueue(chunk);
|
|
}
|
|
} catch {
|
|
// The downstream may have closed while we were formatting the in-band
|
|
// error event. The original stream error has already been recorded.
|
|
}
|
|
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
// Closing an already-closed/aborted controller after client disconnect is expected.
|
|
}
|
|
}
|
|
},
|
|
|
|
async cancel(reason) {
|
|
if (clientTerminalSeen) {
|
|
streamController.handleComplete();
|
|
} else {
|
|
streamController.handleDisconnect(reason || "cancelled");
|
|
}
|
|
await Promise.allSettled([reader.cancel(reason), writer.abort(reason)]);
|
|
},
|
|
},
|
|
{ highWaterMark: 16384 }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Pipe provider response through transform with disconnect detection.
|
|
*
|
|
* Stall watchdog tracks raw upstream byte activity, not transform output.
|
|
* Reasoning models (Claude thinking via Kiro, etc.) can produce zero SSE
|
|
* output for long stretches while partial EventStream frames keep arriving;
|
|
* measuring stall on the transform output caused false stalls. Any upstream
|
|
* chunk resets the timer. If no bytes arrive for `stallTimeoutMs`, the
|
|
* stream surfaces a "stream stall timeout" error and aborts.
|
|
*
|
|
* Ported from decolua/9router#1243 by @zakirkun.
|
|
*
|
|
* @param providerResponse - Response from provider
|
|
* @param transformStream - Transform stream for SSE
|
|
* @param streamController - Stream controller from createStreamController
|
|
* @param opts.stallTimeoutMs - Override the stall budget (defaults to
|
|
* STREAM_IDLE_TIMEOUT_MS / DEFAULT_STREAM_STALL_TIMEOUT_MS). `0` disables
|
|
* the watchdog.
|
|
*/
|
|
export function pipeWithDisconnect(
|
|
providerResponse: Response,
|
|
transformStream: TransformStream<Uint8Array, Uint8Array>,
|
|
streamController: StreamController,
|
|
opts: { stallTimeoutMs?: number } = {}
|
|
) {
|
|
const stallTimeoutMs = opts.stallTimeoutMs ?? DEFAULT_STREAM_STALL_TIMEOUT_MS;
|
|
|
|
// Watchdog disabled — preserve legacy behavior verbatim.
|
|
if (!stallTimeoutMs || stallTimeoutMs <= 0) {
|
|
const transformedBody = providerResponse.body.pipeThrough(transformStream);
|
|
return createDisconnectAwareStream(
|
|
{ readable: transformedBody, writable: createNoopAbortWritable() },
|
|
streamController
|
|
);
|
|
}
|
|
|
|
let stallTimer: ReturnType<typeof setTimeout> | null = null;
|
|
// Captured on the upstream tap's `start`, used by the watchdog to error the
|
|
// pipeline so the downstream reader unblocks and emits a clean SSE error
|
|
// event. Without this, aborting the AbortController alone does not unblock
|
|
// a `reader.read()` already suspended on the transform pipe — the request
|
|
// would hang until the upstream finally closed the socket.
|
|
let upstreamTapController: TransformStreamDefaultController<Uint8Array> | null = null;
|
|
// Set when the watchdog fires so the downstream pull() catch (which sees
|
|
// the same error propagated through the pipeline) does not call
|
|
// handleError a second time — pending-cleanup is idempotent but onError
|
|
// callbacks should fire once per error.
|
|
let stallFired = false;
|
|
|
|
const clearStall = () => {
|
|
if (stallTimer) {
|
|
clearTimeout(stallTimer);
|
|
stallTimer = null;
|
|
}
|
|
};
|
|
const armStall = () => {
|
|
clearStall();
|
|
stallTimer = setTimeout(() => {
|
|
stallTimer = null;
|
|
stallFired = true;
|
|
const stallError = new Error("stream stall timeout");
|
|
// Notify the controller (onError callback + pending-request cleanup).
|
|
try {
|
|
streamController.handleError?.(stallError);
|
|
} catch (e) {
|
|
console.debug(`[STREAM-HANDLER] stall watchdog handleError failed:`, e);
|
|
}
|
|
// Error the pipeline so the downstream reader unblocks. createDisconnect-
|
|
// AwareStream's catch block translates this into buildStreamErrorChunks
|
|
// (sanitized SSE error event with finish_reason:"error", per the format).
|
|
try {
|
|
upstreamTapController?.error(stallError);
|
|
} catch (e) {
|
|
console.debug(`[STREAM-HANDLER] stall watchdog upstream tap error failed:`, e);
|
|
}
|
|
// Abort the underlying fetch so upstream releases the connection.
|
|
try {
|
|
streamController.abort?.();
|
|
} catch (e) {
|
|
console.debug(`[STREAM-HANDLER] stall watchdog abort failed:`, e);
|
|
}
|
|
}, stallTimeoutMs);
|
|
};
|
|
|
|
// Wrap controller so every termination path clears the stall timer.
|
|
// Without this, abort/complete/error/disconnect paths leave the timer armed
|
|
// and a stale abort could fire after the request has already ended.
|
|
const wrappedController: StreamController = {
|
|
...streamController,
|
|
handleComplete: () => {
|
|
clearStall();
|
|
streamController.handleComplete();
|
|
},
|
|
handleError: (e: unknown) => {
|
|
clearStall();
|
|
// Watchdog already fired its own handleError — the inner pull() catch
|
|
// sees the same error propagated through the pipeline; suppress the
|
|
// duplicate to keep onError callbacks single-fire.
|
|
if (stallFired) return;
|
|
streamController.handleError(e);
|
|
},
|
|
handleDisconnect: (reason?: string) => {
|
|
clearStall();
|
|
streamController.handleDisconnect(reason);
|
|
},
|
|
abort: () => {
|
|
clearStall();
|
|
streamController.abort();
|
|
},
|
|
};
|
|
|
|
// Inert tap that resets the stall timer on every raw upstream byte chunk.
|
|
// Sits between the provider body and the SSE transform so reasoning models
|
|
// that buffer many raw bytes into a single emitted event do not look
|
|
// stalled to the watchdog.
|
|
const upstreamTap = new TransformStream<Uint8Array, Uint8Array>({
|
|
start(controller) {
|
|
upstreamTapController = controller;
|
|
armStall();
|
|
},
|
|
transform(chunk, controller) {
|
|
armStall();
|
|
controller.enqueue(chunk);
|
|
},
|
|
flush() {
|
|
clearStall();
|
|
},
|
|
});
|
|
|
|
const transformedBody = providerResponse.body
|
|
.pipeThrough(upstreamTap)
|
|
.pipeThrough(transformStream);
|
|
return createDisconnectAwareStream(
|
|
{ readable: transformedBody, writable: createNoopAbortWritable() },
|
|
wrappedController
|
|
);
|
|
}
|