File size: 8,372 Bytes
ffba252
 
 
 
ee20373
 
 
 
 
 
 
d135f12
ee20373
ffba252
e9acff4
ffba252
 
 
 
ee20373
 
 
 
ffba252
88466b5
 
d756c8e
 
 
ffba252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f9105d
 
ffba252
 
 
 
6f9105d
 
 
 
ffba252
6f9105d
ffba252
 
 
 
 
 
ee20373
 
 
 
 
 
 
e9acff4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ffba252
 
e9acff4
ffba252
 
 
ec9cfc9
ee20373
 
 
ffba252
ee20373
ffba252
ee20373
ffba252
ee20373
 
ec9cfc9
ffba252
 
 
e9acff4
 
 
 
 
ffba252
 
 
 
 
 
 
 
 
c3fc087
 
ec9cfc9
 
 
 
ee20373
 
 
 
 
ffba252
d756c8e
 
 
 
 
 
 
 
 
 
 
88466b5
 
 
d756c8e
 
 
 
 
ffba252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee20373
 
ffba252
ee20373
e9acff4
ee20373
e9acff4
ec9cfc9
 
 
 
 
 
 
 
 
e9acff4
 
 
ffba252
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
'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);
    });
}