SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
11.6 KB · 203 lines typescript
Raw Blame History
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