spb/llmindex Public
The discriminative, contamination-resistant, fully transparent LLM ranking — updated live.
TypeScript 77.9%
TeX 15.2%
Python 3.7%
SQL 1.4%
JavaScript 1.1%
Shell 0.5%
1/**2 * llmindex.io — typed OpenRouter client (retry, cost tracking, latency)3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * License: Proprietary — © Simon-Pierre Boucher, all rights reserved6 *7 * ALL model calls in the platform go through this client (§6 of CLAUDE.md).8 * Key comes from OPENROUTER_API_KEY — never hardcoded, logged, or committed.9 */10import type {11 ChatCompletionResponse,12 ChatRequest,13 ChatResult,14 ORModel,15 Pricing,16} from './types';1718const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';19const MAX_RETRIES = 5;2021export interface OpenRouterClientOptions {22 apiKey?: string;23 baseUrl?: string;24 referer?: string;25 title?: string;26 /** Injectable for tests. */27 fetchFn?: typeof fetch;28 /** Base backoff in ms (exponential, jittered). */29 backoffBaseMs?: number;30 /** Per-attempt timeout in ms (slow reasoning models included). */31 timeoutMs?: number;32}3334export class OpenRouterError extends Error {35 constructor(36 message: string,37 public readonly status: number | null,38 public readonly body?: string,39 ) {40 super(message);41 this.name = 'OpenRouterError';42 }43}4445export class OpenRouterClient {46 private readonly apiKey: string;47 private readonly baseUrl: string;48 private readonly referer: string;49 private readonly title: string;50 private readonly fetchFn: typeof fetch;51 private readonly backoffBaseMs: number;52 private readonly timeoutMs: number;5354 constructor(opts: OpenRouterClientOptions = {}) {55 const key = opts.apiKey ?? process.env.OPENROUTER_API_KEY;56 if (!key) throw new OpenRouterError('OPENROUTER_API_KEY is not set', null);57 this.apiKey = key;58 this.baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;59 this.referer = opts.referer ?? 'https://www.llmindex.io';60 this.title = opts.title ?? 'LLM Index';61 this.fetchFn = opts.fetchFn ?? fetch;62 this.backoffBaseMs = opts.backoffBaseMs ?? 1000;63 this.timeoutMs = opts.timeoutMs ?? 180_000;64 }6566 private headers(): Record<string, string> {67 return {68 Authorization: `Bearer ${this.apiKey}`,69 'HTTP-Referer': this.referer,70 'X-Title': this.title,71 'Content-Type': 'application/json',72 };73 }7475 /** POST with exponential backoff on 429/5xx (max 5 retries). */76 private async request(path: string, init: RequestInit): Promise<Response> {77 let attempt = 0;78 for (;;) {79 let res: Response | null = null;80 let networkError: unknown = null;81 try {82 res = await this.fetchFn(`${this.baseUrl}${path}`, {83 ...init,84 headers: { ...this.headers(), ...(init.headers ?? {}) },85 signal: AbortSignal.timeout(this.timeoutMs),86 });87 } catch (err) {88 networkError = err;89 }90 if (res && res.ok) return res;91 const status = res?.status ?? null;92 const retryable = networkError !== null || status === 429 || (status !== null && status >= 500);93 if (!retryable || attempt >= MAX_RETRIES) {94 const body = res ? await res.text().catch(() => '') : String(networkError);95 throw new OpenRouterError(96 `OpenRouter ${path} failed after ${attempt + 1} attempt(s) (status ${status})`,97 status,98 body.slice(0, 2000),99 );100 }101 const delay = this.backoffBaseMs * 2 ** attempt * (0.5 + Math.random() / 2);102 await new Promise((r) => setTimeout(r, delay));103 attempt += 1;104 }105 }106107 async chat(req: ChatRequest, pricing?: Pricing): Promise<ChatResult> {108 const started = performance.now();109 const res = await this.request('/chat/completions', {110 method: 'POST',111 body: JSON.stringify(req),112 });113 const latencyMs = Math.round(performance.now() - started);114 const raw = (await res.json()) as ChatCompletionResponse;115 const usage = raw.usage ?? null;116 let costUsd: number | null = null;117 if (usage && pricing) {118 costUsd =119 (usage.prompt_tokens * pricing.promptPerM +120 usage.completion_tokens * pricing.completionPerM) /121 1_000_000;122 }123 return {124 text: raw.choices?.[0]?.message?.content ?? '',125 raw,126 usage,127 latencyMs,128 costUsd,129 requestParams: req,130 };131 }132133 /** Model catalog + pricing; synced daily into the models table. */134 async listModels(): Promise<ORModel[]> {135 const res = await this.request('/models', { method: 'GET' });136 const body = (await res.json()) as { data: ORModel[] };137 return body.data;138 }139}140