/** * @factory/ai — provider HTTP/SSE plumbing (shared by all adapters). * * All adapters use the injected `fetch` (default global fetch) and one shared * SSE line parser. Provider SDK types never leave the adapter. */ export interface HttpOptions { fetchImpl?: typeof fetch; /** Default 60_000 ms for chat, 10_000 for embed (arch §2.2). */ timeoutMs?: number; /** Stream idle timeout (arch §2.4: 30 s). Default 30_000. */ idleMs?: number; } export class ProviderHttpError extends Error { readonly status: number; readonly retryable: boolean; constructor(status: number, message: string, retryable: boolean) { super(message); this.name = "ProviderHttpError"; this.status = status; this.retryable = retryable; } } /** 429 / 5xx are retryable (gateway honors retry-after); transport errors too. */ export function isRetryableStatus(status: number): boolean { return status === 429 || status >= 500; } export async function httpJson( url: string, init: RequestInit, options: HttpOptions, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 60_000); try { const fetchImpl = options.fetchImpl ?? fetch; const res = await fetchImpl(url, { ...init, signal: controller.signal }); if (!res.ok) { const body = await safeText(res); throw new ProviderHttpError( res.status, `HTTP ${res.status}: ${truncate(body, 200)}`, isRetryableStatus(res.status), ); } return (await res.json()) as T; } finally { clearTimeout(timer); } } /** * Stream the response body as SSE `data:` payloads. Yields each parsed JSON * payload; the `data: [DONE]` sentinel terminates the stream. */ /** * TASK-0122: `signal` (caller cancellation) races each read — an abort ends * the stream with an {@link abortError} WITHOUT keeping the fetch open * (the reader is cancelled, not merely released). */ export async function* sseData(response: Response, options: HttpOptions, signal?: AbortSignal): AsyncIterable { if (!response.body) { throw new ProviderHttpError(502, "provider returned no body", true); } if (signal?.aborted) throw abortError(); const reader = response.body.getReader(); const decoder = new TextDecoder("utf8"); const idleMs = options.idleMs ?? 30_000; let buffer = ""; try { for (;;) { const chunk = await readWithTimeout(reader, idleMs, signal); if (chunk.done) break; buffer += decoder.decode(chunk.value, { stream: true }); let newline: number; while ((newline = buffer.indexOf("\n")) !== -1) { const line = buffer.slice(0, newline).replace(/\r$/, ""); buffer = buffer.slice(newline + 1); if (!line.startsWith("data:")) continue; const payload = line.slice(5).trim(); if (payload.length === 0) continue; if (payload === "[DONE]") return; yield JSON.parse(payload) as T; } } } finally { // Abort: cancel the body (torn down the fetch socket) instead of merely // releasing the lock. Normal completion: plain release. if (signal?.aborted) { await reader.cancel().catch(() => {}); } else { reader.releaseLock(); } } } /** The canonical caller-abort error (adapters + gateway agree on its shape). */ export function abortError(): Error { const err = new Error("The operation was aborted"); err.name = "AbortError"; return err; } /** Result of `reader.read()` (mirrors the stream lib's `ReadableStreamReadResult`). */ type ReadResult = | { done: false; value: T } | { done: true; value: T | undefined }; async function readWithTimeout( reader: ReadableStreamDefaultReader, timeoutMs: number, signal?: AbortSignal, ): Promise> { let timer: ReturnType | undefined; let onAbort: (() => void) | undefined; const timeout = new Promise<"timeout">((resolve) => { timer = setTimeout(() => resolve("timeout"), timeoutMs); }); // Caller abort wins over both the idle timeout and the read. const abort = new Promise<"abort">((resolve) => { if (!signal) return; if (signal.aborted) { resolve("abort"); return; } onAbort = () => resolve("abort"); signal.addEventListener("abort", onAbort, { once: true }); }); try { const result = await Promise.race([reader.read(), timeout, abort]); if (result === "abort") throw abortError(); if (result === "timeout") throw new ProviderHttpError(504, "stream idle timeout", true); return result; } finally { if (timer) clearTimeout(timer); if (onAbort && signal) signal.removeEventListener("abort", onAbort); } } function safeText(res: Response): Promise { return res.text().catch(() => ""); } function truncate(s: string, n: number): string { return s.length > n ? s.slice(0, n) + "…" : s; }