'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); }); }