import type OpenAI from "openai"; import { type PolyModel, type ModelPricing, modelKey } from "@/lib/ai/core/types"; import { createOpenAICompatAdapter } from "../shared/openai-compat/factory"; /** * OpenRouter — OpenAI-compatible gateway to hundreds of models. The registry is built entirely * from `GET /api/v1/models`, whose metadata (pricing per token, context, modalities, * supported_parameters, reasoning) is authoritative for each underlying model. * Quirks (docs/provider-research/openrouter.md): `usage: {include:true}` → exact `usage.cost`, * `reasoning: {effort|max_tokens|exclude}`, streamed `delta.reasoning`, web search via * `plugins:[{id:"web"}]`, attribution headers, 402 = insufficient credits. */ export interface OpenRouterModel { id: string; canonical_slug?: string; name: string; description?: string; created?: number; context_length?: number | null; architecture?: { modality?: string; input_modalities?: string[]; output_modalities?: string[]; tokenizer?: string }; 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 }[] }; top_provider?: { context_length?: number | null; max_completion_tokens?: number | null; is_moderated?: boolean }; supported_parameters?: string[]; default_parameters?: Record; expiration_date?: string | null; knowledge_cutoff?: string | null; reasoning?: { mandatory?: boolean; default_enabled?: boolean; supported_efforts?: string[]; default_effort?: string } | null; } const perMillion = (v?: string | null): number | undefined => { if (v === undefined || v === null) return undefined; const n = Number(v); return Number.isFinite(n) ? Math.round(n * 1_000_000 * 1e6) / 1e6 : undefined; }; export function normalizeOpenRouterModel(m: OpenRouterModel): PolyModel | null { const out = m.architecture?.output_modalities ?? ["text"]; if (!out.includes("text") || out.includes("image") || out.includes("audio")) return null; // image/audio generators are not chat models if (m.expiration_date && new Date(m.expiration_date).getTime() < Date.now()) return null; if (/embedding|embed-|moderation|tts|whisper/i.test(m.id)) return null; if (m.id.endsWith(":batch")) return null; // Batch API only — 404 on chat completions if (m.id.startsWith("~")) return null; // `~vendor/x-latest` aliases fold into their targets if (m.pricing?.prompt === "-1" && m.id !== "openrouter/auto") return null; // routers without a price const sp = new Set(m.supported_parameters ?? []); const inputs = new Set(m.architecture?.input_modalities ?? ["text"]); const reasoningInfo = m.reasoning ?? null; const reasoning = Boolean(reasoningInfo) || sp.has("reasoning") || sp.has("include_reasoning"); const efforts = reasoningInfo?.supported_efforts?.length ? [...reasoningInfo.supported_efforts].reverse() : sp.has("reasoning_effort") || sp.has("reasoning") ? ["low", "medium", "high"] : undefined; const free = m.pricing?.prompt === "0" && m.pricing?.completion === "0"; const pricing: ModelPricing | null = m.pricing ? { inputPerMillion: perMillion(m.pricing.prompt), outputPerMillion: perMillion(m.pricing.completion), cachedInputPerMillion: perMillion(m.pricing.input_cache_read), 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, source: "openrouter:/api/v1/models", asOf: new Date().toISOString().slice(0, 10), } : null; const vendor = m.id.split("/")[0]; return { key: modelKey("openrouter", m.id), id: m.id, provider: "openrouter", displayName: m.name, family: vendorName(vendor), capabilities: { text: true, vision: inputs.has("image"), audioInput: inputs.has("audio"), audioOutput: false, imageGeneration: false, video: inputs.has("video"), reasoning, tools: sp.has("tools"), structuredOutput: sp.has("structured_outputs") || sp.has("response_format"), streaming: true, files: inputs.has("file"), webSearch: true, // web plugin is available on every model (billed per request) }, limits: { contextTokens: m.context_length ?? m.top_provider?.context_length ?? undefined, maxOutputTokens: m.top_provider?.max_completion_tokens ?? undefined }, parameters: { temperature: sp.has("temperature"), topP: sp.has("top_p"), topK: sp.has("top_k"), maxTokens: sp.has("max_tokens") || sp.has("max_completion_tokens"), stop: sp.has("stop"), seed: sp.has("seed"), frequencyPenalty: sp.has("frequency_penalty"), presencePenalty: sp.has("presence_penalty"), verbosity: sp.has("verbosity"), reasoningEffort: Boolean(efforts?.length), reasoningEffortLevels: efforts ? [...(reasoningInfo?.mandatory ? [] : ["none"]), ...efforts] : undefined, thinkingBudget: reasoning && !reasoningInfo?.supported_efforts?.length, thinkingBudgetRange: reasoning && !reasoningInfo?.supported_efforts?.length ? { min: 1024, max: 32_000 } : undefined, temperatureRange: { min: 0, max: 2 }, }, status: m.id.endsWith(":free") || free ? "active" : /preview|beta|exp/i.test(m.id) ? "preview" : "active", pricing, metadata: { vendor, canonicalSlug: m.canonical_slug, description: m.description?.slice(0, 300), supportedParameters: m.supported_parameters, reasoningInfo, free, moderated: m.top_provider?.is_moderated, knowledgeCutoff: m.knowledge_cutoff, expirationDate: m.expiration_date, createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined, sortWeight: sortWeightOf(m, free), }, }; } function vendorName(v: string): string { const map: Record = { 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" }; return map[v] ?? v.charAt(0).toUpperCase() + v.slice(1); } function sortWeightOf(m: OpenRouterModel, free: boolean): number { // newest first within the gateway, big vendors slightly boosted, free variants last const age = m.created ? Math.max(0, (Date.now() / 1000 - m.created) / 86_400) : 3650; // Aggregator entries rank below native provider catalogs (-30) so "gpt-5.5" resolves to OpenAI first. let w = Math.round(70 - Math.min(60, age / 10)); if (/^(openai|anthropic|google|x-ai)\//.test(m.id)) w += 5; if (free) w -= 20; return w; } async function listOpenRouter(_client: OpenAI, apiKey: string, signal?: AbortSignal): Promise { // `/models/user` honours the account's ignored-model settings; fall back to the public listing. let res = await fetch("https://openrouter.ai/api/v1/models/user", { headers: { Authorization: `Bearer ${apiKey}` }, signal }); if (!res.ok && res.status !== 401 && res.status !== 402) res = await fetch("https://openrouter.ai/api/v1/models", { headers: { Authorization: `Bearer ${apiKey}` }, signal }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: { message?: string; code?: number } }; throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers }); } const data = (await res.json()) as { data: OpenRouterModel[] }; return data.data.map(normalizeOpenRouterModel).filter((m): m is PolyModel => Boolean(m)); } async function validateOpenRouter(_client: OpenAI, apiKey: string, signal?: AbortSignal) { // `/api/v1/key` is the cheapest authenticated call and confirms the key is live. const res = await fetch("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${apiKey}` }, signal }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers }); } const models = await listOpenRouter(_client, apiKey, signal); return { modelsAvailable: models.length }; } export const openrouterAdapter = createOpenAICompatAdapter({ id: "openrouter", name: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", keyDocsUrl: "https://openrouter.ai/settings/keys", keyPrefixHint: "sk-or-v1-", defaultHeaders: { "HTTP-Referer": "https://www.polyllm.io", "X-OpenRouter-Title": "PolyLLM", "X-Title": "PolyLLM" }, listModels: listOpenRouter, validate: validateOpenRouter, messageOptions: { inlineFiles: true }, useMaxTokens: true, tweakParams: (params, settings, req) => { const p = params as unknown as Record; const sp = new Set((req.modelInfo?.metadata?.supportedParameters as string[] | undefined) ?? []); const info = (req.modelInfo?.metadata?.reasoningInfo ?? null) as { mandatory?: boolean; supported_efforts?: string[] } | null; if (req.modelInfo?.capabilities.reasoning) { const effort = settings.reasoningEffort; const reasoning: Record = {}; if (effort === "none" && !info?.mandatory) reasoning.enabled = false; else if (effort && effort !== "none") { if (info?.supported_efforts?.length) reasoning.effort = effort === "minimal" ? "low" : effort; else reasoning.max_tokens = effort === "low" || effort === "minimal" ? 2000 : effort === "medium" ? 8000 : 16000; // models without effort levels (e.g. Anthropic) need a budget } else if (settings.thinkingBudget) reasoning.max_tokens = settings.thinkingBudget; if (settings.includeReasoning === false) reasoning.exclude = true; if (Object.keys(reasoning).length) p.reasoning = reasoning; // reasoning tokens count against max_tokens → never leave a tiny cap if (typeof params.max_tokens === "number" && params.max_tokens < 2000 && effort !== "none") params.max_tokens = 2000; } if (settings.webSearch) p.plugins = [{ id: "web" }]; // Ask OpenRouter to route only to endpoints that honour the schema when strict JSON is requested. if (params.response_format?.type === "json_schema") p.provider = { require_parameters: true }; // Never forward knobs the underlying model does not list (OpenRouter may otherwise 400 or silently ignore). if (sp.size) { 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) { if (p[ours] !== undefined && !sp.has(theirs)) delete p[ours]; } if (p.response_format && !sp.has("response_format") && !sp.has("structured_outputs")) delete p.response_format; } }, refineError: (status, code, message) => { if (status === 402) return "INSUFFICIENT_CREDITS"; if (status === 401) return "INVALID_API_KEY"; if (status === 403 && /moderat|flagged/i.test(message)) return "CONTENT_REJECTED"; if (status === 404 && /model|no endpoints/i.test(message)) return "MODEL_NOT_FOUND"; if (status === 408) return "REQUEST_TIMEOUT"; if (status === 502 || status === 503) return "PROVIDER_UNAVAILABLE"; void code; return undefined; }, });