profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
openrouter.ts 8,308 bytes
1 /**
2 * OpenRouter-backed implementation of the core `LlmClient`, plus the REST
3 * helpers the UI needs (model catalog, key validation, credit balance).
4 *
5 * OpenRouter is OpenAI-compatible, so we drive it through the Vercel AI SDK's
6 * OpenAI provider pointed at the OpenRouter base URL. This is the ONLY place in
7 * the server that talks to a model provider; the orchestrator stays oblivious.
8 *
9 * Cost is computed locally from the model catalog's per-token pricing so we can
10 * attribute spend per model per stage without a second API round-trip.
11 */
12 import { createOpenAI } from '@ai-sdk/openai';
13 import { generateText, streamText } from 'ai';
14 import {
15 LlmError,
16 type LlmClient,
17 type LlmMessage,
18 type LlmRequest,
19 type LlmResult,
20 type LlmStreamHandle,
21 } from '@/core/llm-client';
22 import type { Usage } from '@/core/types';
23 import { env } from './env';
24
25 // ---------------------------------------------------------------------------
26 // Catalog + pricing
27 // ---------------------------------------------------------------------------
28
29 export interface OpenRouterModel {
30 id: string;
31 name: string;
32 description?: string;
33 context_length?: number;
34 pricing: { prompt: string; completion: string; request?: string; image?: string };
35 top_provider?: { max_completion_tokens?: number | null };
36 architecture?: { modality?: string; input_modalities?: string[] };
37 }
38
39 export interface ModelPricing {
40 /** USD per prompt token. */
41 prompt: number;
42 /** USD per completion token. */
43 completion: number;
44 }
45 export type PricingMap = Map<string, ModelPricing>;
46
47 function authHeaders(apiKey?: string): Record<string, string> {
48 return {
49 ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
50 'HTTP-Referer': env.OPENROUTER_APP_URL,
51 'X-Title': env.OPENROUTER_APP_TITLE,
52 'Content-Type': 'application/json',
53 };
54 }
55
56 async function fetchWithRetry(url: string, init: RequestInit, attempts = 3): Promise<Response> {
57 let lastErr: unknown;
58 for (let i = 0; i < attempts; i++) {
59 try {
60 const res = await fetch(url, init);
61 if (res.status === 429 || res.status >= 500) {
62 if (i < attempts - 1) {
63 await sleep(250 * 2 ** i);
64 continue;
65 }
66 }
67 return res;
68 } catch (err) {
69 lastErr = err;
70 if (i < attempts - 1) await sleep(250 * 2 ** i);
71 }
72 }
73 throw new LlmError(`Network error contacting OpenRouter: ${String(lastErr)}`, { retryable: true, cause: lastErr });
74 }
75
76 export async function fetchOpenRouterModels(apiKey?: string): Promise<OpenRouterModel[]> {
77 const res = await fetchWithRetry(`${env.OPENROUTER_BASE_URL}/models`, {
78 method: 'GET',
79 headers: authHeaders(apiKey),
80 });
81 if (!res.ok) {
82 throw new LlmError(`Failed to fetch model catalog (${res.status})`, {
83 status: res.status,
84 retryable: res.status >= 500,
85 });
86 }
87 const json = (await res.json()) as { data: OpenRouterModel[] };
88 return json.data ?? [];
89 }
90
91 export function pricingFromModels(models: OpenRouterModel[]): PricingMap {
92 const map: PricingMap = new Map();
93 for (const m of models) {
94 const prompt = Number(m.pricing?.prompt ?? '0');
95 const completion = Number(m.pricing?.completion ?? '0');
96 map.set(m.id, {
97 prompt: Number.isFinite(prompt) ? prompt : 0,
98 completion: Number.isFinite(completion) ? completion : 0,
99 });
100 }
101 return map;
102 }
103
104 // ---------------------------------------------------------------------------
105 // Key validation / credits
106 // ---------------------------------------------------------------------------
107
108 export interface KeyValidation {
109 valid: boolean;
110 label?: string;
111 usage?: number;
112 limit?: number | null;
113 limitRemaining?: number | null;
114 isFreeTier?: boolean;
115 error?: string;
116 }
117
118 export async function validateOpenRouterKey(apiKey: string): Promise<KeyValidation> {
119 try {
120 const res = await fetchWithRetry(`${env.OPENROUTER_BASE_URL}/key`, {
121 method: 'GET',
122 headers: authHeaders(apiKey),
123 });
124 if (res.status === 401 || res.status === 403) return { valid: false, error: 'Invalid API key' };
125 if (!res.ok) return { valid: false, error: `OpenRouter returned ${res.status}` };
126 const json = (await res.json()) as {
127 data?: { label?: string; usage?: number; limit?: number | null; is_free_tier?: boolean };
128 };
129 const d = json.data ?? {};
130 const limit = d.limit ?? null;
131 const usage = d.usage ?? 0;
132 return {
133 valid: true,
134 label: d.label,
135 usage,
136 limit,
137 limitRemaining: limit === null ? null : Math.max(0, limit - usage),
138 isFreeTier: d.is_free_tier,
139 };
140 } catch (err) {
141 return { valid: false, error: err instanceof Error ? err.message : 'Network error' };
142 }
143 }
144
145 // ---------------------------------------------------------------------------
146 // LlmClient implementation
147 // ---------------------------------------------------------------------------
148
149 function toCoreMessages(messages: LlmMessage[]) {
150 return messages.map((m) => ({ role: m.role, content: m.content }) as const);
151 }
152
153 function buildUsage(model: string, raw: { promptTokens?: number; completionTokens?: number; totalTokens?: number } | undefined, pricing: PricingMap): Usage {
154 const promptTokens = raw?.promptTokens ?? 0;
155 const completionTokens = raw?.completionTokens ?? 0;
156 const totalTokens = raw?.totalTokens ?? promptTokens + completionTokens;
157 const price = pricing.get(model);
158 const costUsd = price ? promptTokens * price.prompt + completionTokens * price.completion : 0;
159 return { promptTokens, completionTokens, totalTokens, costUsd };
160 }
161
162 function toLlmError(err: unknown): LlmError {
163 if (err instanceof LlmError) return err;
164 const anyErr = err as { statusCode?: number; status?: number; name?: string; message?: string } | undefined;
165 const status = anyErr?.statusCode ?? anyErr?.status;
166 const retryable = status === 429 || (status !== undefined && status >= 500);
167 return new LlmError(anyErr?.message ?? String(err), { status, retryable, cause: err });
168 }
169
170 export interface OpenRouterClientOptions {
171 apiKey: string;
172 pricing?: PricingMap;
173 maxRetries?: number;
174 }
175
176 export function createOpenRouterClient(opts: OpenRouterClientOptions): LlmClient {
177 const provider = createOpenAI({
178 baseURL: env.OPENROUTER_BASE_URL,
179 apiKey: opts.apiKey,
180 name: 'openrouter',
181 headers: {
182 'HTTP-Referer': env.OPENROUTER_APP_URL,
183 'X-Title': env.OPENROUTER_APP_TITLE,
184 },
185 });
186 const pricing = opts.pricing ?? new Map();
187 const maxRetries = opts.maxRetries ?? 4;
188
189 return {
190 async complete(req: LlmRequest): Promise<LlmResult> {
191 const t0 = Date.now();
192 try {
193 const res = await generateText({
194 model: provider.chat(req.model),
195 messages: toCoreMessages(req.messages),
196 temperature: req.temperature,
197 maxTokens: req.maxTokens,
198 abortSignal: req.signal,
199 maxRetries,
200 });
201 return {
202 text: res.text,
203 usage: buildUsage(req.model, res.usage, pricing),
204 model: req.model,
205 latencyMs: Date.now() - t0,
206 };
207 } catch (err) {
208 throw toLlmError(err);
209 }
210 },
211
212 async streamComplete(req: LlmRequest): Promise<LlmStreamHandle> {
213 const t0 = Date.now();
214 const result = streamText({
215 model: provider.chat(req.model),
216 messages: toCoreMessages(req.messages),
217 temperature: req.temperature,
218 maxTokens: req.maxTokens,
219 abortSignal: req.signal,
220 maxRetries,
221 });
222
223 async function* stream(): AsyncGenerator<string> {
224 try {
225 for await (const delta of result.textStream) yield delta;
226 } catch (err) {
227 throw toLlmError(err);
228 }
229 }
230
231 const final: Promise<LlmResult> = (async () => {
232 try {
233 const [text, usage] = await Promise.all([result.text, result.usage]);
234 return {
235 text,
236 usage: buildUsage(req.model, usage, pricing),
237 model: req.model,
238 latencyMs: Date.now() - t0,
239 };
240 } catch (err) {
241 throw toLlmError(err);
242 }
243 })();
244
245 return { stream: stream(), result: final };
246 },
247 };
248 }
249
250 function sleep(ms: number): Promise<void> {
251 return new Promise((resolve) => setTimeout(resolve, ms));
252 }
253