// CRITICAL import { getApiKey } from "../api-key"; import { getStoredBackendUrl } from "../backend-url"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_RETRIES = 3; const DEFAULT_RETRY_DELAY_MS = 1_000; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export const encodePathSegments = (path: string) => path .split("/") .filter((segment) => segment.length > 0) .map((segment) => encodeURIComponent(segment)) .join("/"); function isRetryableError(error: unknown, status?: number): boolean { if (status && status >= 500) return true; if (status === 429) return true; if (status === 408) return true; if (error instanceof TypeError) return true; if (error instanceof Error && error.name === "AbortError") return false; return false; } export interface RequestOptions extends RequestInit { timeout?: number; retries?: number; retryDelay?: number; } export class ApiError extends Error { public readonly status: number; public readonly body: unknown; public constructor(message: string, status: number, body: unknown) { super(message); this.status = status; this.body = body; } } export interface ChatRunStreamEvent { event: string; data: Record; } export type ApiCore = ReturnType; export function createApiCore(params: { baseUrl: string; useProxy: boolean }) { const { baseUrl, useProxy } = params; const buildUrl = (endpoint: string): string => { const path = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; return useProxy ? `${baseUrl}/${path}` : `${baseUrl}${endpoint}`; }; const buildHeaders = (extraHeaders?: HeadersInit): Record => { const headers: Record = { "Content-Type": "application/json" }; const storedBackendUrl = getStoredBackendUrl(); if (useProxy && storedBackendUrl) { headers["X-Backend-Url"] = storedBackendUrl; } const storedKey = getApiKey(); if (storedKey) { headers["Authorization"] = `Bearer ${storedKey}`; } if (extraHeaders) { const merged = new Headers(extraHeaders); merged.forEach((value, key) => { headers[key] = value; }); } return headers; }; const request = async (endpoint: string, options: RequestOptions = {}): Promise => { const { timeout = DEFAULT_TIMEOUT_MS, retries = DEFAULT_RETRIES, retryDelay = DEFAULT_RETRY_DELAY_MS, ...fetchOptions } = options; const headers = buildHeaders(fetchOptions.headers); const url = buildUrl(endpoint); let lastError: Error | null = null; let lastStatus: number | undefined; for (let attempt = 0; attempt <= retries; attempt++) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...fetchOptions, headers, credentials: "include", signal: controller.signal, }); clearTimeout(timeoutId); lastStatus = response.status; if (!response.ok) { const errorBody = await response.json().catch(() => ({ detail: "Request failed" })); const errorMessage = errorBody.detail || errorBody.error?.message || `HTTP ${response.status}`; lastError = new ApiError(errorMessage, response.status, errorBody); if (isRetryableError(lastError, response.status) && attempt < retries) { const delay = retryDelay * Math.pow(2, attempt); console.warn( `[API] Retry ${attempt + 1}/${retries} for ${endpoint} after ${delay}ms (status: ${response.status})`, ); await sleep(delay); continue; } throw lastError; } const text = await response.text(); return text ? (JSON.parse(text) as T) : (null as unknown as T); } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === "AbortError") { lastError = new Error(`Request timeout after ${timeout}ms`); } else if (error instanceof Error) { lastError = error; } else { lastError = new Error(String(error)); } if (isRetryableError(error, lastStatus) && attempt < retries) { const delay = retryDelay * Math.pow(2, attempt); console.warn( `[API] Retry ${attempt + 1}/${retries} for ${endpoint} after ${delay}ms (${lastError.message})`, ); await sleep(delay); continue; } throw lastError; } } throw lastError || new Error("Request failed after retries"); }; const parseSseStream = async function* ( reader: ReadableStreamDefaultReader, ): AsyncGenerator { const decoder = new TextDecoder(); let buffer = ""; let eventType = ""; let dataLines: string[] = []; const flushEvent = (): ChatRunStreamEvent | null => { if (dataLines.length === 0) return null; const dataString = dataLines.join("\n"); let data: Record; try { data = JSON.parse(dataString) as Record; } catch { data = { raw: dataString }; } const payload = { event: eventType || "message", data }; eventType = ""; dataLines = []; return payload; }; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split(/\r?\n/); buffer = lines.pop() || ""; for (const line of lines) { if (!line) { const payload = flushEvent(); if (payload) yield payload; continue; } if (line.startsWith("event:")) { eventType = line.slice(6).trim(); continue; } if (line.startsWith("data:")) { dataLines.push(line.slice(5).trim()); } } } const finalPayload = flushEvent(); if (finalPayload) yield finalPayload; }; const parseOpenAIStream = async function* ( reader: ReadableStreamDefaultReader, ): AsyncGenerator> { const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split(/\r?\n/); buffer = lines.pop() || ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; if (!trimmed.startsWith("data:")) continue; const raw = trimmed.slice(5).trim(); if (!raw) continue; if (raw === "[DONE]") return; try { yield JSON.parse(raw) as Record; } catch { yield { raw }; } } } }; const postSseJson = async ( endpoint: string, payload: unknown, options: { signal?: AbortSignal } = {}, ): Promise<{ runId: string | null; stream: AsyncGenerator }> => { const url = buildUrl(endpoint); const headers = buildHeaders({ Accept: "text/event-stream" }); const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload), signal: options.signal, credentials: "include", }); if (!response.ok || !response.body) { const errorBody = await response.json().catch(() => ({ detail: "Request failed" })); const errorMessage = errorBody.detail || errorBody.error?.message || `HTTP ${response.status}`; throw new ApiError(errorMessage, response.status, errorBody); } const runId = response.headers.get("x-run-id"); const reader = response.body.getReader(); return { runId, stream: parseSseStream(reader) }; }; const postOpenAIStream = async ( endpoint: string, payload: unknown, options: { signal?: AbortSignal } = {}, ): Promise<{ stream: AsyncGenerator> }> => { const url = buildUrl(endpoint); const headers = buildHeaders({ Accept: "text/event-stream" }); const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload), signal: options.signal, credentials: "include", }); if (!response.ok || !response.body) { const text = await response.text().catch(() => ""); let errorBody: { detail?: string; error?: { message?: string } } = {}; if (text) { try { errorBody = JSON.parse(text) as { detail?: string; error?: { message?: string } }; } catch { errorBody = {}; } } const errorMessage = errorBody.detail || errorBody.error?.message || text || `HTTP ${response.status}`; throw new ApiError(errorMessage, response.status, errorBody || { raw: text }); } const reader = response.body.getReader(); return { stream: parseOpenAIStream(reader) }; }; return { baseUrl, useProxy, buildUrl, buildHeaders, request, postSseJson, postOpenAIStream, }; }