LLMProviders / scripts /providers /openrouter.js
CrispStrobe
fix: propagate model capabilities globally and add missing icons for Whisper/Voxtral
6f9105d
'use strict';
// OpenRouter exposes a public JSON API – no scraping needed.
// Docs: https://openrouter.ai/docs/models
//
// Without an API key: ~342 models (public subset).
// With an API key: ~600+ models including image-gen (FLUX, etc.) and subscriber-only models.
// Set OPENROUTER_API_KEY in env or ../AIToolkit/.env to unlock all models.
const { loadEnv } = require('../load-env');
loadEnv();
const { getJson } = require('../fetch-utils');
const API_URL = 'https://openrouter.ai/api/v1/models';
const EU_API_URL = 'https://eu.openrouter.ai/api/v1/models';
// OpenRouter stores per-token prices; multiply by 1e6 to get per-1M price.
const toPerMillion = (val) => (val ? parseFloat(val) * 1_000_000 : 0);
function loadApiKey() {
return process.env.OPENROUTER_API_KEY || null;
}
const getSizeB = (id) => {
// Relaxed regex: match any digits followed by 'b', even if part of a word like 'e2b' or '30b-a3b'
const match = (id || '').match(/([\d.]+)[Bb](?:\b|:|$)/);
if (!match) return undefined;
const num = parseFloat(match[1]);
return (num > 0 && num < 2000) ? num : undefined;
};
// Derive model type from architecture modalities.
function getModelType(architecture) {
if (!architecture) return 'chat';
const inMods = architecture.input_modalities || [];
const outMods = architecture.output_modalities || [];
if (outMods.includes('audio')) return 'audio';
if (outMods.includes('image')) return 'image';
if (inMods.includes('image') || inMods.includes('video')) return 'vision';
if (inMods.includes('audio')) return 'audio';
return 'chat';
}
// Derive capabilities array from modalities + supported parameters.
function getCapabilities(architecture, supportedParams) {
const caps = [];
const inMods = (architecture?.input_modalities || []);
const outMods = (architecture?.output_modalities || []);
const params = supportedParams || [];
// Inputs
if (inMods.includes('image')) caps.push('vision');
if (inMods.includes('video')) caps.push('video');
if (inMods.includes('audio')) caps.push('audio');
if (inMods.includes('file')) caps.push('files');
// Outputs
if (outMods.includes('image')) caps.push('image-out');
if (outMods.includes('video')) caps.push('video-out');
if (outMods.includes('audio')) caps.push('audio-out');
if (params.includes('tools')) caps.push('tools');
if (params.includes('reasoning')) caps.push('reasoning');
return caps;
}
async function fetchOpenRouter() {
const apiKey = loadApiKey();
const headers = {
Accept: 'application/json',
'HTTP-Referer': 'https://github.com/providers-comparison',
};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
// If we have an API key, use the /user endpoint to get EU-filtered models correctly.
// Standard /models endpoint doesn't filter by subdomain.
const globalUrl = apiKey ? `${API_URL}/user` : API_URL;
const euUrl = apiKey ? `${EU_API_URL}/user` : EU_API_URL;
process.stdout.write('OpenRouter: fetching Global... ');
const globalData = await getJson(globalUrl, { headers });
let euModelIds = new Set();
if (apiKey) {
process.stdout.write('EU... ');
try {
const euData = await getJson(euUrl, { headers });
if (euData?.data) {
euModelIds = new Set(euData.data.map(m => m.id));
}
} catch (e) {
console.warn(`\n ⚠ Failed to fetch EU models: ${e.message}`);
}
}
const models = [];
for (const model of globalData.data || []) {
const pricing = model.pricing || {};
const inputPrice = toPerMillion(pricing.prompt);
const outputPrice = toPerMillion(pricing.completion);
const audioPrice = toPerMillion(pricing.audio);
// pricing.image: per-image cost for image-gen models (e.g. FLUX) — in USD per image
// (NOT the same as per-pixel input cost on vision models like Gemini, which also have prompt price set)
const imagePrice = parseFloat(pricing.image || '0');
// Skip meta-routes with sentinel negative prices (e.g. openrouter/auto)
if (inputPrice < 0 || outputPrice < 0) continue;
// Skip the free-router meta-model
if (model.id === 'openrouter/free') continue;
// Skip models with genuinely zero pricing across all fields (unpriced/placeholder entries).
// Exception: models with a :free suffix are real free models and should be kept.
if (inputPrice === 0 && outputPrice === 0 && imagePrice === 0 && audioPrice === 0 && !model.id.endsWith(':free')) continue;
const type = getModelType(model.architecture);
const capabilities = getCapabilities(model.architecture, model.supported_parameters);
// Tag with eu-endpoint if model is available via EU subdomain
if (euModelIds.has(model.id)) {
capabilities.push('eu-endpoint');
}
const modelEntry = {
name: model.id,
type,
input_price_per_1m: Math.round(inputPrice * 10000) / 10000,
output_price_per_1m: Math.round(outputPrice * 10000) / 10000,
currency: 'USD',
};
if (model.hugging_face_id) modelEntry.hf_id = model.hugging_face_id;
if (audioPrice > 0) {
modelEntry.audio_price_per_1m = Math.round(audioPrice * 10000) / 10000;
}
// For pure image-gen models (no per-token pricing), store the per-image price
if (imagePrice > 0 && inputPrice === 0 && outputPrice === 0) {
modelEntry.price_per_image = Math.round(imagePrice * 100000) / 100000;
}
if (capabilities.length) modelEntry.capabilities = capabilities;
const apiParams = model.architecture?.parameters;
const apiSize = (apiParams && apiParams > 0) ? Math.round(apiParams / 1_000_000_000 * 10) / 10 : null;
// Attempt detection in priority order:
// 1. Explicit architecture parameters from API
// 2. Regex on canonical HF ID if provided by OpenRouter
// 3. Regex on the model description (common for new models missing architecture metadata)
// 4. Regex on the OpenRouter ID itself
let sizeB = apiSize;
if (!sizeB && model.hugging_face_id) sizeB = getSizeB(model.hugging_face_id);
if (!sizeB && model.description) {
// Improved description regex: catch "size of 2B", "effective 2B", "196B parameters", etc.
const descMatch = model.description.match(/([\d.]+)[Bb](?:[ -]parameter| size| effective)/i) ||
model.description.match(/effective parameter size of ([\d.]+)[Bb]/i);
if (descMatch) sizeB = parseFloat(descMatch[1]);
}
if (!sizeB) sizeB = getSizeB(model.id);
if (sizeB) modelEntry.size_b = sizeB;
models.push(modelEntry);
}
// Sort: free first (price=0), then by input price
models.sort((a, b) => {
const aFree = a.input_price_per_1m === 0 ? 1 : 0;
const bFree = b.input_price_per_1m === 0 ? 1 : 0;
if (aFree !== bFree) return aFree - bFree; // paid first, free last
return a.input_price_per_1m - b.input_price_per_1m;
});
return models;
}
module.exports = { fetchOpenRouter, providerName: 'OpenRouter' };
// Run standalone: node scripts/providers/openrouter.js
if (require.main === module) {
fetchOpenRouter()
.then((models) => {
const apiKey = loadApiKey();
const free = models.filter(m => m.input_price_per_1m === 0 && !m.price_per_image);
const vision = models.filter(m => m.type === 'vision');
const imageGen = models.filter(m => m.type === 'image');
const eu = models.filter(m => m.capabilities?.includes('eu-endpoint'));
console.log(`Fetched ${models.length} models from OpenRouter API ${apiKey ? '(authenticated)' : '(public – set OPENROUTER_API_KEY for more models)'}`);
console.log(` Free: ${free.length}, Vision: ${vision.length}, Image-gen: ${imageGen.length}, EU-Endpoint: ${eu.length}`);
const audioModels = models.filter(m => m.audio_price_per_1m > 0);
console.log(` Audio-priced models: ${audioModels.length}`);
console.log('\nSample Audio-priced models:');
audioModels.slice(0, 5).forEach((m) =>
console.log(` ${m.name.padEnd(55)} Audio: $${m.audio_price_per_1m}/M [${m.type}]`)
);
console.log('\nFirst 5 EU-available:');
eu.slice(0, 5).forEach((m) =>
console.log(` ${m.name.padEnd(55)} $${m.input_price_per_1m} / $${m.output_price_per_1m} [${m.type}]`)
);
})
.catch((err) => {
console.error('Error:', err.message);
process.exit(1);
});
}