// SPDX-License-Identifier: GPL-3.0-or-later /** * OpenRouter adapter (merge mode). * * Source: GET https://openrouter.ai/api/v1/models — public, no key required. * * Price units: OpenRouter quotes USD **per token** as strings; Pi wants USD per * million, hence the `* 1e6`. Volume pricing arrives as `pricing.overrides[]` * keyed by `min_prompt_tokens`, which maps onto Pi's `cost.tiers[]`. * * Conditional requests: the last stored ETag is sent as `If-None-Match`. * OpenRouter does not currently emit an ETag (it serves * `cache-control: max-age=300` through Cloudflare instead), so in practice the * TTL is what prevents refetching; the 304 path exists for adapters that do. * * This adapter runs in merge mode: it overrides Pi's built-in `openrouter` * provider, contributing prices only. See merge.ts for why. */ import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; import type { CatalogAdapter, FetchedCatalog, LiveModel, ModelCost, ModelCostRates, ModelCostTier, ThinkingLevel, ThinkingLevelMap, } from "../types.ts"; const CATALOG_URL = "https://openrouter.ai/api/v1/models"; /** * Fallback output cap for models whose `top_provider.max_completion_tokens` is * absent (50 of 422 at time of writing). Deliberately conservative: too low * merely truncates, while too high makes the provider reject the request. * Merge mode rarely needs it — curated entries already carry a real value. */ const FALLBACK_MAX_TOKENS = 16_384; const FALLBACK_CONTEXT_WINDOW = 128_000; interface OpenRouterPricingOverride { min_prompt_tokens?: number; prompt?: string; completion?: string; input_cache_read?: string; input_cache_write?: string; } interface OpenRouterPricing { prompt?: string; completion?: string; input_cache_read?: string; input_cache_write?: string; overrides?: OpenRouterPricingOverride[]; } /** * `reasoning` is an object, not a boolean — an easy mistake to make, since * `Boolean(obj)` happens to give the right answer while discarding * `supported_efforts`, which is exactly what Pi's thinkingLevelMap needs. */ interface OpenRouterReasoning { mandatory?: boolean; default_enabled?: boolean; supported_efforts?: string[]; default_effort?: string; } interface OpenRouterArchitecture { input_modalities?: string[]; output_modalities?: string[]; } interface OpenRouterTopProvider { context_length?: number; max_completion_tokens?: number; } export interface OpenRouterModelRaw { id?: string; name?: string; context_length?: number; architecture?: OpenRouterArchitecture | null; top_provider?: OpenRouterTopProvider | null; reasoning?: OpenRouterReasoning | null; pricing?: OpenRouterPricing | null; } /** Pi's thinking levels, in the order OpenRouter reports efforts. */ const THINKING_LEVELS: ThinkingLevel[] = ["minimal", "low", "medium", "high", "xhigh", "max"]; /** * Converts a USD-per-token price string to USD per million. * * Rounded to 6 decimals because the naive `parseFloat(x) * 1e6` yields values * like 0.19999999999999998 that then get persisted to disk and rendered in the * UI. Negative and unparseable values collapse to 0. */ export function toPerMillion(value: string | undefined): number { if (value === undefined) return 0; const parsed = Number.parseFloat(value); if (!Number.isFinite(parsed) || parsed <= 0) return 0; return Math.round(parsed * 1e6 * 1e6) / 1e6; } function baseRates(pricing: OpenRouterPricing): ModelCostRates { return { input: toPerMillion(pricing.prompt), output: toPerMillion(pricing.completion), cacheRead: toPerMillion(pricing.input_cache_read), cacheWrite: toPerMillion(pricing.input_cache_write), }; } /** An override only states the rates it changes; the rest inherit the base. */ function fillTier(base: ModelCostRates, override: OpenRouterPricingOverride): ModelCostRates { const rates = { input: toPerMillion(override.prompt), output: toPerMillion(override.completion), cacheRead: toPerMillion(override.input_cache_read), cacheWrite: toPerMillion(override.input_cache_write), }; return { input: rates.input || base.input, output: rates.output || base.output, cacheRead: rates.cacheRead || base.cacheRead, cacheWrite: rates.cacheWrite || base.cacheWrite, }; } /** * Builds the cost block. A model priced at 0/0 is genuinely free rather than * unknown, so it is kept — dropping free models silently hid 22 usable entries. */ export function parseCost(pricing?: OpenRouterPricing | null): ModelCost | undefined { if (!pricing) return undefined; const base = baseRates(pricing); const tiers: ModelCostTier[] = []; for (const override of pricing.overrides ?? []) { if (!override.min_prompt_tokens) continue; tiers.push({ inputTokensAbove: override.min_prompt_tokens, ...fillTier(base, override) }); } tiers.sort((a, b) => a.inputTokensAbove - b.inputTokensAbove); return tiers.length ? { ...base, tiers } : base; } /** * Translates `reasoning.supported_efforts` into Pi's thinkingLevelMap. * Unsupported levels are explicitly null so Pi clamps instead of guessing; * `off` is null when the model reasons mandatorily. */ export function parseThinkingLevels(reasoning?: OpenRouterReasoning | null): ThinkingLevelMap | undefined { const efforts = reasoning?.supported_efforts; if (!efforts?.length) return undefined; const supported = new Set(efforts); const map: ThinkingLevelMap = {}; for (const level of THINKING_LEVELS) { map[level] = supported.has(level) ? level : null; } map.off = reasoning?.mandatory ? null : "off"; return map; } /** `architecture.input_modalities` is what tells us a model accepts images. */ export function parseInputModalities(architecture?: OpenRouterArchitecture | null): ("text" | "image")[] { const modalities = architecture?.input_modalities; if (!modalities?.length) return ["text"]; const input: ("text" | "image")[] = ["text"]; if (modalities.includes("image")) input.push("image"); return input; } export function mapOpenModel(raw: OpenRouterModelRaw): LiveModel | null { if (!raw.id) return null; const cost = parseCost(raw.pricing); if (!cost) return null; // No pricing block at all: we cannot track spend. const reasoning = Boolean(raw.reasoning); const thinkingLevelMap = parseThinkingLevels(raw.reasoning); return { id: raw.id, name: raw.name ?? raw.id, reasoning, input: parseInputModalities(raw.architecture), contextWindow: raw.top_provider?.context_length ?? raw.context_length ?? FALLBACK_CONTEXT_WINDOW, maxTokens: raw.top_provider?.max_completion_tokens ?? FALLBACK_MAX_TOKENS, cost, ...(thinkingLevelMap ? { thinkingLevelMap } : {}), ...(reasoning ? { compat: { thinkingFormat: "openrouter" } } : {}), }; } export function parseCatalog(body: unknown): LiveModel[] { const data = (body as { data?: unknown })?.data; if (!Array.isArray(data)) return []; const models: LiveModel[] = []; for (const raw of data as OpenRouterModelRaw[]) { const model = mapOpenModel(raw); if (model) models.push(model); } return models; } /** * The OpenRouter catalog frozen into the installed pi release. * * Last-resort merge base only. It is a snapshot of one release, so it lacks * both the models Pi's catalog server has shipped since and everything the user * declared in `models.json`; sync.ts prefers the catalog captured live from the * registry and treats this as the floor. */ export function openRouterBaseModels(): readonly LiveModel[] { return getBuiltinModels("openrouter") as unknown as readonly LiveModel[]; } export const openRouterAdapter: CatalogAdapter = { providerId: "openrouter", providerName: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", api: "openai-completions", baseModels: openRouterBaseModels, async fetch(signal: AbortSignal, ifNoneMatch?: string): Promise { const headers: Record = {}; if (ifNoneMatch) headers["If-None-Match"] = ifNoneMatch; const response = await fetch(CATALOG_URL, { signal, headers }); if (response.status === 304) return null; if (!response.ok) throw new Error(`openrouter /models -> HTTP ${response.status}`); return { models: parseCatalog(await response.json()), fetchedAt: Date.now(), etag: response.headers.get("etag") ?? undefined, }; }, };