// Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Project: chat.spboucher.ai // The ONLY place that talks to the OpenRouter HTTP API. No scattered fetch() calls. import { normalizeHttpError, normalizeNetworkError, AIError } from "./errors"; const BASE_URL = "https://openrouter.ai/api/v1"; const MAX_RETRIES = 2; const RETRY_BASE_DELAY_MS = 750; function apiKey(): string { const key = process.env.OPENROUTER_API_KEY; if (!key) { throw new AIError({ code: "AUTHENTICATION_ERROR", message: "Server is missing its OpenRouter credentials.", retryable: false, }); } return key; } function baseHeaders(): Record { return { Authorization: `Bearer ${apiKey()}`, "Content-Type": "application/json", "HTTP-Referer": process.env.APP_ORIGIN ?? "https://chat.spboucher.ai", "X-Title": "chat.spboucher.ai", }; } async function readErrorMessage(res: Response): Promise { try { const json = await res.json(); return json?.error?.message ?? json?.message ?? ""; } catch { return ""; } } /** * GET with conservative bounded retries for transient failures only. * Auth failures and client errors are never retried. */ export async function orGet(path: string, signal?: AbortSignal): Promise { let lastError: AIError | null = null; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { const res = await fetch(`${BASE_URL}${path}`, { headers: baseHeaders(), signal }); if (res.ok) return await res.json(); const err = normalizeHttpError(res.status, await readErrorMessage(res)); if (!err.retryable) throw err; lastError = err; } catch (e) { if (signal?.aborted) throw normalizeNetworkError(e); const err = e instanceof AIError ? e : normalizeNetworkError(e); if (!err.retryable) throw err; lastError = err; } if (attempt < MAX_RETRIES) { await new Promise((r) => setTimeout(r, RETRY_BASE_DELAY_MS * 2 ** attempt)); } } throw lastError ?? new AIError({ code: "UNKNOWN", message: "Request failed.", retryable: false }); } /** * Open a streaming chat completion. Returns the raw Response whose body is an SSE stream. * No retries once the stream is open; connection-phase transient errors retry once. */ export async function orChatStream( body: Record, signal: AbortSignal ): Promise { const modelId = typeof body.model === "string" ? body.model : undefined; let lastError: AIError | null = null; for (let attempt = 0; attempt <= 1; attempt++) { try { const res = await fetch(`${BASE_URL}/chat/completions`, { method: "POST", headers: baseHeaders(), body: JSON.stringify({ ...body, stream: true, usage: { include: true } }), signal, }); if (res.ok && res.body) return res; const err = normalizeHttpError(res.status, await readErrorMessage(res), modelId); if (!err.retryable) throw err; lastError = err; } catch (e) { if (signal.aborted) throw normalizeNetworkError(e, modelId); const err = e instanceof AIError ? e : normalizeNetworkError(e, modelId); if (!err.retryable) throw err; lastError = err; } if (attempt < 1) await new Promise((r) => setTimeout(r, RETRY_BASE_DELAY_MS)); } throw lastError ?? new AIError({ code: "UNKNOWN", message: "Stream failed to open.", retryable: false, modelId }); }