TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import type OpenAI from "openai";2import { type PolyModel, type ModelPricing, modelKey } from "@/lib/ai/core/types";3import { createOpenAICompatAdapter } from "../shared/openai-compat/factory";45/**6 * OpenRouter — OpenAI-compatible gateway to hundreds of models. The registry is built entirely7 * from `GET /api/v1/models`, whose metadata (pricing per token, context, modalities,8 * supported_parameters, reasoning) is authoritative for each underlying model.9 * Quirks (docs/provider-research/openrouter.md): `usage: {include:true}` → exact `usage.cost`,10 * `reasoning: {effort|max_tokens|exclude}`, streamed `delta.reasoning`, web search via11 * `plugins:[{id:"web"}]`, attribution headers, 402 = insufficient credits.12 */13export interface OpenRouterModel {14 id: string;15 canonical_slug?: string;16 name: string;17 description?: string;18 created?: number;19 context_length?: number | null;20 architecture?: { modality?: string; input_modalities?: string[]; output_modalities?: string[]; tokenizer?: string };21 pricing?: { prompt?: string; completion?: string; request?: string; image?: string; web_search?: string; input_cache_read?: string; input_cache_write?: string; overrides?: { min_prompt_tokens?: number; prompt?: string; completion?: string; input_cache_read?: string }[] };22 top_provider?: { context_length?: number | null; max_completion_tokens?: number | null; is_moderated?: boolean };23 supported_parameters?: string[];24 default_parameters?: Record<string, unknown>;25 expiration_date?: string | null;26 knowledge_cutoff?: string | null;27 reasoning?: { mandatory?: boolean; default_enabled?: boolean; supported_efforts?: string[]; default_effort?: string } | null;28}2930const perMillion = (v?: string | null): number | undefined => {31 if (v === undefined || v === null) return undefined;32 const n = Number(v);33 return Number.isFinite(n) ? Math.round(n * 1_000_000 * 1e6) / 1e6 : undefined;34};3536export function normalizeOpenRouterModel(m: OpenRouterModel): PolyModel | null {37 const out = m.architecture?.output_modalities ?? ["text"];38 if (!out.includes("text") || out.includes("image") || out.includes("audio")) return null; // image/audio generators are not chat models39 if (m.expiration_date && new Date(m.expiration_date).getTime() < Date.now()) return null;40 if (/embedding|embed-|moderation|tts|whisper/i.test(m.id)) return null;41 if (m.id.endsWith(":batch")) return null; // Batch API only — 404 on chat completions42 if (m.id.startsWith("~")) return null; // `~vendor/x-latest` aliases fold into their targets43 if (m.pricing?.prompt === "-1" && m.id !== "openrouter/auto") return null; // routers without a price44 const sp = new Set(m.supported_parameters ?? []);45 const inputs = new Set(m.architecture?.input_modalities ?? ["text"]);46 const reasoningInfo = m.reasoning ?? null;47 const reasoning = Boolean(reasoningInfo) || sp.has("reasoning") || sp.has("include_reasoning");48 const efforts = reasoningInfo?.supported_efforts?.length ? [...reasoningInfo.supported_efforts].reverse() : sp.has("reasoning_effort") || sp.has("reasoning") ? ["low", "medium", "high"] : undefined;49 const free = m.pricing?.prompt === "0" && m.pricing?.completion === "0";50 const pricing: ModelPricing | null = m.pricing51 ? {52 inputPerMillion: perMillion(m.pricing.prompt),53 outputPerMillion: perMillion(m.pricing.completion),54 cachedInputPerMillion: perMillion(m.pricing.input_cache_read),55 longContext: m.pricing.overrides?.[0]?.min_prompt_tokens ? { thresholdTokens: m.pricing.overrides[0].min_prompt_tokens!, inputPerMillion: perMillion(m.pricing.overrides[0].prompt), outputPerMillion: perMillion(m.pricing.overrides[0].completion), cachedInputPerMillion: perMillion(m.pricing.overrides[0].input_cache_read) } : undefined,56 source: "openrouter:/api/v1/models",57 asOf: new Date().toISOString().slice(0, 10),58 }59 : null;60 const vendor = m.id.split("/")[0];61 return {62 key: modelKey("openrouter", m.id),63 id: m.id,64 provider: "openrouter",65 displayName: m.name,66 family: vendorName(vendor),67 capabilities: {68 text: true,69 vision: inputs.has("image"),70 audioInput: inputs.has("audio"),71 audioOutput: false,72 imageGeneration: false,73 video: inputs.has("video"),74 reasoning,75 tools: sp.has("tools"),76 structuredOutput: sp.has("structured_outputs") || sp.has("response_format"),77 streaming: true,78 files: inputs.has("file"),79 webSearch: true, // web plugin is available on every model (billed per request)80 },81 limits: { contextTokens: m.context_length ?? m.top_provider?.context_length ?? undefined, maxOutputTokens: m.top_provider?.max_completion_tokens ?? undefined },82 parameters: {83 temperature: sp.has("temperature"),84 topP: sp.has("top_p"),85 topK: sp.has("top_k"),86 maxTokens: sp.has("max_tokens") || sp.has("max_completion_tokens"),87 stop: sp.has("stop"),88 seed: sp.has("seed"),89 frequencyPenalty: sp.has("frequency_penalty"),90 presencePenalty: sp.has("presence_penalty"),91 verbosity: sp.has("verbosity"),92 reasoningEffort: Boolean(efforts?.length),93 reasoningEffortLevels: efforts ? [...(reasoningInfo?.mandatory ? [] : ["none"]), ...efforts] : undefined,94 thinkingBudget: reasoning && !reasoningInfo?.supported_efforts?.length,95 thinkingBudgetRange: reasoning && !reasoningInfo?.supported_efforts?.length ? { min: 1024, max: 32_000 } : undefined,96 temperatureRange: { min: 0, max: 2 },97 },98 status: m.id.endsWith(":free") || free ? "active" : /preview|beta|exp/i.test(m.id) ? "preview" : "active",99 pricing,100 metadata: {101 vendor,102 canonicalSlug: m.canonical_slug,103 description: m.description?.slice(0, 300),104 supportedParameters: m.supported_parameters,105 reasoningInfo,106 free,107 moderated: m.top_provider?.is_moderated,108 knowledgeCutoff: m.knowledge_cutoff,109 expirationDate: m.expiration_date,110 createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined,111 sortWeight: sortWeightOf(m, free),112 },113 };114}115116function vendorName(v: string): string {117 const map: Record<string, string> = { openai: "OpenAI", anthropic: "Anthropic", google: "Google", "x-ai": "xAI", "meta-llama": "Meta", mistralai: "Mistral", deepseek: "DeepSeek", moonshotai: "Moonshot", qwen: "Qwen", cohere: "Cohere", perplexity: "Perplexity", nvidia: "NVIDIA", microsoft: "Microsoft", amazon: "Amazon", "z-ai": "Z.ai", minimax: "MiniMax", openrouter: "OpenRouter" };118 return map[v] ?? v.charAt(0).toUpperCase() + v.slice(1);119}120function sortWeightOf(m: OpenRouterModel, free: boolean): number {121 // newest first within the gateway, big vendors slightly boosted, free variants last122 const age = m.created ? Math.max(0, (Date.now() / 1000 - m.created) / 86_400) : 3650;123 // Aggregator entries rank below native provider catalogs (-30) so "gpt-5.5" resolves to OpenAI first.124 let w = Math.round(70 - Math.min(60, age / 10));125 if (/^(openai|anthropic|google|x-ai)\//.test(m.id)) w += 5;126 if (free) w -= 20;127 return w;128}129130async function listOpenRouter(_client: OpenAI, apiKey: string, signal?: AbortSignal): Promise<PolyModel[]> {131 // `/models/user` honours the account's ignored-model settings; fall back to the public listing.132 let res = await fetch("https://openrouter.ai/api/v1/models/user", { headers: { Authorization: `Bearer ${apiKey}` }, signal });133 if (!res.ok && res.status !== 401 && res.status !== 402) res = await fetch("https://openrouter.ai/api/v1/models", { headers: { Authorization: `Bearer ${apiKey}` }, signal });134 if (!res.ok) {135 const body = (await res.json().catch(() => ({}))) as { error?: { message?: string; code?: number } };136 throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers });137 }138 const data = (await res.json()) as { data: OpenRouterModel[] };139 return data.data.map(normalizeOpenRouterModel).filter((m): m is PolyModel => Boolean(m));140}141142async function validateOpenRouter(_client: OpenAI, apiKey: string, signal?: AbortSignal) {143 // `/api/v1/key` is the cheapest authenticated call and confirms the key is live.144 const res = await fetch("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${apiKey}` }, signal });145 if (!res.ok) {146 const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };147 throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers });148 }149 const models = await listOpenRouter(_client, apiKey, signal);150 return { modelsAvailable: models.length };151}152153export const openrouterAdapter = createOpenAICompatAdapter({154 id: "openrouter",155 name: "OpenRouter",156 baseURL: "https://openrouter.ai/api/v1",157 keyDocsUrl: "https://openrouter.ai/settings/keys",158 keyPrefixHint: "sk-or-v1-",159 defaultHeaders: { "HTTP-Referer": "https://www.polyllm.io", "X-OpenRouter-Title": "PolyLLM", "X-Title": "PolyLLM" },160 listModels: listOpenRouter,161 validate: validateOpenRouter,162 messageOptions: { inlineFiles: true },163 useMaxTokens: true,164 tweakParams: (params, settings, req) => {165 const p = params as unknown as Record<string, unknown>;166 const sp = new Set((req.modelInfo?.metadata?.supportedParameters as string[] | undefined) ?? []);167 const info = (req.modelInfo?.metadata?.reasoningInfo ?? null) as { mandatory?: boolean; supported_efforts?: string[] } | null;168 if (req.modelInfo?.capabilities.reasoning) {169 const effort = settings.reasoningEffort;170 const reasoning: Record<string, unknown> = {};171 if (effort === "none" && !info?.mandatory) reasoning.enabled = false;172 else if (effort && effort !== "none") {173 if (info?.supported_efforts?.length) reasoning.effort = effort === "minimal" ? "low" : effort;174 else reasoning.max_tokens = effort === "low" || effort === "minimal" ? 2000 : effort === "medium" ? 8000 : 16000; // models without effort levels (e.g. Anthropic) need a budget175 } else if (settings.thinkingBudget) reasoning.max_tokens = settings.thinkingBudget;176 if (settings.includeReasoning === false) reasoning.exclude = true;177 if (Object.keys(reasoning).length) p.reasoning = reasoning;178 // reasoning tokens count against max_tokens → never leave a tiny cap179 if (typeof params.max_tokens === "number" && params.max_tokens < 2000 && effort !== "none") params.max_tokens = 2000;180 }181 if (settings.webSearch) p.plugins = [{ id: "web" }];182 // Ask OpenRouter to route only to endpoints that honour the schema when strict JSON is requested.183 if (params.response_format?.type === "json_schema") p.provider = { require_parameters: true };184 // Never forward knobs the underlying model does not list (OpenRouter may otherwise 400 or silently ignore).185 if (sp.size) {186 for (const [ours, theirs] of [["temperature", "temperature"], ["top_p", "top_p"], ["seed", "seed"], ["stop", "stop"], ["frequency_penalty", "frequency_penalty"], ["presence_penalty", "presence_penalty"]] as const) {187 if (p[ours] !== undefined && !sp.has(theirs)) delete p[ours];188 }189 if (p.response_format && !sp.has("response_format") && !sp.has("structured_outputs")) delete p.response_format;190 }191 },192 refineError: (status, code, message) => {193 if (status === 402) return "INSUFFICIENT_CREDITS";194 if (status === 401) return "INVALID_API_KEY";195 if (status === 403 && /moderat|flagged/i.test(message)) return "CONTENT_REJECTED";196 if (status === 404 && /model|no endpoints/i.test(message)) return "MODEL_NOT_FOUND";197 if (status === 408) return "REQUEST_TIMEOUT";198 if (status === 502 || status === 503) return "PROVIDER_UNAVAILABLE";199 void code;200 return undefined;201 },202});203