SPB Git

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