spb/chat-spboucher Public
Private universal chat interface over the OpenRouter ecosystem — 400+ models, branching, streaming, usage tracking. Next.js 16 + SQLite, PWA, deployed on m4m64a at chat.spboucher.ai
TypeScript 78.8%
CSS 15.1%
JavaScript 4.9%
Shell 1.2%
1// Author: Simon-Pierre Boucher2// Contact: contact@spboucher.ai3// Project: chat.spboucher.ai45// The ONLY place that talks to the OpenRouter HTTP API. No scattered fetch() calls.67import { normalizeHttpError, normalizeNetworkError, AIError } from "./errors";89const BASE_URL = "https://openrouter.ai/api/v1";10const MAX_RETRIES = 2;11const RETRY_BASE_DELAY_MS = 750;1213function apiKey(): string {14 const key = process.env.OPENROUTER_API_KEY;15 if (!key) {16 throw new AIError({17 code: "AUTHENTICATION_ERROR",18 message: "Server is missing its OpenRouter credentials.",19 retryable: false,20 });21 }22 return key;23}2425function baseHeaders(): Record<string, string> {26 return {27 Authorization: `Bearer ${apiKey()}`,28 "Content-Type": "application/json",29 "HTTP-Referer": process.env.APP_ORIGIN ?? "https://chat.spboucher.ai",30 "X-Title": "chat.spboucher.ai",31 };32}3334async function readErrorMessage(res: Response): Promise<string> {35 try {36 const json = await res.json();37 return json?.error?.message ?? json?.message ?? "";38 } catch {39 return "";40 }41}4243/**44 * GET with conservative bounded retries for transient failures only.45 * Auth failures and client errors are never retried.46 */47export async function orGet(path: string, signal?: AbortSignal): Promise<unknown> {48 let lastError: AIError | null = null;49 for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {50 try {51 const res = await fetch(`${BASE_URL}${path}`, { headers: baseHeaders(), signal });52 if (res.ok) return await res.json();53 const err = normalizeHttpError(res.status, await readErrorMessage(res));54 if (!err.retryable) throw err;55 lastError = err;56 } catch (e) {57 if (signal?.aborted) throw normalizeNetworkError(e);58 const err = e instanceof AIError ? e : normalizeNetworkError(e);59 if (!err.retryable) throw err;60 lastError = err;61 }62 if (attempt < MAX_RETRIES) {63 await new Promise((r) => setTimeout(r, RETRY_BASE_DELAY_MS * 2 ** attempt));64 }65 }66 throw lastError ?? new AIError({ code: "UNKNOWN", message: "Request failed.", retryable: false });67}6869/**70 * Open a streaming chat completion. Returns the raw Response whose body is an SSE stream.71 * No retries once the stream is open; connection-phase transient errors retry once.72 */73export async function orChatStream(74 body: Record<string, unknown>,75 signal: AbortSignal76): Promise<Response> {77 const modelId = typeof body.model === "string" ? body.model : undefined;78 let lastError: AIError | null = null;79 for (let attempt = 0; attempt <= 1; attempt++) {80 try {81 const res = await fetch(`${BASE_URL}/chat/completions`, {82 method: "POST",83 headers: baseHeaders(),84 body: JSON.stringify({ ...body, stream: true, usage: { include: true } }),85 signal,86 });87 if (res.ok && res.body) return res;88 const err = normalizeHttpError(res.status, await readErrorMessage(res), modelId);89 if (!err.retryable) throw err;90 lastError = err;91 } catch (e) {92 if (signal.aborted) throw normalizeNetworkError(e, modelId);93 const err = e instanceof AIError ? e : normalizeNetworkError(e, modelId);94 if (!err.retryable) throw err;95 lastError = err;96 }97 if (attempt < 1) await new Promise((r) => setTimeout(r, RETRY_BASE_DELAY_MS));98 }99 throw lastError ?? new AIError({ code: "UNKNOWN", message: "Stream failed to open.", retryable: false, modelId });100}101