/** * llmindex.io — typed OpenRouter client (retry, cost tracking, latency) * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * License: Proprietary — © Simon-Pierre Boucher, all rights reserved * * ALL model calls in the platform go through this client (§6 of CLAUDE.md). * Key comes from OPENROUTER_API_KEY — never hardcoded, logged, or committed. */ import type { ChatCompletionResponse, ChatRequest, ChatResult, ORModel, Pricing, } from './types'; const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1'; const MAX_RETRIES = 5; export interface OpenRouterClientOptions { apiKey?: string; baseUrl?: string; referer?: string; title?: string; /** Injectable for tests. */ fetchFn?: typeof fetch; /** Base backoff in ms (exponential, jittered). */ backoffBaseMs?: number; /** Per-attempt timeout in ms (slow reasoning models included). */ timeoutMs?: number; } export class OpenRouterError extends Error { constructor( message: string, public readonly status: number | null, public readonly body?: string, ) { super(message); this.name = 'OpenRouterError'; } } export class OpenRouterClient { private readonly apiKey: string; private readonly baseUrl: string; private readonly referer: string; private readonly title: string; private readonly fetchFn: typeof fetch; private readonly backoffBaseMs: number; private readonly timeoutMs: number; constructor(opts: OpenRouterClientOptions = {}) { const key = opts.apiKey ?? process.env.OPENROUTER_API_KEY; if (!key) throw new OpenRouterError('OPENROUTER_API_KEY is not set', null); this.apiKey = key; this.baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL; this.referer = opts.referer ?? 'https://www.llmindex.io'; this.title = opts.title ?? 'LLM Index'; this.fetchFn = opts.fetchFn ?? fetch; this.backoffBaseMs = opts.backoffBaseMs ?? 1000; this.timeoutMs = opts.timeoutMs ?? 180_000; } private headers(): Record { return { Authorization: `Bearer ${this.apiKey}`, 'HTTP-Referer': this.referer, 'X-Title': this.title, 'Content-Type': 'application/json', }; } /** POST with exponential backoff on 429/5xx (max 5 retries). */ private async request(path: string, init: RequestInit): Promise { let attempt = 0; for (;;) { let res: Response | null = null; let networkError: unknown = null; try { res = await this.fetchFn(`${this.baseUrl}${path}`, { ...init, headers: { ...this.headers(), ...(init.headers ?? {}) }, signal: AbortSignal.timeout(this.timeoutMs), }); } catch (err) { networkError = err; } if (res && res.ok) return res; const status = res?.status ?? null; const retryable = networkError !== null || status === 429 || (status !== null && status >= 500); if (!retryable || attempt >= MAX_RETRIES) { const body = res ? await res.text().catch(() => '') : String(networkError); throw new OpenRouterError( `OpenRouter ${path} failed after ${attempt + 1} attempt(s) (status ${status})`, status, body.slice(0, 2000), ); } const delay = this.backoffBaseMs * 2 ** attempt * (0.5 + Math.random() / 2); await new Promise((r) => setTimeout(r, delay)); attempt += 1; } } async chat(req: ChatRequest, pricing?: Pricing): Promise { const started = performance.now(); const res = await this.request('/chat/completions', { method: 'POST', body: JSON.stringify(req), }); const latencyMs = Math.round(performance.now() - started); const raw = (await res.json()) as ChatCompletionResponse; const usage = raw.usage ?? null; let costUsd: number | null = null; if (usage && pricing) { costUsd = (usage.prompt_tokens * pricing.promptPerM + usage.completion_tokens * pricing.completionPerM) / 1_000_000; } return { text: raw.choices?.[0]?.message?.content ?? '', raw, usage, latencyMs, costUsd, requestParams: req, }; } /** Model catalog + pricing; synced daily into the models table. */ async listModels(): Promise { const res = await this.request('/models', { method: 'GET' }); const body = (await res.json()) as { data: ORModel[] }; return body.data; } }