/** JSON requests return error records; streaming requests reject unless onError handles them. */ export class ApiError extends Error { constructor(message, {status = 0, code = "REQUEST_FAILED", requestId = null, ...details} = {}) { super(message); this.name = "ApiError"; this.status = status; this.code = code; this.requestId = requestId; this.details = details; } toJSON() { return {...this.details, error: this.message, status: this.status, code: this.code, requestId: this.requestId}; } } function requestScope({signal, timeoutMs = 30000} = {}) { const controller = new AbortController(); const abort = () => controller.abort(new ApiError("Request cancelled.", {code: "ABORTED"})); if (signal?.aborted) abort(); else signal?.addEventListener("abort", abort, {once: true}); const timer = timeoutMs > 0 ? setTimeout(() => { controller.abort(new ApiError("Request timed out. Please try again.", {code: "TIMEOUT"})); }, timeoutMs) : null; return { signal: controller.signal, close() { clearTimeout(timer); signal?.removeEventListener("abort", abort); } }; } // Also bound body reads, even after fetch has resolved its response headers. function abortable(operation, signal) { if (signal.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); }; signal.addEventListener("abort", abort, {once: true}); try { Promise.resolve(operation()).then(resolve, reject) .finally(() => signal.removeEventListener("abort", abort)); } catch (error) { signal.removeEventListener("abort", abort); reject(error); } }); } function responseError(response, payload = {}, fallback = "", code = "") { const details = payload && typeof payload === "object" ? payload : {}; const error = details.error; return new ApiError(typeof error === "string" ? error : error?.message || fallback || `HTTP ${response.status} ${response.statusText}`.trim(), { ...details, status: response.ok && Number.isInteger(details.status) && details.status >= 400 ? details.status : response.status, code: details.code || error?.code || code || `HTTP_${response.status}`, requestId: details.requestId || response.headers.get("X-Request-ID") || null }); } async function readJson(response, signal) { const text = await abortable(() => response.text(), signal); let payload; if (text.trim()) { try { payload = JSON.parse(text); } catch { if (response.ok) throw responseError(response, {}, "Invalid JSON response.", "INVALID_RESPONSE"); } } if (!response.ok) throw responseError(response, payload); return payload ?? {}; } function normalizeError(error, scope, response) { const failure = scope.signal.aborted ? scope.signal.reason : error; if (failure instanceof ApiError) { if (!failure.status && response) failure.status = response.status; failure.requestId ||= response?.headers.get("X-Request-ID") || null; return failure; } return new ApiError(response ? failure?.message || "Response could not be read." : "Network error - please try again.", { status: response?.status || 0, code: response ? "INVALID_RESPONSE" : "NETWORK_ERROR", requestId: response?.headers.get("X-Request-ID") || null }); } export async function request(url, data, method = "POST", options = {}) { const scope = requestScope(options); let response; try { response = await abortable(() => fetch(url, { method, credentials: "same-origin", headers: {"Accept": "application/json", "Content-Type": "application/json"}, body: data == null ? undefined : JSON.stringify(data), signal: scope.signal }), scope.signal); return await readJson(response, scope.signal); } catch (error) { return normalizeError(error, scope, response).toJSON(); } finally { scope.close(); } } export const get = (url, options) => request(url, null, "GET", options); export const post = (url, data, options) => request(url, data, "POST", options); export const put = (url, data, options) => request(url, data, "PUT", options); export const del = (url, options) => request(url, null, "DELETE", options); /** * SSE text events carry {chunk}; corrections carry {target: "reply"|"thinking", text}. * A legacy AbortSignal is also accepted as the fourth argument. Set timeoutMs: 0 to disable the deadline. */ export async function stream(url, data, handlers = {}, options = {}) { if (options && typeof options.addEventListener === "function") options = {signal: options}; const scope = requestScope({timeoutMs: 660000, ...options}); let response, reader; try { response = await abortable(() => fetch(url, { method: "POST", credentials: "same-origin", headers: {"Accept": "text/event-stream, application/json", "Content-Type": "application/json"}, body: JSON.stringify(data || {}), signal: scope.signal }), scope.signal); if (!response.ok) await readJson(response, scope.signal); if (!response.body) throw responseError(response, {}, "Missing response stream.", "INVALID_RESPONSE"); reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let terminal = false; while (!terminal) { const {value, done} = await abortable(() => reader.read(), scope.signal); if (done) break; buffer += decoder.decode(value, {stream: true}); const parts = buffer.split(/\r?\n\r?\n/); buffer = parts.pop() || ""; for (const part of parts) { const lines = part.split(/\r?\n/); const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim(); const dataLines = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()); if (!event || !dataLines.length) continue; const payload = JSON.parse(dataLines.join("\n")); if (!payload || typeof payload !== "object") throw new Error("Invalid response stream."); const textFields = {status: "status", done: "reply", error: "error", replace: "text"}; if (textFields[event] && typeof payload[textFields[event]] !== "string") throw new Error("Invalid response stream."); if (event === "chunk" && typeof (payload.chunk ?? payload.fullReply) !== "string") throw new Error("Invalid response stream."); if (event === "thinking" && typeof (payload.chunk ?? payload.fullThinking) !== "string") throw new Error("Invalid response stream."); if (event === "replace" && !["reply", "thinking"].includes(payload.target)) throw new Error("Invalid replacement stream."); if (event === "error") throw responseError(response, payload, "Streaming request failed.", "STREAM_ERROR"); const handler = {chunk: "onChunk", thinking: "onThinking", status: "onStatus", replace: "onReplace", done: "onDone"}[event]; if (handler) handlers[handler]?.(payload); if (event === "done") { terminal = true; break; } } } if (!terminal) throw responseError(response, {}, "Response was interrupted. Please try again.", "STREAM_INTERRUPTED"); } catch (error) { const failure = normalizeError(error, scope, response); if (handlers.onError) handlers.onError(failure.toJSON()); else throw failure; } finally { scope.close(); if (reader) { // Cancelling a custom or stalled source must not prevent the request from settling. void reader.cancel().catch(() => {}); reader.releaseLock(); } } }