SPB Git

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%
3.4 KB · 101 lines typescript
Raw Blame History
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