CrispStrobe commited on
Commit
ffba252
·
0 Parent(s):
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build output
5
+ dist/
6
+
7
+ # Environment files
8
+ .env
9
+ .env.local
10
+ .env.*.local
11
+
12
+ # Editor / OS
13
+ .DS_Store
14
+ *.swp
15
+ *.swo
16
+ .idea/
17
+ .vscode/
18
+
19
+ # Logs
20
+ *.log
21
+ npm-debug.log*
22
+
23
+ # TypeScript build info
24
+ *.tsbuildinfo
data/benchmarks.json ADDED
The diff for this file is too large to render. See raw diff
 
data/providers.json ADDED
The diff for this file is too large to render. See raw diff
 
index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>LLM Provider Comparison</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "providers",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1",
8
+ "dev": "vite",
9
+ "build": "tsc && vite build",
10
+ "preview": "vite preview",
11
+ "fetch": "node scripts/fetch-providers.js",
12
+ "fetch:scaleway": "node scripts/providers/scaleway.js",
13
+ "fetch:openrouter": "node scripts/providers/openrouter.js",
14
+ "fetch:requesty": "node scripts/providers/requesty.js",
15
+ "fetch:nebius": "node scripts/providers/nebius.js",
16
+ "fetch:mistral": "node scripts/providers/mistral.js",
17
+ "fetch:langdock": "node scripts/providers/langdock.js",
18
+ "fetch:groq": "node scripts/providers/groq.js",
19
+ "fetch:infomaniak": "node scripts/providers/infomaniak.js",
20
+ "fetch:ionos": "node scripts/providers/ionos.js",
21
+ "fetch:benchmarks": "node scripts/fetch-benchmarks.js",
22
+ "server": "node server.js",
23
+ "dev:api": "node server.js"
24
+ },
25
+ "keywords": [],
26
+ "author": "",
27
+ "license": "ISC",
28
+ "type": "commonjs",
29
+ "dependencies": {
30
+ "lucide-react": "^0.575.0",
31
+ "react": "^19.2.4",
32
+ "react-dom": "^19.2.4"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "^19.2.14",
36
+ "@types/react-dom": "^19.2.3",
37
+ "@vitejs/plugin-react": "^5.1.4",
38
+ "cheerio": "^1.2.0",
39
+ "express": "^5.2.1",
40
+ "js-yaml": "^4.1.1",
41
+ "typescript": "^5.9.3",
42
+ "vite": "^7.3.1"
43
+ }
44
+ }
scripts/fetch-benchmarks.js ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ /**
4
+ * Fetch benchmark data from five sources and merge into data/benchmarks.json.
5
+ *
6
+ * Sources:
7
+ * 1. AchilleasDrakou/LLMStats on GitHub (71 curated models, self-reported benchmarks)
8
+ * 2. open-llm-leaderboard/contents on Hugging Face (4500+ open models, standardised evals)
9
+ * 3. LiveBench (livebench.ai) — contamination-free, monthly, 70+ frontier models
10
+ * 4. Chatbot Arena (lmarena.ai) — 316 models with real ELO ratings from human votes
11
+ * 5. Aider (aider.chat) — code editing benchmark, 133 tasks per model
12
+ *
13
+ * Unified field names (0-1 scale unless noted):
14
+ * mmlu, mmlu_pro, gpqa, human_eval, math, gsm8k, mmmu,
15
+ * hellaswag, ifeval, arc, drop, mbpp, mgsm, bbh (from LLMStats)
16
+ * hf_math_lvl5, hf_musr, hf_avg, params_b (HF-only)
17
+ * lb_name, lb_global, lb_reasoning, lb_coding, (LiveBench, 0-1)
18
+ * lb_math, lb_language, lb_if, lb_data_analysis
19
+ * arena_elo, arena_rank, arena_votes (Chatbot Arena; elo is raw ELO ~800-1500)
20
+ * aider_pass_rate (Aider edit bench, 0-1)
21
+ *
22
+ * Where both sources have data for the same benchmark (gpqa, mmlu_pro, ifeval, bbh),
23
+ * LLMStats takes priority (it stores self-reported model-card values).
24
+ *
25
+ * Usage: node scripts/fetch-benchmarks.js
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const yaml = require('js-yaml');
31
+
32
+ const OUT_FILE = path.join(__dirname, '..', 'data', 'benchmarks.json');
33
+
34
+ // ─── helpers ────────────────────────────────────────────────────────────────
35
+
36
+ async function getJson(url) {
37
+ const res = await fetch(url, {
38
+ headers: { 'User-Agent': 'providers-benchmark-fetcher', Accept: 'application/json' },
39
+ });
40
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
41
+ return res.json();
42
+ }
43
+
44
+ const normName = (s) =>
45
+ (s || '').toLowerCase().replace(/[-_.]/g, ' ').replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
46
+
47
+ // ─── LLMStats ───────────────────────────────────────────────────────────────
48
+
49
+ const LLMSTATS_TREE = 'https://api.github.com/repos/AchilleasDrakou/LLMStats/git/trees/main?recursive=1';
50
+ const LLMSTATS_RAW = 'https://raw.githubusercontent.com/AchilleasDrakou/LLMStats/main/';
51
+
52
+ const LLMSTATS_MAP = {
53
+ mmlu: ['MMLU', 'MMLU Chat', 'MMLU-Base', 'MMLU (CoT)', 'Multilingual MMLU'],
54
+ mmlu_pro: ['MMLU-Pro', 'MMLU-STEM', 'Multilingual MMLU-Pro'],
55
+ gpqa: ['GPQA'],
56
+ human_eval: ['HumanEval', 'Humaneval', 'HumanEval+', 'HumanEval-Average', 'Instruct HumanEval', 'MBPP EvalPlus', 'EvalPlus', 'Evalplus'],
57
+ math: ['MATH', 'Math', 'MATH (CoT)', 'MATH-500', 'Functional_MATH', 'FunctionalMATH'],
58
+ gsm8k: ['GSM8K', 'GSM-8K', 'GSM8k', 'GSM8K Chat', 'GSM-8K (CoT)'],
59
+ mmmu: ['MMMU', 'MMMUval', 'MMMU-Pro'],
60
+ hellaswag: ['HellaSwag', 'HellaSWAG', 'Hellaswag'],
61
+ ifeval: ['IFEval', 'IF-Eval'],
62
+ arc: ['ARC Challenge', 'ARC-C', 'ARC-c', 'ARC-e', 'ARC-Challenge', 'AI2 Reasoning Challenge (ARC)'],
63
+ drop: ['DROP'],
64
+ mbpp: ['MBPP', 'MBPP+', 'MBPP++', 'MBPP pass@1', 'MBPP EvalPlus (base)'],
65
+ mgsm: ['MGSM', 'Multilingual MGSM', 'Multilingual MGSM (CoT)'],
66
+ bbh: ['BBH', 'BigBench Hard CoT', 'BIG-Bench-Hard', 'BigBench-Hard', 'BIG-Bench Hard', 'BigBench_Hard'],
67
+ };
68
+
69
+ function extractLLMStatsMetrics(qualitative_metrics) {
70
+ const scores = {};
71
+ for (const m of qualitative_metrics || []) {
72
+ for (const [key, names] of Object.entries(LLMSTATS_MAP)) {
73
+ if (names.some((n) => m.dataset_name === n) && scores[key] === undefined) {
74
+ scores[key] = m.score;
75
+ }
76
+ }
77
+ }
78
+ return scores;
79
+ }
80
+
81
+ async function fetchLLMStats() {
82
+ process.stdout.write('LLMStats: fetching file list... ');
83
+ const tree = await getJson(LLMSTATS_TREE);
84
+ const files = tree.tree.filter(
85
+ (f) => f.type === 'blob' && f.path.startsWith('models/') && f.path.endsWith('/model.json')
86
+ );
87
+ console.log(`${files.length} models`);
88
+
89
+ const results = [];
90
+ const BATCH = 10;
91
+ for (let i = 0; i < files.length; i += BATCH) {
92
+ const batch = files.slice(i, i + BATCH);
93
+ const rows = await Promise.all(batch.map(async (f) => {
94
+ try {
95
+ const data = await getJson(LLMSTATS_RAW + f.path);
96
+ const slug = f.path.replace(/^models\//, '').replace(/\/model\.json$/, '');
97
+ return { slug, name: data.name, ...extractLLMStatsMetrics(data.qualitative_metrics) };
98
+ } catch (e) {
99
+ console.warn(`\n ⚠ LLMStats ${f.path}: ${e.message}`);
100
+ return null;
101
+ }
102
+ }));
103
+ rows.forEach((r) => { if (r) results.push(r); });
104
+ process.stdout.write(` LLMStats: ${Math.min(i + BATCH, files.length)}/${files.length}\r`);
105
+ }
106
+ console.log(` LLMStats: ${results.length} entries fetched `);
107
+ return results;
108
+ }
109
+
110
+ // ��── HF Leaderboard ─────────────────────────────────────────────────────────
111
+
112
+ const HF_ROWS_URL = 'https://datasets-server.huggingface.co/rows' +
113
+ '?dataset=open-llm-leaderboard%2Fcontents&config=default&split=train';
114
+
115
+ async function fetchHFPage(offset, limit = 100) {
116
+ const data = await getJson(`${HF_ROWS_URL}&offset=${offset}&limit=${limit}`);
117
+ return { rows: data.rows.map((r) => r.row), total: data.num_rows_total };
118
+ }
119
+
120
+ async function fetchHFLeaderboard() {
121
+ process.stdout.write('HF Leaderboard: probing total... ');
122
+ const first = await fetchHFPage(0, 1);
123
+ const total = first.total;
124
+ console.log(`${total} rows`);
125
+
126
+ const LIMIT = 100;
127
+ const pages = Math.ceil(total / LIMIT);
128
+ const allRows = [...first.rows];
129
+
130
+ // Fetch remaining pages in batches of 5 concurrent requests
131
+ const CONCURRENT = 5;
132
+ for (let p = 1; p < pages; p += CONCURRENT) {
133
+ const batch = [];
134
+ for (let q = p; q < Math.min(p + CONCURRENT, pages); q++) {
135
+ batch.push(fetchHFPage(q * LIMIT, LIMIT));
136
+ }
137
+ const results = await Promise.all(batch);
138
+ results.forEach((r) => allRows.push(...r.rows));
139
+ const done = Math.min((p + CONCURRENT) * LIMIT, total);
140
+ process.stdout.write(` HF: ${done}/${total}\r`);
141
+ }
142
+ console.log(` HF: ${total}/${total} — filtering... `);
143
+
144
+ // The Average column name has a Unicode emoji
145
+ const AVG_KEY = Object.keys(allRows[0]).find((k) => k.startsWith('Average'));
146
+
147
+ const entries = allRows
148
+ .filter((r) => r['Available on the hub'] && !r.Flagged)
149
+ .map((r) => {
150
+ const entry = {
151
+ hf_id: r.fullname,
152
+ name: r.fullname.split('/').pop(),
153
+ };
154
+ if (r['#Params (B)']) entry.params_b = r['#Params (B)'];
155
+ if (r['IFEval Raw']) entry.ifeval = r['IFEval Raw'];
156
+ if (r['BBH Raw']) entry.bbh = r['BBH Raw'];
157
+ if (r['GPQA Raw']) entry.gpqa = r['GPQA Raw'];
158
+ if (r['MMLU-PRO Raw']) entry.mmlu_pro = r['MMLU-PRO Raw'];
159
+ if (r['MATH Lvl 5 Raw']) entry.hf_math_lvl5 = r['MATH Lvl 5 Raw'];
160
+ if (r['MUSR Raw']) entry.hf_musr = r['MUSR Raw'];
161
+ if (AVG_KEY && r[AVG_KEY]) entry.hf_avg = r[AVG_KEY];
162
+ return entry;
163
+ });
164
+
165
+ console.log(` HF: ${entries.length} entries after filtering`);
166
+ return entries;
167
+ }
168
+
169
+ // ─── LiveBench ───────────────────────────────────────────────────────────────
170
+
171
+ const LB_GITHUB_TREE = 'https://api.github.com/repos/LiveBench/livebench.github.io/git/trees/main?recursive=1';
172
+ const LB_BASE_URL = 'https://livebench.ai';
173
+
174
+ // Suffixes LiveBench appends to model names that providers don't use.
175
+ // We strip these to produce a "base" name for matching.
176
+ const LB_SUFFIX_RE = new RegExp(
177
+ '(-thinking-(?:auto-)?(?:\\d+k-)?(?:(?:high|medium|low)-effort)?|' +
178
+ '-thinking(?:-(?:64k|32k|auto|minimal))?|' +
179
+ '-(?:high|medium|low)-effort|' +
180
+ '-base|-non-?reasoning|-(?:high|low|min)thinking|-nothinking)' +
181
+ '(?:-(?:high|medium|low)-effort)?$' // handle double-suffix like -thinking-64k-high-effort
182
+ );
183
+
184
+ function lbBaseName(name) {
185
+ // Repeatedly strip known suffixes until stable
186
+ let prev;
187
+ let cur = name;
188
+ do { prev = cur; cur = cur.replace(LB_SUFFIX_RE, ''); } while (cur !== prev);
189
+ return cur;
190
+ }
191
+
192
+ function parseLiveBenchCsv(csvText, taskToGroup) {
193
+ const avg = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
194
+ const lines = csvText.split('\n').filter(Boolean);
195
+ const headers = lines[0].split(',');
196
+ const entries = [];
197
+ for (const line of lines.slice(1)) {
198
+ const vals = line.split(',');
199
+ const modelName = vals[0];
200
+ if (!modelName) continue;
201
+ const taskScores = {};
202
+ for (let i = 1; i < headers.length; i++) {
203
+ const v = parseFloat(vals[i]);
204
+ if (!isNaN(v)) taskScores[headers[i]] = v / 100;
205
+ }
206
+ const groupBuckets = {};
207
+ for (const [task, group] of Object.entries(taskToGroup)) {
208
+ if (taskScores[task] !== undefined) {
209
+ groupBuckets[group] = groupBuckets[group] || [];
210
+ groupBuckets[group].push(taskScores[task]);
211
+ }
212
+ }
213
+ const allScores = Object.values(taskScores);
214
+ entries.push({
215
+ lb_name: modelName,
216
+ lb_global: allScores.length ? avg(allScores) : undefined,
217
+ lb_reasoning: groupBuckets.lb_reasoning ? avg(groupBuckets.lb_reasoning) : undefined,
218
+ lb_coding: groupBuckets.lb_coding ? avg(groupBuckets.lb_coding) : undefined,
219
+ lb_math: groupBuckets.lb_math ? avg(groupBuckets.lb_math) : undefined,
220
+ lb_language: groupBuckets.lb_language ? avg(groupBuckets.lb_language) : undefined,
221
+ lb_if: groupBuckets.lb_if ? avg(groupBuckets.lb_if) : undefined,
222
+ lb_data_analysis: groupBuckets.lb_data_analysis ? avg(groupBuckets.lb_data_analysis) : undefined,
223
+ });
224
+ }
225
+ return entries;
226
+ }
227
+
228
+ async function fetchLiveBench() {
229
+ process.stdout.write('LiveBench: finding all releases... ');
230
+ const tree = await getJson(LB_GITHUB_TREE);
231
+ const dates = tree.tree
232
+ .filter((f) => f.path.startsWith('public/table_') && f.path.endsWith('.csv'))
233
+ .map((f) => f.path.replace('public/table_', '').replace('.csv', ''))
234
+ .sort(); // oldest first
235
+ console.log(`${dates.length} releases (${dates[0]} → ${dates[dates.length - 1]})`);
236
+
237
+ // Use task→group mapping from the latest categories JSON (stable across releases)
238
+ const cats = await fetch(`${LB_BASE_URL}/categories_${dates[dates.length - 1]}.json`, {
239
+ headers: { 'User-Agent': 'providers-benchmark-fetcher' },
240
+ }).then((r) => r.json());
241
+
242
+ const taskToGroup = {};
243
+ for (const [cat, tasks] of Object.entries(cats)) {
244
+ const group =
245
+ cat === 'Coding' || cat === 'Agentic Coding' ? 'lb_coding' :
246
+ cat === 'Reasoning' ? 'lb_reasoning' :
247
+ cat === 'Mathematics' ? 'lb_math' :
248
+ cat === 'Language' ? 'lb_language' :
249
+ cat === 'IF' ? 'lb_if' :
250
+ cat === 'Data Analysis' ? 'lb_data_analysis' : null;
251
+ if (group) for (const t of tasks) taskToGroup[t] = group;
252
+ }
253
+
254
+ // Fetch all releases (oldest→newest), so newer results overwrite older ones per model
255
+ // Map: lb_name → entry (most recent release wins)
256
+ const byName = new Map();
257
+ for (const date of dates) {
258
+ let csv;
259
+ try {
260
+ csv = await fetch(`${LB_BASE_URL}/table_${date}.csv`, {
261
+ headers: { 'User-Agent': 'providers-benchmark-fetcher' },
262
+ }).then((r) => { if (!r.ok) throw new Error(`${r.status}`); return r.text(); });
263
+ } catch (e) {
264
+ console.warn(`\n ⚠ LiveBench ${date}: ${e.message}`);
265
+ continue;
266
+ }
267
+ for (const entry of parseLiveBenchCsv(csv, taskToGroup)) {
268
+ byName.set(entry.lb_name, entry); // newer release overwrites
269
+ }
270
+ process.stdout.write(` LiveBench: ${date}\r`);
271
+ }
272
+
273
+ const entries = [...byName.values()];
274
+ console.log(` LiveBench: ${entries.length} unique models across all releases`);
275
+ return entries;
276
+ }
277
+
278
+ function mergeLiveBench(entries, lbEntries) {
279
+ // Build two lookups:
280
+ // exact: normalized lb_name → entry
281
+ // base: normalized base-name (suffixes stripped) → best-scoring entry among variants
282
+ const exactMap = new Map();
283
+ const baseMap = new Map(); // base → best lb entry by lb_global
284
+
285
+ for (const lb of lbEntries) {
286
+ exactMap.set(normName(lb.lb_name), lb);
287
+ const base = normName(lbBaseName(lb.lb_name));
288
+ if (base !== normName(lb.lb_name)) {
289
+ const prev = baseMap.get(base);
290
+ if (!prev || (lb.lb_global || 0) > (prev.lb_global || 0)) baseMap.set(base, lb);
291
+ }
292
+ }
293
+
294
+ // Track which lb entries have been used (to avoid adding them as standalone new entries)
295
+ const usedLbNames = new Set();
296
+
297
+ let matched = 0;
298
+ for (const e of entries) {
299
+ const candidates = [
300
+ normName(e.name || ''),
301
+ normName((e.slug || '').split('/').pop() || ''),
302
+ normName((e.hf_id || '').split('/').pop() || ''),
303
+ ].filter(Boolean);
304
+
305
+ let lb = null;
306
+ for (const c of candidates) {
307
+ lb = exactMap.get(c) || baseMap.get(c);
308
+ if (lb) break;
309
+ }
310
+ if (lb) {
311
+ Object.assign(e, lb);
312
+ usedLbNames.add(lb.lb_name);
313
+ matched++;
314
+ }
315
+ }
316
+
317
+ // Add standalone entries for lbEntries not matched above.
318
+ // Skip variants whose base was already matched (avoid duplicating e.g. all -effort variants).
319
+ // Use the base model name (without -high-effort etc.) as the entry name so that
320
+ // provider model names (which have no effort suffixes) can find this entry.
321
+ const usedBases = new Set([...usedLbNames].map((n) => normName(lbBaseName(n))));
322
+ const newEntries = [];
323
+ for (const lb of lbEntries) {
324
+ if (usedLbNames.has(lb.lb_name)) continue;
325
+ const base = normName(lbBaseName(lb.lb_name));
326
+ if (usedBases.has(base)) continue; // a variant of a matched model — skip
327
+ // Only add the best-scoring variant of each base group
328
+ if (baseMap.get(base) === lb || exactMap.get(normName(lb.lb_name)) === lb) {
329
+ const baseName = lbBaseName(lb.lb_name); // e.g. "claude-opus-4-5-20251101"
330
+ newEntries.push({ name: baseName, ...lb }); // name uses base; lb_name keeps variant
331
+ usedBases.add(base);
332
+ }
333
+ }
334
+
335
+ console.log(` LiveBench: ${matched} matched, ${newEntries.length} new entries`);
336
+ return [...entries, ...newEntries];
337
+ }
338
+
339
+ // ─── Merge ───────────────────────────────────────────────────────────────────
340
+
341
+ function mergeEntries(llmstats, hfEntries) {
342
+ // Build lookup: normalized LLMStats name/slug → entry index
343
+ const lsIdx = new Map();
344
+ llmstats.forEach((e, i) => {
345
+ lsIdx.set(normName(e.name), i);
346
+ const slugModel = e.slug?.split('/').pop() || '';
347
+ if (slugModel) lsIdx.set(normName(slugModel), i);
348
+ });
349
+
350
+ const merged = llmstats.map((e) => ({ ...e }));
351
+ const hfOnly = [];
352
+
353
+ for (const hf of hfEntries) {
354
+ // Try matching by the model name part of the HF ID
355
+ const modelPart = normName(hf.name);
356
+ // Also try stripping a leading word (org prefix embedded in model name like "Meta-Llama-...")
357
+ const modelWords = modelPart.split(' ');
358
+ const modelNoPrefix = modelWords.length > 1 ? modelWords.slice(1).join(' ') : modelPart;
359
+
360
+ const idx = lsIdx.get(modelPart) ?? lsIdx.get(modelNoPrefix);
361
+ if (idx !== undefined) {
362
+ // Merge HF fields into LLMStats entry (LLMStats wins for shared benchmarks)
363
+ const target = merged[idx];
364
+ if (!target.hf_id) target.hf_id = hf.hf_id;
365
+ if (!target.params_b) target.params_b = hf.params_b;
366
+ if (!target.ifeval) target.ifeval = hf.ifeval;
367
+ if (!target.bbh) target.bbh = hf.bbh;
368
+ if (!target.gpqa) target.gpqa = hf.gpqa;
369
+ if (!target.mmlu_pro) target.mmlu_pro = hf.mmlu_pro;
370
+ target.hf_math_lvl5 = hf.hf_math_lvl5;
371
+ target.hf_musr = hf.hf_musr;
372
+ target.hf_avg = hf.hf_avg;
373
+ } else {
374
+ hfOnly.push(hf);
375
+ }
376
+ }
377
+
378
+ return [...merged, ...hfOnly];
379
+ }
380
+
381
+ // ─── Chatbot Arena ───────────────────────────────────────────────────────────
382
+
383
+ async function fetchChatbotArena() {
384
+ process.stdout.write('Chatbot Arena: fetching RSC leaderboard... ');
385
+
386
+ // The lmarena.ai leaderboard page renders via React Server Components.
387
+ // Requesting with "RSC: 1" returns a streaming text/x-component payload that
388
+ // embeds the full leaderboard entries (rank, ELO rating, votes) in the server
389
+ // response — no authentication required.
390
+ const text = await fetch('https://lmarena.ai/en/leaderboard/text', {
391
+ headers: {
392
+ 'User-Agent': 'Mozilla/5.0',
393
+ 'RSC': '1',
394
+ 'Accept': 'text/x-component',
395
+ },
396
+ }).then((r) => {
397
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
398
+ return r.text();
399
+ });
400
+
401
+ // Each RSC line has the format: <hex_id>:<json_value>
402
+ // Find the line containing "entries":[...] with ELO ratings
403
+ let entries = null;
404
+ for (const line of text.split('\n')) {
405
+ if (!line.includes('"entries":[') || !line.includes('"rating":')) continue;
406
+ const start = line.indexOf('"entries":[') + '"entries":'.length;
407
+ let depth = 0, end = -1;
408
+ for (let i = start; i < line.length; i++) {
409
+ if (line[i] === '[' || line[i] === '{') depth++;
410
+ else if (line[i] === ']' || line[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
411
+ }
412
+ entries = JSON.parse(line.substring(start, end));
413
+ break;
414
+ }
415
+
416
+ if (!entries) throw new Error('Could not find entries in RSC payload');
417
+ console.log(`${entries.length} models`);
418
+
419
+ return entries.map((e) => ({
420
+ arena_name: e.modelDisplayName,
421
+ arena_org: e.modelOrganization,
422
+ arena_elo: e.rating,
423
+ arena_rank: e.rank,
424
+ arena_votes: e.votes,
425
+ }));
426
+ }
427
+
428
+ function mergeArena(entries, arenaEntries) {
429
+ const arenaMap = new Map();
430
+ for (const a of arenaEntries) arenaMap.set(normName(a.arena_name), a);
431
+
432
+ let matched = 0;
433
+ for (const e of entries) {
434
+ const candidates = [
435
+ normName(e.name || ''),
436
+ normName((e.lb_name) || ''),
437
+ normName((e.slug || '').split('/').pop() || ''),
438
+ normName((e.hf_id || '').split('/').pop() || ''),
439
+ ];
440
+ const a = candidates.map((c) => arenaMap.get(c)).find(Boolean);
441
+ if (a) {
442
+ e.arena_elo = a.arena_elo;
443
+ e.arena_rank = a.arena_rank;
444
+ e.arena_votes = a.arena_votes;
445
+ arenaMap.delete(normName(a.arena_name));
446
+ matched++;
447
+ }
448
+ }
449
+
450
+ const newEntries = [];
451
+ for (const a of arenaMap.values()) {
452
+ newEntries.push({ name: a.arena_name, ...a });
453
+ }
454
+
455
+ console.log(` Arena: ${matched} matched, ${newEntries.length} new entries`);
456
+ return [...entries, ...newEntries];
457
+ }
458
+
459
+ // ─── Aider ───────────────────────────────────────────────────────────────────
460
+
461
+ const AIDER_RAW = 'https://raw.githubusercontent.com/Aider-AI/aider/main/aider/website/_data/edit_leaderboard.yml';
462
+
463
+ async function fetchAider() {
464
+ process.stdout.write('Aider: fetching edit leaderboard... ');
465
+ const text = await fetch(AIDER_RAW, { headers: { 'User-Agent': 'providers-benchmark-fetcher' } }).then((r) => {
466
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
467
+ return r.text();
468
+ });
469
+
470
+ const rows = yaml.load(text);
471
+
472
+ // Multiple runs per model — keep the one with the best pass_rate_1
473
+ const best = new Map();
474
+ for (const row of rows) {
475
+ if (!row.model || row.pass_rate_1 === undefined) continue;
476
+ const key = normName(row.model);
477
+ const existing = best.get(key);
478
+ if (!existing || row.pass_rate_1 > existing.pass_rate_1) best.set(key, row);
479
+ }
480
+
481
+ const entries = [];
482
+ for (const row of best.values()) {
483
+ entries.push({
484
+ aider_model: row.model,
485
+ aider_pass_rate: row.pass_rate_1 / 100, // normalize 0-100 → 0-1
486
+ });
487
+ }
488
+
489
+ console.log(`${entries.length} models (best run each)`);
490
+ return entries;
491
+ }
492
+
493
+ function mergeAider(entries, aiderEntries) {
494
+ const aiderMap = new Map();
495
+ for (const a of aiderEntries) aiderMap.set(normName(a.aider_model), a);
496
+
497
+ let matched = 0;
498
+ for (const e of entries) {
499
+ const candidates = [
500
+ normName(e.name || ''),
501
+ normName((e.lb_name) || ''),
502
+ normName((e.slug || '').split('/').pop() || ''),
503
+ normName((e.hf_id || '').split('/').pop() || ''),
504
+ normName((e.arena_name) || ''),
505
+ ];
506
+ const a = candidates.map((c) => aiderMap.get(c)).find(Boolean);
507
+ if (a) {
508
+ e.aider_pass_rate = a.aider_pass_rate;
509
+ aiderMap.delete(normName(a.aider_model));
510
+ matched++;
511
+ }
512
+ }
513
+
514
+ const newEntries = [];
515
+ for (const a of aiderMap.values()) {
516
+ newEntries.push({ name: a.aider_model, aider_pass_rate: a.aider_pass_rate });
517
+ }
518
+
519
+ console.log(` Aider: ${matched} matched, ${newEntries.length} new entries`);
520
+ return [...entries, ...newEntries];
521
+ }
522
+
523
+ // ─── Main ────────────────────────────────────────────────────────────────────
524
+
525
+ async function main() {
526
+ const [llmstats, hfEntries, lbEntries, arenaEntries, aiderEntries] = await Promise.all([
527
+ fetchLLMStats(),
528
+ fetchHFLeaderboard(),
529
+ fetchLiveBench(),
530
+ fetchChatbotArena(),
531
+ fetchAider(),
532
+ ]);
533
+
534
+ const merged = mergeEntries(llmstats, hfEntries);
535
+ const withLB = mergeLiveBench(merged, lbEntries);
536
+ const withAr = mergeArena(withLB, arenaEntries);
537
+ const all = mergeAider(withAr, aiderEntries);
538
+
539
+ const hfOnlyCount = all.filter((e) => e.hf_id && !e.slug).length;
540
+ const lsOnlyCount = all.filter((e) => e.slug && !e.hf_id).length;
541
+ const bothCount = all.filter((e) => e.slug && e.hf_id).length;
542
+ const lbCount = all.filter((e) => e.lb_name).length;
543
+ const arenaCount = all.filter((e) => e.arena_elo).length;
544
+ const aiderCount = all.filter((e) => e.aider_pass_rate !== undefined).length;
545
+ console.log(`\nTotal entries: ${all.length}`);
546
+ console.log(` LLMStats only: ${lsOnlyCount} | HF only: ${hfOnlyCount} | Both: ${bothCount}`);
547
+ console.log(` With LiveBench: ${lbCount} | With Arena ELO: ${arenaCount} | With Aider: ${aiderCount}`);
548
+
549
+ fs.writeFileSync(OUT_FILE, JSON.stringify(all, null, 2));
550
+ console.log(`Saved to data/benchmarks.json (${(fs.statSync(OUT_FILE).size / 1024).toFixed(0)} KB)`);
551
+ }
552
+
553
+ main().catch((err) => {
554
+ console.error('Fatal:', err);
555
+ process.exit(1);
556
+ });
scripts/fetch-providers.js ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ /**
4
+ * Fetch live pricing data from all supported providers and update data/providers.json.
5
+ *
6
+ * Usage:
7
+ * node scripts/fetch-providers.js # fetch all providers
8
+ * node scripts/fetch-providers.js scaleway # fetch only Scaleway
9
+ * node scripts/fetch-providers.js openrouter # fetch only OpenRouter
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const DATA_FILE = path.join(__dirname, '..', 'data', 'providers.json');
16
+
17
+ // Registry of all available fetchers.
18
+ // Each module must export { providerName, fetch<Name> }.
19
+ // Add new providers here as scripts/providers/<name>.js modules.
20
+ const FETCHER_MODULES = {
21
+ scaleway: require('./providers/scaleway'),
22
+ openrouter: require('./providers/openrouter'),
23
+ requesty: require('./providers/requesty'),
24
+ nebius: require('./providers/nebius'),
25
+ mistral: require('./providers/mistral'),
26
+ langdock: require('./providers/langdock'),
27
+ groq: require('./providers/groq'),
28
+ infomaniak: require('./providers/infomaniak'),
29
+ ionos: require('./providers/ionos'),
30
+ };
31
+
32
+ const FETCHERS = Object.entries(FETCHER_MODULES).map(([key, mod]) => {
33
+ // Find the exported async function (the one that isn't providerName)
34
+ const fn = Object.values(mod).find((v) => typeof v === 'function');
35
+ if (!fn) throw new Error(`Module for ${key} exports no function`);
36
+ return { key, providerName: mod.providerName, fn };
37
+ });
38
+
39
+ function loadData() {
40
+ return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
41
+ }
42
+
43
+ function saveData(data) {
44
+ fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
45
+ }
46
+
47
+ function updateProviderModels(providers, providerName, models) {
48
+ const provider = providers.find((p) => p.name === providerName);
49
+ if (!provider) {
50
+ console.warn(` ⚠ Provider "${providerName}" not found in providers.json – skipping.`);
51
+ return false;
52
+ }
53
+ provider.models = models;
54
+ return true;
55
+ }
56
+
57
+ // Normalize a model name/ID for fuzzy matching (same as App.tsx normalizeName).
58
+ const normName = (s) =>
59
+ s.toLowerCase().replace(/[-_.:]/g, ' ').replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
60
+
61
+ // Build an index of normalized OpenRouter model-part → { capabilities, type }
62
+ // Only includes entries that carry non-trivial capability data.
63
+ function buildOrIndex(orProvider) {
64
+ if (!orProvider) return [];
65
+ const index = [];
66
+ for (const m of orProvider.models || []) {
67
+ if (!m.capabilities || m.capabilities.length === 0) continue;
68
+ // Strip :free suffix and take the model part after '/'
69
+ const modelPart = m.name.replace(/:free$/, '').split('/').pop();
70
+ index.push({ norm: normName(modelPart), capabilities: m.capabilities, type: m.type });
71
+ }
72
+ return index;
73
+ }
74
+
75
+ // For a given model name, find the best matching OpenRouter index entry.
76
+ // Returns { capabilities, type } or null.
77
+ function findOrMatch(modelName, orIndex) {
78
+ // Use the model part (after last '/') for matching, strip :region/@suffix
79
+ const raw = modelName.replace(/@[^/]+$/, '').replace(/:[^/]+$/, '');
80
+ const modelPart = raw.includes('/') ? raw.split('/').pop() : raw;
81
+ const n = normName(modelPart);
82
+
83
+ // 1. Exact match
84
+ for (const entry of orIndex) {
85
+ if (entry.norm === n) return entry;
86
+ }
87
+ // 2. Provider model name starts with OR model part (e.g. "claude-3-5-sonnet-20241022" starts with "claude-3-5-sonnet")
88
+ let best = null;
89
+ let bestLen = 0;
90
+ for (const entry of orIndex) {
91
+ if (n.startsWith(entry.norm) && entry.norm.length > bestLen) {
92
+ best = entry;
93
+ bestLen = entry.norm.length;
94
+ }
95
+ }
96
+ if (best) return best;
97
+ // 3. OR model part starts with provider name (e.g. "claude-haiku-4-5" → "claude-haiku-4-5-20251001")
98
+ for (const entry of orIndex) {
99
+ if (entry.norm.startsWith(n + ' ')) return entry;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ // Propagate capabilities from OpenRouter to all other providers' models.
105
+ // Only fills in capabilities/type when the model doesn't already have them.
106
+ function propagateCapabilities(data) {
107
+ const orProvider = data.providers.find((p) => p.name === 'OpenRouter');
108
+ const orIndex = buildOrIndex(orProvider);
109
+ if (orIndex.length === 0) return;
110
+
111
+ let propagated = 0;
112
+ for (const provider of data.providers) {
113
+ if (provider.name === 'OpenRouter') continue;
114
+ for (const model of provider.models || []) {
115
+ if (model.capabilities && model.capabilities.length > 0) continue; // already set
116
+ const match = findOrMatch(model.name, orIndex);
117
+ if (!match) continue;
118
+ model.capabilities = match.capabilities;
119
+ // Update type only if currently 'chat' (don't demote image/embedding/audio)
120
+ if (model.type === 'chat' && match.type !== 'chat') model.type = match.type;
121
+ propagated++;
122
+ }
123
+ }
124
+ if (propagated > 0) console.log(`\nPropagated capabilities to ${propagated} models from OpenRouter.`);
125
+ }
126
+
127
+ async function runFetcher(fetcher, data) {
128
+ const { key, providerName, fn } = fetcher;
129
+
130
+ try {
131
+ process.stdout.write(`Fetching ${providerName}... `);
132
+ const models = await fn();
133
+ const updated = updateProviderModels(data.providers, providerName, models);
134
+ if (updated) console.log(`✓ ${models.length} models`);
135
+ return { key, providerName, success: true, count: models.length };
136
+ } catch (err) {
137
+ console.log(`✗ ${err.message}`);
138
+ return { key, providerName, success: false, error: err.message };
139
+ }
140
+ }
141
+
142
+ async function main() {
143
+ // Determine which fetchers to run
144
+ const args = process.argv.slice(2).map((a) => a.toLowerCase());
145
+ const fetchers =
146
+ args.length > 0
147
+ ? FETCHERS.filter((f) => args.includes(f.key))
148
+ : FETCHERS;
149
+
150
+ if (fetchers.length === 0) {
151
+ console.error('No matching fetchers found. Available:', FETCHERS.map((f) => f.key).join(', '));
152
+ process.exit(1);
153
+ }
154
+
155
+ const data = loadData();
156
+ console.log(`Running ${fetchers.length} fetcher(s)...\n`);
157
+
158
+ const results = [];
159
+ for (const fetcher of fetchers) {
160
+ const result = await runFetcher(fetcher, data);
161
+ results.push(result);
162
+ }
163
+
164
+ // When all providers are fetched (or OpenRouter was included), propagate capabilities.
165
+ const fetchedKeys = new Set(results.filter((r) => r.success).map((r) => r.key));
166
+ if (fetchedKeys.has('openrouter')) propagateCapabilities(data);
167
+
168
+ saveData(data);
169
+
170
+ console.log('\nSummary:');
171
+ let anyFailed = false;
172
+ results.forEach((r) => {
173
+ if (r.success) console.log(` ✓ ${r.providerName}: ${r.count} models`);
174
+ else {
175
+ console.log(` ✗ ${r.providerName}: ${r.error}`);
176
+ anyFailed = true;
177
+ }
178
+ });
179
+
180
+ if (anyFailed) process.exit(1);
181
+ }
182
+
183
+ main().catch((err) => {
184
+ console.error('Fatal:', err);
185
+ process.exit(1);
186
+ });
scripts/providers/groq.js ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ /**
4
+ * Groq pricing fetcher.
5
+ *
6
+ * groq.com/pricing is a Next.js App Router page (RSC streaming) with real
7
+ * <table class="type-ui-1"> elements rendered server-side — cheerio works.
8
+ *
9
+ * Tables on the page:
10
+ * 1. Large Language Models → chat models, cols: name / speed / input ($/M tok) / output ($/M tok)
11
+ * 2. Text-to-Speech → audio, cols: name / chars/s / price ($/M chars)
12
+ * 3. ASR (Whisper) → audio, cols: name / speed / price ($/hr transcribed)
13
+ * 4-6. Caching / Tools → skip
14
+ */
15
+
16
+ const cheerio = require('cheerio');
17
+
18
+ const URL = 'https://groq.com/pricing';
19
+
20
+ const parseUsd = (text) => {
21
+ if (!text) return null;
22
+ const clean = text.trim();
23
+ if (!clean || clean === '-' || clean === '–') return null;
24
+ const m = clean.match(/\$?([\d]+\.[\d]*|[\d]+)/);
25
+ return m ? parseFloat(m[1]) : null;
26
+ };
27
+
28
+ const getSizeB = (name) => {
29
+ const m = (name || '').match(/[^.\d](\d+)[Bb]/) || (name || '').match(/^(\d+)[Bb]/);
30
+ return m ? parseInt(m[1]) : undefined;
31
+ };
32
+
33
+ // Extract the first meaningful span text from a td cell
34
+ // Groq wraps content in: td > div > div[class*=contents-inner] > span
35
+ const cellText = ($, cell) => {
36
+ // Try the inner contents div first, fall back to any span
37
+ const inner = $(cell).find('[class*="contents-inner"] span').first();
38
+ if (inner.length) return inner.text().trim();
39
+ return $(cell).find('span').first().text().trim();
40
+ };
41
+
42
+ async function fetchGroq() {
43
+ const response = await fetch(URL, {
44
+ headers: {
45
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
46
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
47
+ 'Accept-Language': 'en-US,en;q=0.9',
48
+ },
49
+ });
50
+
51
+ if (!response.ok) throw new Error(`HTTP ${response.status} from ${URL}`);
52
+ const html = await response.text();
53
+ const $ = cheerio.load(html);
54
+
55
+ const models = [];
56
+
57
+ $('table').each((_, table) => {
58
+ // Find the nearest preceding heading to determine section type
59
+ let sectionTitle = '';
60
+ let prev = $(table).parent();
61
+ for (let depth = 0; depth < 8; depth++) {
62
+ const h = prev.find('h1, h2, h3, h4').last();
63
+ if (h.length) { sectionTitle = h.text().trim(); break; }
64
+ const prevSibling = prev.prev();
65
+ const hSibling = prevSibling.is('h1,h2,h3,h4')
66
+ ? prevSibling
67
+ : prevSibling.find('h1,h2,h3,h4').last();
68
+ if (hSibling.length) { sectionTitle = hSibling.text().trim(); break; }
69
+ prev = prev.parent();
70
+ }
71
+
72
+ const titleLower = sectionTitle.toLowerCase();
73
+
74
+ // Skip non-pricing tables
75
+ if (titleLower.includes('caching') || titleLower.includes('tool') || titleLower.includes('compound')) return;
76
+
77
+ // Determine model type from section title
78
+ let tableType = 'chat';
79
+ if (titleLower.includes('speech') && titleLower.includes('text')) tableType = 'audio'; // TTS
80
+ if (titleLower.includes('recognition') || titleLower.includes('asr') || titleLower.includes('whisper')) tableType = 'audio';
81
+
82
+ // Parse header row to find column indices
83
+ const headers = [];
84
+ $(table).find('thead th').each((_, th) => {
85
+ headers.push($(th).text().toLowerCase().replace(/\s+/g, ' ').trim());
86
+ });
87
+
88
+ const colIdx = (...keywords) =>
89
+ headers.findIndex((h) => keywords.some((k) => h.includes(k)));
90
+
91
+ const nameCol = colIdx('model', 'name') ?? 0;
92
+ const inputCol = colIdx('input token', 'input price');
93
+ const outputCol = colIdx('output token', 'output price');
94
+ // For TTS/ASR: single price column
95
+ const priceCol = colIdx('per m char', 'per hour', 'price');
96
+
97
+ $(table).find('tbody tr').each((_, row) => {
98
+ const cells = $(row).find('td').toArray();
99
+ if (cells.length < 2) return;
100
+
101
+ const name = cellText($, cells[nameCol >= 0 ? nameCol : 0]);
102
+ if (!name) return;
103
+
104
+ let inputPrice = null;
105
+ let outputPrice = null;
106
+
107
+ if (inputCol >= 0 && outputCol >= 0) {
108
+ // LLM table
109
+ inputPrice = parseUsd(cellText($, cells[inputCol]));
110
+ outputPrice = parseUsd(cellText($, cells[outputCol]));
111
+ } else if (priceCol >= 0) {
112
+ // TTS or ASR — single price column
113
+ inputPrice = parseUsd(cellText($, cells[priceCol]));
114
+ outputPrice = 0;
115
+ }
116
+
117
+ if (inputPrice === null) return;
118
+
119
+ const size_b = getSizeB(name);
120
+ const model = {
121
+ name,
122
+ type: tableType,
123
+ input_price_per_1m: inputPrice,
124
+ output_price_per_1m: outputPrice ?? 0,
125
+ currency: 'USD',
126
+ };
127
+ if (size_b) model.size_b = size_b;
128
+
129
+ models.push(model);
130
+ });
131
+ });
132
+
133
+ return models;
134
+ }
135
+
136
+ module.exports = { fetchGroq, providerName: 'Groq' };
137
+
138
+ if (require.main === module) {
139
+ fetchGroq()
140
+ .then((models) => {
141
+ console.log(`Fetched ${models.length} models from Groq:\n`);
142
+ const byType = {};
143
+ models.forEach((m) => { (byType[m.type] = byType[m.type] || []).push(m); });
144
+ for (const [type, ms] of Object.entries(byType)) {
145
+ console.log(` [${type}]`);
146
+ ms.forEach((m) =>
147
+ console.log(` ${m.name.padEnd(45)} $${m.input_price_per_1m} / $${m.output_price_per_1m}`)
148
+ );
149
+ }
150
+ })
151
+ .catch((err) => {
152
+ console.error('Error:', err.message);
153
+ process.exit(1);
154
+ });
155
+ }
scripts/providers/infomaniak.js ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ /**
4
+ * Infomaniak AI pricing fetcher.
5
+ *
6
+ * Source: https://www.infomaniak.com/en/hosting/ai-services/prices
7
+ * The page is a 2.5MB SSR bundle (no Next.js, no __NEXT_DATA__).
8
+ * Pricing data is embedded in the HTML using CSS-module class names.
9
+ *
10
+ * Structure per model card:
11
+ * div[class*="sectionWrapperPricesContentModelsTitle"]
12
+ * p[class*="IkTypography-module--h4"] → model name
13
+ * div[class*="sectionWrapperPricesContentModelsPrice"]
14
+ * div[class*="sectionWrapperPricesContentModelsPriceWrapper"] (×2)
15
+ * p → "Incoming token:" or "Outgoing token:" (or "Image:")
16
+ * span[class*="IkTypography-module--h3"] → price number
17
+ * span[class*="color-text-secondary"] → currency (CHF)
18
+ *
19
+ * Currency is CHF (Swiss Francs).
20
+ */
21
+
22
+ const cheerio = require('cheerio');
23
+
24
+ const URL = 'https://www.infomaniak.com/en/hosting/ai-services/prices';
25
+
26
+ const parseChf = (text) => {
27
+ if (!text) return null;
28
+ if (text.trim().toLowerCase() === 'free') return 0;
29
+ const m = text.trim().match(/([\d]+\.[\d]*|[\d]+)/);
30
+ return m ? parseFloat(m[1]) : null;
31
+ };
32
+
33
+ const getSizeB = (name) => {
34
+ const m = (name || '').match(/[^.\d](\d+)[Bb]/) || (name || '').match(/^(\d+)[Bb]/);
35
+ return m ? parseInt(m[1]) : undefined;
36
+ };
37
+
38
+ const inferType = (name) => {
39
+ const n = name.toLowerCase();
40
+ if (n.includes('embed') || n.includes('minilm') || n.includes('bge')) return 'embedding';
41
+ if (n.includes('whisper')) return 'audio';
42
+ if (n.includes('flux') || n.includes('photomaker') || n.includes('image')) return 'image';
43
+ return 'chat';
44
+ };
45
+
46
+ async function fetchInfomaniak() {
47
+ const response = await fetch(URL, {
48
+ headers: {
49
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
50
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
51
+ 'Accept-Language': 'en-US,en;q=0.9',
52
+ },
53
+ });
54
+
55
+ if (!response.ok) throw new Error(`HTTP ${response.status} from ${URL}`);
56
+ const html = await response.text();
57
+ const $ = cheerio.load(html);
58
+
59
+ const models = [];
60
+
61
+ // Each model card contains a "Title" div (name) followed by a "Price" div (pricing rows)
62
+ // Use the CSS-module partial class pattern for both
63
+ $('[class*="sectionWrapperPricesContentModelsTitle"]').each((_, titleEl) => {
64
+ const nameEl = $(titleEl).find('[class*="IkTypography-module--h4"]').first();
65
+ if (!nameEl.length) return;
66
+
67
+ // Strip provider prefix (e.g. "openai/gpt-oss-120b" → "gpt-oss-120b")
68
+ const rawName = nameEl.text().trim();
69
+ const name = rawName.includes('/') ? rawName.split('/').pop() : rawName;
70
+ if (!name) return;
71
+
72
+ // The price section is the direct next sibling of the title div
73
+ const priceSection = $(titleEl).next('[class*="sectionWrapperPricesContentModelsPrice"]');
74
+ if (!priceSection.length) return;
75
+
76
+ let inputPrice = null;
77
+ let outputPrice = null;
78
+ let currency = 'CHF';
79
+
80
+ priceSection.find('[class*="sectionWrapperPricesContentModelsPriceWrapper"]').each((_, priceRow) => {
81
+ const label = $(priceRow).find('p').first().text().toLowerCase();
82
+ // Currency from the secondary span
83
+ const currSpan = $(priceRow).find('[class*="color-text-secondary"]').first();
84
+ const currText = currSpan.text().trim().replace(/\s/g, '');
85
+ if (currText && /^[A-Z]{3}$/.test(currText)) currency = currText;
86
+
87
+ const valSpan = $(priceRow).find('[class*="IkTypography-module--h3"]').first();
88
+ const val = parseChf(valSpan.text());
89
+ if (val === null) return;
90
+
91
+ if (label.includes('incoming') || label.includes('input')) {
92
+ inputPrice = val;
93
+ } else if (label.includes('outgoing') || label.includes('output')) {
94
+ outputPrice = val;
95
+ } else if (label.includes('image') || label.includes('per image')) {
96
+ inputPrice = val; // image models: price per image stored as input
97
+ } else if (inputPrice === null) {
98
+ inputPrice = val; // fallback: first price row = input
99
+ }
100
+ });
101
+
102
+ if (inputPrice === null) return;
103
+
104
+ const type = inferType(name);
105
+ const size_b = getSizeB(name);
106
+
107
+ const model = {
108
+ name,
109
+ type,
110
+ input_price_per_1m: inputPrice,
111
+ output_price_per_1m: outputPrice ?? 0,
112
+ currency,
113
+ };
114
+ if (size_b) model.size_b = size_b;
115
+
116
+ models.push(model);
117
+ });
118
+
119
+ return models;
120
+ }
121
+
122
+ module.exports = { fetchInfomaniak, providerName: 'Infomaniak' };
123
+
124
+ if (require.main === module) {
125
+ fetchInfomaniak()
126
+ .then((models) => {
127
+ console.log(`Fetched ${models.length} models from Infomaniak:\n`);
128
+ const byType = {};
129
+ models.forEach((m) => { (byType[m.type] = byType[m.type] || []).push(m); });
130
+ for (const [type, ms] of Object.entries(byType)) {
131
+ console.log(` [${type}]`);
132
+ ms.forEach((m) =>
133
+ console.log(` ${m.name.padEnd(45)} ${m.currency} ${m.input_price_per_1m} / ${m.output_price_per_1m}`)
134
+ );
135
+ }
136
+ })
137
+ .catch((err) => {
138
+ console.error('Error:', err.message);
139
+ process.exit(1);
140
+ });
141
+ }
scripts/providers/ionos.js ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ /**
4
+ * IONOS AI Model Hub pricing fetcher.
5
+ *
6
+ * Source: https://cloud.ionos.com/managed/ai-model-hub
7
+ * The page is SSR'd (Next.js pages router with Tailwind CSS classes).
8
+ * Pricing is embedded in real <table> elements — cheerio works fine.
9
+ *
10
+ * Tables on the page (desktop versions are even-indexed):
11
+ * 0. LLM / chat — cols: tier | model(s) | input $/M tok | output $/M tok
12
+ * The model cell can list several models separated by \n
13
+ * 2. OCR / vision — cols: model | input $/M tok | output $/M tok
14
+ * 4. Image — cols: model | price per image
15
+ * 6. Embedding — cols: model | price per 1M tokens
16
+ * 8. Storage — skip
17
+ * Odd-indexed tables (1,3,5,7,9) are mobile card duplicates of the above.
18
+ */
19
+
20
+ const cheerio = require('cheerio');
21
+
22
+ const URL = 'https://cloud.ionos.com/managed/ai-model-hub';
23
+
24
+ const parseUsd = (text) => {
25
+ if (!text) return null;
26
+ const m = text.trim().match(/\$?([\d]+\.[\d]*|[\d]+)/);
27
+ return m ? parseFloat(m[1]) : null;
28
+ };
29
+
30
+ const getSizeB = (name) => {
31
+ const m = (name || '').match(/[^.\d](\d+)[Bb]/) || (name || '').match(/^(\d+)[Bb]/);
32
+ return m ? parseInt(m[1]) : undefined;
33
+ };
34
+
35
+ // Split a cell value that may contain multiple model names separated by newlines
36
+ const splitModels = (text) =>
37
+ text
38
+ .split('\n')
39
+ .map((s) => s.trim())
40
+ .filter(Boolean);
41
+
42
+ async function fetchIonos() {
43
+ const response = await fetch(URL, {
44
+ headers: {
45
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
46
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
47
+ 'Accept-Language': 'en-US,en;q=0.9',
48
+ },
49
+ });
50
+
51
+ if (!response.ok) throw new Error(`HTTP ${response.status} from ${URL}`);
52
+ const html = await response.text();
53
+ const $ = cheerio.load(html);
54
+
55
+ const models = [];
56
+ const tables = $('table').toArray();
57
+
58
+ // ── Table 0: LLM / chat ─────────────────────────────────────────────────────
59
+ // cols: tier (may be empty for continuation rows) | model(s) | input | output
60
+ const llmTable = $(tables[0]);
61
+ llmTable.find('tbody tr').each((_, row) => {
62
+ const cells = $(row).find('td');
63
+ if (cells.length < 4) return;
64
+
65
+ const rawNames = cells.eq(1).text();
66
+ const inputPrice = parseUsd(cells.eq(2).text());
67
+ const outputPrice = parseUsd(cells.eq(3).text());
68
+ if (inputPrice === null) return;
69
+
70
+ splitModels(rawNames).forEach((name) => {
71
+ if (!name) return;
72
+ const model = {
73
+ name,
74
+ type: 'chat',
75
+ input_price_per_1m: inputPrice,
76
+ output_price_per_1m: outputPrice ?? 0,
77
+ currency: 'USD',
78
+ };
79
+ const size_b = getSizeB(name);
80
+ if (size_b) model.size_b = size_b;
81
+ models.push(model);
82
+ });
83
+ });
84
+
85
+ // ── Table 2: OCR / vision ───────────────────────────────────────────────────
86
+ // cols: model | input | output
87
+ const ocrTable = $(tables[2]);
88
+ ocrTable.find('tbody tr').each((_, row) => {
89
+ const cells = $(row).find('td');
90
+ if (cells.length < 3) return;
91
+ const name = cells.eq(0).text().trim();
92
+ const inputPrice = parseUsd(cells.eq(1).text());
93
+ const outputPrice = parseUsd(cells.eq(2).text());
94
+ if (!name || inputPrice === null) return;
95
+ models.push({
96
+ name,
97
+ type: 'vision',
98
+ capabilities: ['vision', 'files'],
99
+ input_price_per_1m: inputPrice,
100
+ output_price_per_1m: outputPrice ?? 0,
101
+ currency: 'USD',
102
+ });
103
+ });
104
+
105
+ // ── Table 4: Image generation ───────────────────────────────────────────────
106
+ // cols: model | price per image
107
+ const imgTable = $(tables[4]);
108
+ imgTable.find('tbody tr').each((_, row) => {
109
+ const cells = $(row).find('td');
110
+ if (cells.length < 2) return;
111
+ // Strip badge text like " New" appended after the model name
112
+ const name = cells.eq(0).text().trim().replace(/\s+New$/, '');
113
+ const pricePerImage = parseUsd(cells.eq(1).text());
114
+ if (!name || pricePerImage === null) return;
115
+ models.push({
116
+ name,
117
+ type: 'image',
118
+ input_price_per_1m: pricePerImage,
119
+ output_price_per_1m: 0,
120
+ currency: 'USD',
121
+ });
122
+ });
123
+
124
+ // ── Table 6: Embedding ───────────────────────────────────────────────────────
125
+ // cols: model | price per 1M tokens
126
+ const embTable = $(tables[6]);
127
+ embTable.find('tbody tr').each((_, row) => {
128
+ const cells = $(row).find('td');
129
+ if (cells.length < 2) return;
130
+ const name = cells.eq(0).text().trim();
131
+ const inputPrice = parseUsd(cells.eq(1).text());
132
+ if (!name || inputPrice === null) return;
133
+ models.push({
134
+ name,
135
+ type: 'embedding',
136
+ input_price_per_1m: inputPrice,
137
+ output_price_per_1m: 0,
138
+ currency: 'USD',
139
+ });
140
+ });
141
+
142
+ return models;
143
+ }
144
+
145
+ module.exports = { fetchIonos, providerName: 'IONOS' };
146
+
147
+ if (require.main === module) {
148
+ fetchIonos()
149
+ .then((models) => {
150
+ console.log(`Fetched ${models.length} models from IONOS:\n`);
151
+ const byType = {};
152
+ models.forEach((m) => { (byType[m.type] = byType[m.type] || []).push(m); });
153
+ for (const [type, ms] of Object.entries(byType)) {
154
+ console.log(` [${type}]`);
155
+ ms.forEach((m) =>
156
+ console.log(` ${m.name.padEnd(45)} $${m.input_price_per_1m} / $${m.output_price_per_1m}`)
157
+ );
158
+ }
159
+ })
160
+ .catch((err) => {
161
+ console.error('Error:', err.message);
162
+ process.exit(1);
163
+ });
164
+ }
scripts/providers/langdock.js ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ /**
4
+ * Langdock model fetcher.
5
+ *
6
+ * Langdock's /v1/models API returns model IDs with no pricing.
7
+ * Pricing lives at https://langdock.com/models — a Webflow SSR page.
8
+ *
9
+ * HTML structure (cheerio selectors):
10
+ * div.w-dyn-item → each model card
11
+ * div.models_row[fs-provider] → row with provider attribute
12
+ * div.models_cell.is-model → model name cell
13
+ * .text-size-small.text-weight-medium → model name text
14
+ * div.models_cell (2nd) → input price cell
15
+ * p.text-size-small span:eq(1) → price number
16
+ * div.models_cell (3rd) → output price cell
17
+ * p.text-size-small span:eq(1) → price number
18
+ *
19
+ * Pricing is in EUR with a stated 10% Langdock surcharge on provider rates.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const cheerio = require('cheerio');
25
+
26
+ const MODELS_URL = 'https://langdock.com/models';
27
+
28
+ function loadApiKey() {
29
+ const envPath = path.join(__dirname, '..', '..', '..', 'AIToolkit', '.env');
30
+ if (!fs.existsSync(envPath)) return null;
31
+ const content = fs.readFileSync(envPath, 'utf8');
32
+ const match = content.match(/^LANGDOCK_API_KEY=(.+)$/m);
33
+ return match ? match[1].trim() : null;
34
+ }
35
+
36
+ const parseEur = (text) => {
37
+ if (!text) return null;
38
+ const clean = text.trim();
39
+ if (!clean || clean === '-' || clean === '–') return null;
40
+ const m = clean.match(/([\d]+\.[\d]*|[\d]+)/);
41
+ return m ? parseFloat(m[1]) : null;
42
+ };
43
+
44
+ const getSizeB = (name) => {
45
+ const m = (name || '').match(/[^.\d](\d+)[Bb]/) || (name || '').match(/^(\d+)[Bb]/);
46
+ return m ? parseInt(m[1]) : undefined;
47
+ };
48
+
49
+ async function fetchLangdock() {
50
+ const response = await fetch(MODELS_URL, {
51
+ headers: {
52
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
53
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
54
+ 'Accept-Language': 'en-US,en;q=0.9',
55
+ },
56
+ });
57
+
58
+ if (!response.ok) throw new Error(`HTTP ${response.status} from ${MODELS_URL}`);
59
+ const html = await response.text();
60
+ const $ = cheerio.load(html);
61
+
62
+ const models = [];
63
+ const seen = new Set();
64
+
65
+ $('div.w-dyn-item').each((_, item) => {
66
+ const row = $(item).find('div.models_row').first();
67
+ if (!row.length) return;
68
+
69
+ const provider = row.attr('fs-provider') || '';
70
+ const nameEl = row.find('div.models_cell.is-model .text-size-small').filter((_, el) => {
71
+ // Pick the element with font-weight medium (model name), not the provider label
72
+ return $(el).hasClass('text-weight-medium');
73
+ }).first();
74
+
75
+ if (!nameEl.length) return;
76
+ const name = nameEl.text().trim();
77
+ if (!name) return;
78
+
79
+ const cells = row.find('div.models_cell').not('.is-model');
80
+
81
+ // Input and output are the first two non-model cells that contain "/ 1M tokens"
82
+ let inputPrice = null;
83
+ let outputPrice = null;
84
+ let priceCount = 0;
85
+
86
+ cells.each((_, cell) => {
87
+ const text = $(cell).text();
88
+ if (!text.includes('1M tokens') && !text.includes('1M token')) return;
89
+ const spans = $(cell).find('p.text-size-small span');
90
+ // Spans: [currency_symbol, price_number, unit_string]
91
+ const priceSpan = spans.eq(1);
92
+ const val = parseEur(priceSpan.text());
93
+ if (val === null) return;
94
+ if (priceCount === 0) inputPrice = val;
95
+ else if (priceCount === 1) outputPrice = val;
96
+ priceCount++;
97
+ });
98
+
99
+ if (inputPrice === null) return;
100
+
101
+ const key = `${provider}|${name}|${inputPrice}|${outputPrice}`;
102
+ if (seen.has(key)) return;
103
+ seen.add(key);
104
+
105
+ const size_b = getSizeB(name);
106
+ const model = {
107
+ name,
108
+ type: 'chat',
109
+ input_price_per_1m: inputPrice,
110
+ output_price_per_1m: outputPrice ?? 0,
111
+ currency: 'EUR',
112
+ };
113
+ if (size_b) model.size_b = size_b;
114
+ if (provider) model.provider_upstream = provider;
115
+
116
+ models.push(model);
117
+ });
118
+
119
+ return models;
120
+ }
121
+
122
+ module.exports = { fetchLangdock, providerName: 'Langdock' };
123
+
124
+ if (require.main === module) {
125
+ fetchLangdock()
126
+ .then((models) => {
127
+ console.log(`Fetched ${models.length} models from Langdock:\n`);
128
+ const byProvider = {};
129
+ models.forEach((m) => {
130
+ const p = m.provider_upstream || 'Unknown';
131
+ (byProvider[p] = byProvider[p] || []).push(m);
132
+ });
133
+ for (const [prov, ms] of Object.entries(byProvider)) {
134
+ console.log(` [${prov}]`);
135
+ ms.forEach((m) =>
136
+ console.log(` ${m.name.padEnd(40)} €${m.input_price_per_1m} / €${m.output_price_per_1m}`)
137
+ );
138
+ }
139
+ })
140
+ .catch((err) => {
141
+ console.error('Error:', err.message);
142
+ process.exit(1);
143
+ });
144
+ }
scripts/providers/mistral.js ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ /**
4
+ * Mistral AI pricing fetcher.
5
+ *
6
+ * mistral.ai/pricing uses Next.js App Router RSC streaming format.
7
+ * Pricing data is embedded in self.__next_f.push([1, "..."]) script tags,
8
+ * NOT in __NEXT_DATA__. We find the script containing "api_grid" and
9
+ * extract the `apis` array which has all models with their price entries.
10
+ */
11
+
12
+ const URL = 'https://mistral.ai/pricing';
13
+
14
+ const stripHtml = (html) => (html || '').replace(/<[^>]+>/g, '').trim();
15
+
16
+ const parseUsd = (html) => {
17
+ const text = stripHtml(html);
18
+ if (!text || text === 'N/A' || text === '-') return null;
19
+ // Take the first dollar amount found (handles "X (audio)" / "X (text)" variants)
20
+ const match = text.match(/\$?([\d]+\.[\d]*|[\d]+)/);
21
+ return match ? parseFloat(match[1]) : null;
22
+ };
23
+
24
+ const getSizeB = (name) => {
25
+ const match = (name || '').match(/[^.\d](\d+)[Bb]/) || (name || '').match(/^(\d+)[Bb]/);
26
+ return match ? parseInt(match[1]) : undefined;
27
+ };
28
+
29
+ const MODEL_TYPE_MAP = {
30
+ 'embedding models': 'embedding',
31
+ 'classifier models': 'chat',
32
+ 'open models': 'chat',
33
+ 'premier model': 'chat',
34
+ 'other models': 'chat',
35
+ };
36
+
37
+ function extractApisArray(payload) {
38
+ // Find the "apis":[...] block in the RSC payload string
39
+ const start = payload.indexOf('"apis":[{');
40
+ if (start === -1) return null;
41
+
42
+ // Walk forward from start to find the opening '[' of the array
43
+ let i = start;
44
+ while (i < payload.length && payload[i] !== '[') i++;
45
+ i++; // step past '['
46
+ const arrStart = i;
47
+ let depth = 0;
48
+
49
+ while (i < payload.length) {
50
+ if (payload[i] === '[') depth++;
51
+ else if (payload[i] === ']') {
52
+ if (depth === 0) break;
53
+ depth--;
54
+ }
55
+ i++;
56
+ }
57
+
58
+ try {
59
+ return JSON.parse('[' + payload.slice(arrStart, i) + ']');
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ async function fetchMistral() {
66
+ const response = await fetch(URL, {
67
+ headers: {
68
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
69
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
70
+ 'Accept-Language': 'en-US,en;q=0.9',
71
+ },
72
+ });
73
+
74
+ if (!response.ok) throw new Error(`HTTP ${response.status} from ${URL}`);
75
+ const html = await response.text();
76
+
77
+ // The page uses Next.js App Router RSC streaming. Pricing data is in a
78
+ // self.__next_f.push([1, "ENCODED_STRING"]) script tag. Inside the raw HTML,
79
+ // inner quotes are escaped as \" so we search for the literal \\"apis\\":[{
80
+ // (which represents \"apis\":[{ in the actual HTML bytes).
81
+ const MARKER = '\\"apis\\":[{';
82
+ const markerIdx = html.indexOf(MARKER);
83
+ if (markerIdx === -1) throw new Error('Could not find apis marker in page HTML');
84
+
85
+ // Find the enclosing <script> tag
86
+ const scriptTagStart = html.lastIndexOf('<script', markerIdx);
87
+ const contentStart = html.indexOf('>', scriptTagStart) + 1;
88
+ const contentEnd = html.indexOf('</script>', contentStart);
89
+ const src = html.slice(contentStart, contentEnd);
90
+
91
+ // Format: self.__next_f.push([1,"ENCODED_PAYLOAD"])
92
+ // Extract the JSON-encoded string and parse it once to get the RSC payload string.
93
+ const pushAt = src.indexOf('[1,');
94
+ const strStart = pushAt + 3; // points to opening "
95
+ const lastBracket = src.lastIndexOf('])');
96
+ const jsonStr = src.slice(strStart, lastBracket); // "ENCODED_PAYLOAD"
97
+
98
+ let payload;
99
+ try {
100
+ payload = JSON.parse(jsonStr);
101
+ } catch (e) {
102
+ throw new Error(`Failed to parse RSC payload: ${e.message}`);
103
+ }
104
+
105
+ const apis = extractApisArray(payload);
106
+ if (!apis) throw new Error('Could not find API pricing data in page');
107
+
108
+ const models = [];
109
+
110
+ for (const api of apis) {
111
+ const name = (api.name || '').trim();
112
+ const rawType = (api.type || '').toLowerCase();
113
+ const endpoint = api.api_endpoint || null;
114
+
115
+ // Skip pure tool entries (no model pricing)
116
+ if (rawType === 'tools' || rawType === 'tool') continue;
117
+ if (!api.price || api.price.length === 0) continue;
118
+
119
+ // Find input and output prices from the price array
120
+ let inputPrice = null;
121
+ let outputPrice = null;
122
+
123
+ for (const p of api.price) {
124
+ const label = (p.value || '').toLowerCase();
125
+ const priceHtml = p.price_dollar || p.price_euro || '';
126
+ const val = parseUsd(priceHtml);
127
+ if (label.includes('input') || label.includes('in ')) {
128
+ if (inputPrice === null) inputPrice = val;
129
+ } else if (label.includes('output') || label.includes('out ')) {
130
+ if (outputPrice === null) outputPrice = val;
131
+ }
132
+ }
133
+
134
+ // Skip if we couldn't get any price
135
+ if (inputPrice === null && outputPrice === null) continue;
136
+
137
+ const type = MODEL_TYPE_MAP[rawType] || 'chat';
138
+ const size_b = getSizeB(name);
139
+
140
+ const model = {
141
+ name,
142
+ type,
143
+ input_price_per_1m: inputPrice ?? 0,
144
+ output_price_per_1m: outputPrice ?? 0,
145
+ currency: 'USD',
146
+ };
147
+ if (size_b) model.size_b = size_b;
148
+ if (endpoint) model.api_endpoint = endpoint;
149
+
150
+ models.push(model);
151
+ }
152
+
153
+ return models;
154
+ }
155
+
156
+ module.exports = { fetchMistral, providerName: 'Mistral AI' };
157
+
158
+ // Run standalone: node scripts/providers/mistral.js
159
+ if (require.main === module) {
160
+ fetchMistral()
161
+ .then((models) => {
162
+ console.log(`Fetched ${models.length} models from Mistral AI:\n`);
163
+ const byType = {};
164
+ models.forEach((m) => { (byType[m.type] = byType[m.type] || []).push(m); });
165
+ for (const [type, ms] of Object.entries(byType)) {
166
+ console.log(` [${type}]`);
167
+ ms.forEach((m) =>
168
+ console.log(` ${m.name.padEnd(40)} $${m.input_price_per_1m} / $${m.output_price_per_1m}`)
169
+ );
170
+ }
171
+ })
172
+ .catch((err) => {
173
+ console.error('Error:', err.message);
174
+ process.exit(1);
175
+ });
176
+ }
scripts/providers/nebius.js ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ /**
4
+ * Nebius Token Factory pricing fetcher.
5
+ *
6
+ * The pricing page (nebius.com/token-factory/prices) is a Next.js SSR app.
7
+ * Pricing tables live inside __NEXT_DATA__ -> __APOLLO_STATE__ -> page content
8
+ * which is a *double-encoded* JSON string. We parse it twice.
9
+ *
10
+ * Table types found on the page:
11
+ * ['Model','Flavor','Input','Output'] – text-to-text; pairs of rows (fast/base)
12
+ * ['Model','Input','Output'] – vision / guardrails; single rows
13
+ * ['Model','Input'] – image gen / embeddings; single rows
14
+ */
15
+
16
+ const URL = 'https://nebius.com/token-factory/prices';
17
+
18
+ const parseUsd = (text) => {
19
+ if (!text) return null;
20
+ const clean = text.trim();
21
+ if (clean === '–' || clean === '-' || clean === '' || clean.toLowerCase() === 'free') return 0;
22
+ const match = clean.match(/\$?([\d]+\.[\d]*|[\d]+)/);
23
+ return match ? parseFloat(match[1]) : null;
24
+ };
25
+
26
+ const getSizeB = (name) => {
27
+ const match = (name || '').match(/[^.\d](\d+)[Bb]/) || (name || '').match(/^(\d+)[Bb]/);
28
+ return match ? parseInt(match[1]) : undefined;
29
+ };
30
+
31
+ // Recursively walk a parsed JSON object and collect all table.content arrays.
32
+ // Returns [{ type, rows }] where type is inferred from surrounding block context.
33
+ function collectTables(obj, context = {}) {
34
+ const results = [];
35
+ if (!obj || typeof obj !== 'object') return results;
36
+
37
+ if (Array.isArray(obj)) {
38
+ for (const item of obj) results.push(...collectTables(item, context));
39
+ return results;
40
+ }
41
+
42
+ // Pick up section context from block type/title
43
+ const blockType = obj.type || '';
44
+ const newCtx = { ...context };
45
+ if (obj.title) newCtx.title = obj.title;
46
+ if (blockType.includes('tabs')) newCtx.inTabs = true;
47
+
48
+ // Found a table
49
+ if (obj.table && Array.isArray(obj.table.content)) {
50
+ results.push({ context: newCtx, rows: obj.table.content });
51
+ }
52
+
53
+ // Also capture the description near a table to infer section type
54
+ if (obj.description && typeof obj.description === 'string') {
55
+ newCtx.description = obj.description;
56
+ }
57
+
58
+ for (const val of Object.values(obj)) {
59
+ results.push(...collectTables(val, newCtx));
60
+ }
61
+ return results;
62
+ }
63
+
64
+ function modelsFromTable({ rows }) {
65
+ if (!rows || rows.length < 2) return [];
66
+ const header = rows[0].map((h) => (h || '').toLowerCase());
67
+ const hasFlavor = header.includes('flavor') || header.includes('tier');
68
+ const hasOutput = header.includes('output');
69
+
70
+ const modelCol = header.indexOf('model') >= 0 ? header.indexOf('model') : 0;
71
+ const flavorCol = hasFlavor ? header.indexOf('flavor') : -1;
72
+ const inputCol = header.indexOf('input') >= 0 ? header.indexOf('input') : (hasFlavor ? 2 : 1);
73
+ const outputCol = hasOutput ? header.indexOf('output') : -1;
74
+
75
+ // Infer model type from header columns
76
+ let type = 'chat';
77
+ const headerStr = header.join(' ');
78
+ if (!hasOutput && !hasFlavor) {
79
+ // image gen or embedding — single input price column
80
+ type = 'image'; // will be overridden by section context below
81
+ }
82
+
83
+ const models = [];
84
+ let lastModelName = '';
85
+
86
+ for (const row of rows.slice(1)) {
87
+ const rawName = (row[modelCol] || '').trim();
88
+ // Carry forward the name when the row belongs to the same model (Flavor rows)
89
+ const name = rawName || lastModelName;
90
+ if (rawName) lastModelName = rawName;
91
+
92
+ // Strip provider prefix (Meta/, google/, BAAI/, etc.)
93
+ const cleanName = name.includes('/') ? name.split('/').pop() : name;
94
+ if (!cleanName) continue;
95
+
96
+ const flavor = flavorCol >= 0 ? (row[flavorCol] || '').trim() : '';
97
+ const inputPrice = parseUsd(row[inputCol]);
98
+ const outputPrice = outputCol >= 0 ? parseUsd(row[outputCol]) : 0;
99
+
100
+ // Skip rows with no pricing at all (e.g. fast tier that's not yet launched)
101
+ if (inputPrice === null || (inputPrice === 0 && outputPrice === 0 && flavor !== 'base')) continue;
102
+ // Also skip "–" fast-only rows with no price
103
+ if (inputPrice === 0 && flavor === 'fast') continue;
104
+
105
+ const displayName = flavor ? `${cleanName} (${flavor})` : cleanName;
106
+ const size_b = getSizeB(cleanName);
107
+
108
+ const model = {
109
+ name: displayName,
110
+ type,
111
+ input_price_per_1m: inputPrice,
112
+ output_price_per_1m: outputPrice ?? 0,
113
+ currency: 'USD',
114
+ };
115
+ if (size_b) model.size_b = size_b;
116
+ if (flavor) model.flavor = flavor;
117
+
118
+ models.push(model);
119
+ }
120
+
121
+ return models;
122
+ }
123
+
124
+ async function fetchNebius() {
125
+ const response = await fetch(URL, {
126
+ headers: {
127
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
128
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
129
+ 'Accept-Language': 'en-US,en;q=0.9',
130
+ },
131
+ });
132
+
133
+ if (!response.ok) throw new Error(`HTTP ${response.status} from ${URL}`);
134
+ const html = await response.text();
135
+ if (html.includes('cf-browser-verification') || html.includes('Just a moment')) {
136
+ throw new Error('Blocked by Cloudflare');
137
+ }
138
+
139
+ // Extract __NEXT_DATA__
140
+ const ndMatch = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);
141
+ if (!ndMatch) throw new Error('__NEXT_DATA__ not found in page');
142
+
143
+ const nextData = JSON.parse(ndMatch[1]);
144
+ const apollo = nextData?.props?.pageProps?.__APOLLO_STATE__;
145
+ if (!apollo) throw new Error('__APOLLO_STATE__ not found');
146
+
147
+ // Find the page entry whose content string contains pricing tables.
148
+ // We search all Apollo state values for one with a stringified content containing "table".
149
+ let pageContent = null;
150
+ for (const val of Object.values(apollo)) {
151
+ if (val && typeof val.content === 'string' && val.content.includes('"table"')) {
152
+ try {
153
+ pageContent = JSON.parse(val.content); // second parse
154
+ if (pageContent) break;
155
+ } catch { /* continue */ }
156
+ }
157
+ }
158
+ if (!pageContent) throw new Error('Could not find pricing content block in Apollo state');
159
+
160
+ // Collect all table blocks
161
+ const tableBlocks = collectTables(pageContent);
162
+
163
+ const allModels = [];
164
+
165
+ tableBlocks.forEach(({ rows, context }, i) => {
166
+ const header = (rows[0] || []).map((h) => (h || '').toLowerCase());
167
+
168
+ // Skip non-pricing tables (post-training has 'model size', enterprise has 'capability')
169
+ if (header[0] === 'model size' || header[0] === 'capability' || header[0] === 'feature') return;
170
+
171
+ // Infer model type from surrounding context text
172
+ const ctx = (context.title || context.description || '').toLowerCase();
173
+ let tableType = 'chat';
174
+ if (ctx.includes('embed')) tableType = 'embedding';
175
+ else if (ctx.includes('image') || ctx.includes('flux')) tableType = 'image';
176
+ else if (ctx.includes('vision')) tableType = 'vision';
177
+ else if (ctx.includes('gemma') || ctx.includes('guard') || ctx.includes('llama-guard')) tableType = 'chat';
178
+ else if (header.includes('flavor')) tableType = 'chat';
179
+ else if (!header.includes('output')) {
180
+ // Single-price column without output — check if it looks like embeddings or image
181
+ const firstModelName = (rows[1]?.[0] || '').toLowerCase();
182
+ if (firstModelName.includes('bge') || firstModelName.includes('embed')) tableType = 'embedding';
183
+ else tableType = 'image';
184
+ }
185
+
186
+ const models = modelsFromTable({ rows });
187
+ models.forEach((m) => {
188
+ m.type = tableType;
189
+ if (tableType === 'vision') m.capabilities = ['vision'];
190
+ });
191
+ allModels.push(...models);
192
+ });
193
+
194
+ return allModels;
195
+ }
196
+
197
+ module.exports = { fetchNebius, providerName: 'Nebius' };
198
+
199
+ // Run standalone: node scripts/providers/nebius.js
200
+ if (require.main === module) {
201
+ fetchNebius()
202
+ .then((models) => {
203
+ console.log(`Fetched ${models.length} models from Nebius:\n`);
204
+ const byType = {};
205
+ models.forEach((m) => {
206
+ (byType[m.type] = byType[m.type] || []).push(m);
207
+ });
208
+ for (const [type, ms] of Object.entries(byType)) {
209
+ console.log(` [${type}]`);
210
+ ms.forEach((m) =>
211
+ console.log(` ${m.name.padEnd(55)} $${m.input_price_per_1m} / $${m.output_price_per_1m}`)
212
+ );
213
+ }
214
+ })
215
+ .catch((err) => {
216
+ console.error('Error:', err.message);
217
+ process.exit(1);
218
+ });
219
+ }
scripts/providers/openrouter.js ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ // OpenRouter exposes a public JSON API – no scraping needed.
4
+ // Docs: https://openrouter.ai/docs/models
5
+ const API_URL = 'https://openrouter.ai/api/v1/models';
6
+
7
+ // OpenRouter stores per-token prices; multiply by 1e6 to get per-1M price.
8
+ const toPerMillion = (val) => (val ? parseFloat(val) * 1_000_000 : 0);
9
+
10
+ const getSizeB = (id) => {
11
+ const match = (id || '').match(/[^.\d](\d+)b/i) || (id || '').match(/^(\d+)b/i);
12
+ return match ? parseInt(match[1]) : undefined;
13
+ };
14
+
15
+ // Derive model type from architecture modalities.
16
+ function getModelType(architecture) {
17
+ if (!architecture) return 'chat';
18
+ const inMods = architecture.input_modalities || [];
19
+ const outMods = architecture.output_modalities || [];
20
+ if (outMods.includes('audio')) return 'audio';
21
+ if (outMods.includes('image')) return 'image';
22
+ if (inMods.includes('image') || inMods.includes('video')) return 'vision';
23
+ if (inMods.includes('audio')) return 'audio';
24
+ return 'chat';
25
+ }
26
+
27
+ // Derive capabilities array from modalities + supported parameters.
28
+ function getCapabilities(architecture, supportedParams) {
29
+ const caps = [];
30
+ const inMods = (architecture?.input_modalities || []);
31
+ const outMods = (architecture?.output_modalities || []);
32
+ const params = supportedParams || [];
33
+ if (inMods.includes('image')) caps.push('vision');
34
+ if (inMods.includes('video')) caps.push('video');
35
+ if (inMods.includes('audio')) caps.push('audio');
36
+ if (inMods.includes('file')) caps.push('files');
37
+ if (outMods.includes('image')) caps.push('image-gen');
38
+ if (outMods.includes('audio')) caps.push('audio-out');
39
+ if (params.includes('tools')) caps.push('tools');
40
+ if (params.includes('reasoning')) caps.push('reasoning');
41
+ return caps;
42
+ }
43
+
44
+ async function fetchOpenRouter() {
45
+ const response = await fetch(API_URL, {
46
+ headers: {
47
+ Accept: 'application/json',
48
+ 'HTTP-Referer': 'https://github.com/providers-comparison',
49
+ },
50
+ });
51
+
52
+ if (!response.ok) {
53
+ throw new Error(`HTTP ${response.status} from OpenRouter API`);
54
+ }
55
+
56
+ const data = await response.json();
57
+ const models = [];
58
+
59
+ for (const model of data.data || []) {
60
+ const pricing = model.pricing || {};
61
+ const inputPrice = toPerMillion(pricing.prompt);
62
+ const outputPrice = toPerMillion(pricing.completion);
63
+
64
+ // Skip meta-route with negative prices (e.g. openrouter/auto sentinel values)
65
+ if (inputPrice < 0 || outputPrice < 0) continue;
66
+ // Skip the free-router meta-model (it's not a real model, just routes to free models)
67
+ if (model.id === 'openrouter/free') continue;
68
+
69
+ const type = getModelType(model.architecture);
70
+ const capabilities = getCapabilities(model.architecture, model.supported_parameters);
71
+
72
+ const modelEntry = {
73
+ name: model.id,
74
+ type,
75
+ input_price_per_1m: Math.round(inputPrice * 10000) / 10000,
76
+ output_price_per_1m: Math.round(outputPrice * 10000) / 10000,
77
+ currency: 'USD',
78
+ };
79
+
80
+ if (capabilities.length) modelEntry.capabilities = capabilities;
81
+ const size_b = getSizeB(model.id);
82
+ if (size_b) modelEntry.size_b = size_b;
83
+
84
+ models.push(modelEntry);
85
+ }
86
+
87
+ // Sort: free first (price=0), then by input price
88
+ models.sort((a, b) => {
89
+ const aFree = a.input_price_per_1m === 0 ? 1 : 0;
90
+ const bFree = b.input_price_per_1m === 0 ? 1 : 0;
91
+ if (aFree !== bFree) return aFree - bFree; // paid first, free last
92
+ return a.input_price_per_1m - b.input_price_per_1m;
93
+ });
94
+
95
+ return models;
96
+ }
97
+
98
+ module.exports = { fetchOpenRouter, providerName: 'OpenRouter' };
99
+
100
+ // Run standalone: node scripts/providers/openrouter.js
101
+ if (require.main === module) {
102
+ fetchOpenRouter()
103
+ .then((models) => {
104
+ const free = models.filter(m => m.input_price_per_1m === 0);
105
+ const vision = models.filter(m => m.type === 'vision');
106
+ console.log(`Fetched ${models.length} models from OpenRouter API`);
107
+ console.log(` Free: ${free.length}, Vision: ${vision.length}`);
108
+ console.log('\nFirst 5 paid:');
109
+ models.filter(m => m.input_price_per_1m > 0).slice(0, 5).forEach((m) =>
110
+ console.log(` ${m.name.padEnd(55)} $${m.input_price_per_1m} / $${m.output_price_per_1m} [${m.type}] ${(m.capabilities||[]).join(',')}`)
111
+ );
112
+ console.log('\nFirst 5 free:');
113
+ free.slice(0, 5).forEach((m) =>
114
+ console.log(` ${m.name.padEnd(55)} [${m.type}] ${(m.capabilities||[]).join(',')}`)
115
+ );
116
+ })
117
+ .catch((err) => {
118
+ console.error('Error:', err.message);
119
+ process.exit(1);
120
+ });
121
+ }
scripts/providers/requesty.js ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const API_URL = 'https://router.requesty.ai/v1/models';
7
+
8
+ // Load API key from ../AIToolkit/.env relative to the project root
9
+ function loadApiKey() {
10
+ const envPath = path.join(__dirname, '..', '..', '..', 'AIToolkit', '.env');
11
+ if (!fs.existsSync(envPath)) return null;
12
+ const content = fs.readFileSync(envPath, 'utf8');
13
+ const match = content.match(/^REQUESTY_API_KEY=(.+)$/m);
14
+ return match ? match[1].trim() : null;
15
+ }
16
+
17
+ const toPerMillion = (val) => (val ? Math.round(parseFloat(val) * 1_000_000 * 10000) / 10000 : 0);
18
+
19
+ const getSizeB = (id) => {
20
+ const match = (id || '').match(/[^.\d](\d+)b/i) || (id || '').match(/^(\d+)b/i);
21
+ return match ? parseInt(match[1]) : undefined;
22
+ };
23
+
24
+ async function fetchRequesty() {
25
+ const apiKey = loadApiKey();
26
+ if (!apiKey) {
27
+ console.warn(' (no REQUESTY_API_KEY found – skipping Requesty)');
28
+ return [];
29
+ }
30
+
31
+ const response = await fetch(API_URL, {
32
+ headers: {
33
+ Authorization: `Bearer ${apiKey}`,
34
+ Accept: 'application/json',
35
+ },
36
+ });
37
+
38
+ if (!response.ok) {
39
+ throw new Error(`HTTP ${response.status} from Requesty API`);
40
+ }
41
+
42
+ const data = await response.json();
43
+ const models = [];
44
+
45
+ for (const model of data.data || []) {
46
+ const inputPrice = toPerMillion(model.input_price);
47
+ const outputPrice = toPerMillion(model.output_price);
48
+
49
+ // Skip free/zero-priced entries
50
+ if (inputPrice <= 0 && outputPrice <= 0) continue;
51
+
52
+ const caps = [];
53
+ if (model.supports_vision) caps.push('vision');
54
+ if (model.supports_reasoning) caps.push('reasoning');
55
+ if (model.supports_tool_calls) caps.push('tools');
56
+
57
+ const baseType = model.api === 'chat' ? 'chat' : model.api;
58
+ const type = (baseType === 'chat' && model.supports_vision) ? 'vision' : baseType;
59
+
60
+ const modelEntry = {
61
+ name: model.id,
62
+ type,
63
+ input_price_per_1m: inputPrice,
64
+ output_price_per_1m: outputPrice,
65
+ currency: 'USD',
66
+ };
67
+
68
+ if (caps.length) modelEntry.capabilities = caps;
69
+ if (model.context_window) modelEntry.context_window = model.context_window;
70
+
71
+ const size_b = getSizeB(model.id);
72
+ if (size_b) modelEntry.size_b = size_b;
73
+
74
+ models.push(modelEntry);
75
+ }
76
+
77
+ // Sort by input price
78
+ models.sort((a, b) => a.input_price_per_1m - b.input_price_per_1m);
79
+
80
+ return models;
81
+ }
82
+
83
+ module.exports = { fetchRequesty, providerName: 'Requesty' };
84
+
85
+ // Run standalone: node scripts/providers/requesty.js
86
+ if (require.main === module) {
87
+ fetchRequesty()
88
+ .then((models) => {
89
+ console.log(`Fetched ${models.length} models from Requesty API\n`);
90
+ models.slice(0, 10).forEach((m) =>
91
+ console.log(` ${m.name.padEnd(55)} $${m.input_price_per_1m} / $${m.output_price_per_1m}`)
92
+ );
93
+ if (models.length > 10) console.log(` ... and ${models.length - 10} more`);
94
+ })
95
+ .catch((err) => {
96
+ console.error('Error:', err.message);
97
+ process.exit(1);
98
+ });
99
+ }
scripts/providers/scaleway.js ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ const cheerio = require('cheerio');
4
+
5
+ const URL = 'https://www.scaleway.com/en/pricing/model-as-a-service/';
6
+
7
+ const parseEurPrice = (text) => {
8
+ if (!text) return null;
9
+ const clean = text.trim();
10
+ if (clean.toLowerCase() === 'free' || clean === '') return 0;
11
+ // Match €0.75 or just 0.75
12
+ const match = clean.match(/€?([\d]+\.[\d]+|[\d]+)/);
13
+ return match ? parseFloat(match[1]) : null;
14
+ };
15
+
16
+ const getModelType = (tasks) => {
17
+ const t = (tasks || '').toLowerCase();
18
+ if (t.includes('embed')) return 'embedding';
19
+ if (t.includes('audio transcription')) return 'audio';
20
+ if (t.includes('audio') && !t.includes('chat')) return 'audio';
21
+ return 'chat';
22
+ };
23
+
24
+ const getSizeB = (name) => {
25
+ // Match patterns like 235b, 70b, 8b but NOT "3.2" in a version number
26
+ const match = name.match(/[^.\d](\d+)b/i) || name.match(/^(\d+)b/i);
27
+ return match ? parseInt(match[1]) : undefined;
28
+ };
29
+
30
+ async function fetchScaleway() {
31
+ const response = await fetch(URL, {
32
+ headers: {
33
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
34
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
35
+ 'Accept-Language': 'en-US,en;q=0.9',
36
+ 'Cache-Control': 'no-cache',
37
+ },
38
+ });
39
+
40
+ if (!response.ok) {
41
+ throw new Error(`HTTP ${response.status} fetching ${URL}`);
42
+ }
43
+
44
+ const html = await response.text();
45
+
46
+ // Quick sanity check for Cloudflare block
47
+ if (html.includes('cf-browser-verification') || html.includes('Just a moment')) {
48
+ throw new Error('Blocked by Cloudflare – try adding a delay or using a browser-based fetcher');
49
+ }
50
+
51
+ const $ = cheerio.load(html);
52
+ const models = [];
53
+
54
+ // Find all tables and pick the pricing one
55
+ $('table').each((_tableIdx, table) => {
56
+ const headerTexts = [];
57
+ $(table).find('thead th, thead td').each((_, th) => {
58
+ headerTexts.push($(th).text().trim().toLowerCase());
59
+ });
60
+
61
+ // Must look like a model pricing table
62
+ const hasModel = headerTexts.some(h => h.includes('model') || h.includes('name'));
63
+ const hasPrice = headerTexts.some(h => h.includes('input') || h.includes('price'));
64
+ if (!hasModel && !hasPrice) return;
65
+
66
+ // Resolve column indices with fallbacks
67
+ const colIdx = (keywords) => {
68
+ const idx = headerTexts.findIndex(h => keywords.some(k => h.includes(k)));
69
+ return idx >= 0 ? idx : null;
70
+ };
71
+
72
+ const modelCol = colIdx(['model', 'name']) ?? 0;
73
+ const taskCol = colIdx(['task', 'type', 'capabilit']);
74
+ const inputCol = colIdx(['input']) ?? 2;
75
+ const outputCol = colIdx(['output']) ?? 3;
76
+
77
+ $(table).find('tbody tr').each((_, row) => {
78
+ const cells = $(row).find('td');
79
+ if (cells.length < 2) return;
80
+
81
+ const name = $(cells[modelCol]).text().trim();
82
+ const tasks = taskCol != null ? $(cells[taskCol]).text().trim() : '';
83
+ const inputText = $(cells[inputCol]).text().trim();
84
+ const outputText = outputCol < cells.length ? $(cells[outputCol]).text().trim() : '';
85
+
86
+ if (!name) return;
87
+
88
+ // Skip GPU/compute instance rows (e.g. L4-1-24G, H100-SXM-4-80G, L40S-1-48G)
89
+ if (/^[A-Z0-9]+(?:-[A-Z0-9]+)*-\d+-\d+G$/i.test(name)) return;
90
+
91
+ const inputPrice = parseEurPrice(inputText);
92
+ const outputPrice = parseEurPrice(outputText);
93
+
94
+ // Skip rows we couldn't parse a price for
95
+ if (inputPrice === null) return;
96
+
97
+ const type = getModelType(tasks || name);
98
+ const size_b = getSizeB(name);
99
+
100
+ const model = {
101
+ name,
102
+ type,
103
+ input_price_per_1m: inputPrice,
104
+ output_price_per_1m: outputPrice ?? 0,
105
+ currency: 'EUR',
106
+ };
107
+ if (size_b) model.size_b = size_b;
108
+
109
+ models.push(model);
110
+ });
111
+ });
112
+
113
+ // Fallback: if table parsing yielded nothing, try regex on the raw HTML
114
+ if (models.length === 0) {
115
+ console.warn(' Table parser found nothing – falling back to text extraction');
116
+ const rows = html.matchAll(
117
+ /([a-z0-9][a-z0-9\-\.]+(?:instruct|embed|whisper|voxtral|gemma|llama|mistral|qwen|pixtral|devstral|holo|bge)[a-z0-9\-\.]*)\D+€([\d.]+)\D+€([\d.]+)/gi
118
+ );
119
+ for (const m of rows) {
120
+ const name = m[1];
121
+ const size_b = getSizeB(name);
122
+ const model = {
123
+ name,
124
+ type: 'chat',
125
+ input_price_per_1m: parseFloat(m[2]),
126
+ output_price_per_1m: parseFloat(m[3]),
127
+ currency: 'EUR',
128
+ };
129
+ if (size_b) model.size_b = size_b;
130
+ models.push(model);
131
+ }
132
+ }
133
+
134
+ return models;
135
+ }
136
+
137
+ module.exports = { fetchScaleway, providerName: 'Scaleway' };
138
+
139
+ // Run standalone: node scripts/providers/scaleway.js
140
+ if (require.main === module) {
141
+ fetchScaleway()
142
+ .then((models) => {
143
+ console.log(`Fetched ${models.length} models from Scaleway:\n`);
144
+ models.forEach((m) =>
145
+ console.log(` ${m.name.padEnd(50)} €${m.input_price_per_1m} / €${m.output_price_per_1m}`)
146
+ );
147
+ })
148
+ .catch((err) => {
149
+ console.error('Error:', err.message);
150
+ process.exit(1);
151
+ });
152
+ }
scripts/update_openrouter.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import json
3
+ import os
4
+
5
+ def parse_openrouter_yml(file_path):
6
+ with open(file_path, 'r') as f:
7
+ content = f.read()
8
+
9
+ # Split by model block start
10
+ # Simplified regex-based parsing for YAML-like structure
11
+ models = []
12
+ # Each model entry starts with "- id:"
13
+ blocks = content.split('\n - id: ')
14
+ if len(blocks) <= 1:
15
+ # Try without space
16
+ blocks = content.split('\n- id: ')
17
+
18
+ for block in blocks[1:]: # skip the header
19
+ lines = block.split('\n')
20
+ model_id = lines[0].strip().strip('"')
21
+
22
+ input_price = 0.0
23
+ output_price = 0.0
24
+
25
+ for line in lines:
26
+ if 'input_mtok:' in line:
27
+ m = re.search(r'input_mtok:\s*([0-9\.]+)', line)
28
+ if m: input_price = float(m.group(1))
29
+ if 'output_mtok:' in line:
30
+ m = re.search(r'output_mtok:\s*([0-9\.]+)', line)
31
+ if m: output_price = float(m.group(1))
32
+
33
+ # Determine size from id if possible
34
+ size_match = re.search(r'(\d+)b', model_id.lower())
35
+ size_b = int(size_match.group(1)) if size_match else None
36
+
37
+ models.append({
38
+ "name": model_id,
39
+ "type": "chat",
40
+ "size_b": size_b,
41
+ "input_price_per_1m": input_price,
42
+ "output_price_per_1m": output_price,
43
+ "currency": "USD"
44
+ })
45
+
46
+ return models
47
+
48
+ openrouter_models = parse_openrouter_yml('openrouter_prices.yml')
49
+
50
+ # Load existing providers
51
+ with open('data/providers.json', 'r') as f:
52
+ data = json.load(f)
53
+
54
+ # Update or Add OpenRouter
55
+ found = False
56
+ for provider in data['providers']:
57
+ if provider['name'] == 'OpenRouter':
58
+ provider['models'] = openrouter_models
59
+ found = True
60
+ break
61
+
62
+ if not found:
63
+ data['providers'].append({
64
+ "name": "OpenRouter",
65
+ "url": "https://openrouter.ai/models",
66
+ "headquarters": "USA",
67
+ "region": "Global",
68
+ "gdpr_compliant": False,
69
+ "eu_endpoints": False,
70
+ "models": openrouter_models
71
+ })
72
+
73
+ with open('data/providers.json', 'w') as f:
74
+ json.dump(data, f, indent=2)
75
+
76
+ print(f"Updated OpenRouter with {len(openrouter_models)} models.")
server.js ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ /**
4
+ * Management API server for the providers comparison app.
5
+ * Runs on port 3001 (Vite dev server runs on 5173).
6
+ *
7
+ * Routes:
8
+ * GET /api/status → provider status (model counts, last updated)
9
+ * POST /api/fetch/:provider → run one provider's fetcher
10
+ * POST /api/fetch → run all providers
11
+ */
12
+
13
+ const express = require('express');
14
+ const { execFile } = require('child_process');
15
+ const path = require('path');
16
+ const fs = require('fs');
17
+
18
+ const app = express();
19
+ const PORT = 3001;
20
+ const DATA_FILE = path.join(__dirname, 'data', 'providers.json');
21
+ const BENCHMARKS_FILE = path.join(__dirname, 'data', 'benchmarks.json');
22
+ const SCRIPTS_DIR = path.join(__dirname, 'scripts', 'providers');
23
+ const BENCHMARKS_SCRIPT = path.join(__dirname, 'scripts', 'fetch-benchmarks.js');
24
+
25
+ // In-memory state: which providers are currently being refreshed
26
+ const refreshing = new Set();
27
+
28
+ // Allow cross-origin requests from the Vite dev server
29
+ app.use((req, res, next) => {
30
+ res.setHeader('Access-Control-Allow-Origin', '*');
31
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
32
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
33
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
34
+ next();
35
+ });
36
+
37
+ app.use(express.json());
38
+
39
+ // Serve built static files in production
40
+ const distDir = path.join(__dirname, 'dist');
41
+ if (fs.existsSync(distDir)) {
42
+ app.use(express.static(distDir));
43
+ }
44
+
45
+ // ------------------------------------------------------------------
46
+ // GET /api/status
47
+ // Returns per-provider: model count, lastUpdated, whether a fetcher script exists
48
+ // ------------------------------------------------------------------
49
+ app.get('/api/status', (req, res) => {
50
+ const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
51
+
52
+ const status = data.providers.map((p) => {
53
+ const key = p.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
54
+ // Check if a fetcher script exists for this provider
55
+ const scriptPath = path.join(SCRIPTS_DIR, `${key}.js`);
56
+ // Also try common name variations (e.g. "mistral-ai" → "mistral")
57
+ const altKey = key.replace(/-ai$/, '').replace(/-/g, '');
58
+ const altScript = path.join(SCRIPTS_DIR, `${altKey}.js`);
59
+ const hasScript = fs.existsSync(scriptPath) || fs.existsSync(altScript);
60
+ const scriptKey = fs.existsSync(scriptPath) ? key : (fs.existsSync(altScript) ? altKey : null);
61
+
62
+ return {
63
+ name: p.name,
64
+ key: scriptKey || key,
65
+ modelCount: p.models?.length ?? 0,
66
+ lastUpdated: p.lastUpdated ?? null,
67
+ hasScript,
68
+ refreshing: refreshing.has(p.name),
69
+ };
70
+ });
71
+
72
+ // Include benchmark dataset info
73
+ let benchmarks = null;
74
+ if (fs.existsSync(BENCHMARKS_FILE)) {
75
+ try {
76
+ const bm = JSON.parse(fs.readFileSync(BENCHMARKS_FILE, 'utf8'));
77
+ benchmarks = {
78
+ entryCount: Array.isArray(bm) ? bm.length : (bm.entries?.length ?? 0),
79
+ lastUpdated: bm.lastUpdated ?? null,
80
+ refreshing: refreshing.has('__benchmarks__'),
81
+ };
82
+ } catch { /* ignore */ }
83
+ }
84
+
85
+ res.json({ providers: status, benchmarks });
86
+ });
87
+
88
+ // ------------------------------------------------------------------
89
+ // POST /api/fetch/:provider (provider = script key, e.g. "scaleway")
90
+ // POST /api/fetch (runs all providers that have a script)
91
+ // ------------------------------------------------------------------
92
+ function runFetcher(providerName, scriptKey) {
93
+ return new Promise((resolve) => {
94
+ if (refreshing.has(providerName)) {
95
+ return resolve({ provider: providerName, success: false, error: 'Already refreshing' });
96
+ }
97
+
98
+ const scriptPath = path.join(SCRIPTS_DIR, `${scriptKey}.js`);
99
+ if (!fs.existsSync(scriptPath)) {
100
+ return resolve({ provider: providerName, success: false, error: 'No fetcher script' });
101
+ }
102
+
103
+ refreshing.add(providerName);
104
+
105
+ // Run the main orchestrator for just this provider
106
+ execFile(
107
+ process.execPath,
108
+ [path.join(__dirname, 'scripts', 'fetch-providers.js'), scriptKey],
109
+ { cwd: __dirname, timeout: 60000 },
110
+ (err, stdout, stderr) => {
111
+ refreshing.delete(providerName);
112
+
113
+ // Stamp lastUpdated on the provider entry in providers.json
114
+ try {
115
+ const d = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
116
+ const prov = d.providers.find((p) => p.name === providerName);
117
+ if (prov) {
118
+ prov.lastUpdated = new Date().toISOString();
119
+ fs.writeFileSync(DATA_FILE, JSON.stringify(d, null, 2));
120
+ }
121
+ } catch { /* best effort */ }
122
+
123
+ if (err) {
124
+ resolve({ provider: providerName, success: false, error: err.message, stderr });
125
+ } else {
126
+ resolve({ provider: providerName, success: true, output: stdout });
127
+ }
128
+ }
129
+ );
130
+ });
131
+ }
132
+
133
+ // ------------------------------------------------------------------
134
+ // POST /api/fetch/benchmarks (runs scripts/fetch-benchmarks.js)
135
+ // ------------------------------------------------------------------
136
+ app.post('/api/fetch/benchmarks', async (req, res) => {
137
+ if (refreshing.has('__benchmarks__')) {
138
+ return res.json({ success: false, error: 'Already refreshing' });
139
+ }
140
+ refreshing.add('__benchmarks__');
141
+ execFile(
142
+ process.execPath,
143
+ [BENCHMARKS_SCRIPT],
144
+ { cwd: __dirname, timeout: 120000 },
145
+ (err, stdout, stderr) => {
146
+ refreshing.delete('__benchmarks__');
147
+ // Stamp lastUpdated into benchmarks.json
148
+ try {
149
+ const bm = JSON.parse(fs.readFileSync(BENCHMARKS_FILE, 'utf8'));
150
+ const arr = Array.isArray(bm) ? bm : (bm.entries ?? bm);
151
+ fs.writeFileSync(BENCHMARKS_FILE, JSON.stringify(
152
+ Array.isArray(bm) ? arr : Object.assign(bm, { lastUpdated: new Date().toISOString() }),
153
+ null, 2
154
+ ));
155
+ } catch { /* best effort */ }
156
+ if (err) res.json({ success: false, error: err.message });
157
+ else res.json({ success: true });
158
+ }
159
+ );
160
+ });
161
+
162
+ app.post('/api/fetch/:provider', async (req, res) => {
163
+ const scriptKey = req.params.provider;
164
+ // Find the provider name by script key
165
+ const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
166
+
167
+ // Try to match by lowercased name or script key
168
+ const match = data.providers.find((p) => {
169
+ const k = p.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
170
+ const ak = k.replace(/-ai$/, '').replace(/-/g, '');
171
+ return k === scriptKey || ak === scriptKey;
172
+ });
173
+
174
+ const providerName = match?.name ?? scriptKey;
175
+ const result = await runFetcher(providerName, scriptKey);
176
+ res.json(result);
177
+ });
178
+
179
+ app.post('/api/fetch', async (req, res) => {
180
+ const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
181
+ const scriptsAvailable = fs
182
+ .readdirSync(SCRIPTS_DIR)
183
+ .filter((f) => f.endsWith('.js'))
184
+ .map((f) => f.replace('.js', ''));
185
+
186
+ const tasks = data.providers
187
+ .filter((p) => {
188
+ const key = p.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
189
+ const altKey = key.replace(/-ai$/, '').replace(/-/g, '');
190
+ return scriptsAvailable.includes(key) || scriptsAvailable.includes(altKey);
191
+ })
192
+ .map((p) => {
193
+ const key = p.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
194
+ const altKey = key.replace(/-ai$/, '').replace(/-/g, '');
195
+ const scriptKey = scriptsAvailable.includes(key) ? key : altKey;
196
+ return runFetcher(p.name, scriptKey);
197
+ });
198
+
199
+ const results = await Promise.all(tasks);
200
+ res.json({ results });
201
+ });
202
+
203
+ app.listen(PORT, () => {
204
+ console.log(`Management API server running at http://localhost:${PORT}`);
205
+ console.log(` GET /api/status`);
206
+ console.log(` POST /api/fetch (all providers)`);
207
+ console.log(` POST /api/fetch/:key (single provider)`);
208
+ });
src/App.css ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --primary-color: #2563eb;
3
+ --bg-color: #f8fafc;
4
+ --text-color: #1e293b;
5
+ --border-color: #e2e8f0;
6
+ --white: #ffffff;
7
+ --success-color: #10b981;
8
+ --accent-color: #8b5cf6;
9
+ }
10
+
11
+ body {
12
+ margin: 0;
13
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
14
+ background-color: var(--bg-color);
15
+ color: var(--text-color);
16
+ -webkit-font-smoothing: antialiased;
17
+ }
18
+
19
+ .container {
20
+ max-width: 1400px;
21
+ margin: 0 auto;
22
+ padding: 2rem;
23
+ }
24
+
25
+ header {
26
+ text-align: center;
27
+ margin-bottom: 3rem;
28
+ }
29
+
30
+ h1 {
31
+ font-size: 2.5rem;
32
+ font-weight: 800;
33
+ margin-bottom: 0.5rem;
34
+ color: var(--text-color);
35
+ letter-spacing: -0.025em;
36
+ }
37
+
38
+ header p {
39
+ font-size: 1.125rem;
40
+ color: #64748b;
41
+ }
42
+
43
+ .controls {
44
+ display: flex;
45
+ gap: 1rem;
46
+ margin-bottom: 2rem;
47
+ align-items: center;
48
+ flex-wrap: wrap;
49
+ }
50
+
51
+ .checkbox-label {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 0.5rem;
55
+ font-size: 0.9rem;
56
+ font-weight: 500;
57
+ color: #475569;
58
+ cursor: pointer;
59
+ padding: 0.5rem 1rem;
60
+ background: #fff;
61
+ border-radius: 0.5rem;
62
+ border: 1px solid var(--border-color);
63
+ }
64
+
65
+ .search-input {
66
+ flex-grow: 1;
67
+ padding: 0.75rem 1rem;
68
+ border-radius: 0.5rem;
69
+ border: 1px solid var(--border-color);
70
+ font-size: 1rem;
71
+ outline: none;
72
+ transition: border-color 0.2s;
73
+ min-width: 300px;
74
+ }
75
+
76
+ .search-input:focus {
77
+ border-color: var(--primary-color);
78
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
79
+ }
80
+
81
+ .type-select {
82
+ padding: 0.75rem 1rem;
83
+ border-radius: 0.5rem;
84
+ border: 1px solid var(--border-color);
85
+ font-size: 1rem;
86
+ background-color: var(--white);
87
+ cursor: pointer;
88
+ }
89
+
90
+ .table-container {
91
+ background-color: var(--white);
92
+ border-radius: 0.75rem;
93
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
94
+ overflow: hidden;
95
+ border: 1px solid var(--border-color);
96
+ }
97
+
98
+ table {
99
+ width: 100%;
100
+ border-collapse: collapse;
101
+ text-align: left;
102
+ }
103
+
104
+ th {
105
+ background-color: #f1f5f9;
106
+ padding: 1rem;
107
+ font-weight: 600;
108
+ text-transform: uppercase;
109
+ font-size: 0.7rem;
110
+ letter-spacing: 0.05em;
111
+ color: #475569;
112
+ user-select: none;
113
+ }
114
+
115
+ th.sortable {
116
+ cursor: pointer;
117
+ transition: background-color 0.2s;
118
+ }
119
+
120
+ th.sortable:hover {
121
+ background-color: #e2e8f0;
122
+ }
123
+
124
+ td {
125
+ padding: 0.75rem 1rem;
126
+ border-bottom: 1px solid var(--border-color);
127
+ font-size: 0.85rem;
128
+ }
129
+
130
+ tr:last-child td {
131
+ border-bottom: none;
132
+ }
133
+
134
+ tr:hover {
135
+ background-color: #f8fafc;
136
+ }
137
+
138
+ tr.group-divider {
139
+ border-top: 2px solid #cbd5e1;
140
+ }
141
+
142
+ .provider-cell {
143
+ font-weight: 700;
144
+ color: var(--primary-color);
145
+ }
146
+
147
+ .model-name {
148
+ font-weight: 600;
149
+ font-family: 'JetBrains Mono', monospace;
150
+ font-size: 0.8rem;
151
+ color: #1e293b;
152
+ }
153
+
154
+ .size-cell {
155
+ font-family: 'JetBrains Mono', monospace;
156
+ font-size: 0.85rem;
157
+ color: #64748b;
158
+ }
159
+
160
+ .caps-cell {
161
+ white-space: nowrap;
162
+ font-size: 0.85rem;
163
+ letter-spacing: 0.02em;
164
+ }
165
+
166
+ .cap-badge {
167
+ display: inline-block;
168
+ cursor: default;
169
+ line-height: 1;
170
+ }
171
+
172
+ .benchmark-cell {
173
+ font-family: 'JetBrains Mono', monospace;
174
+ font-size: 0.82rem;
175
+ text-align: right;
176
+ color: #334155;
177
+ }
178
+ .benchmark-cell:has(> *:only-child) { color: #94a3b8; }
179
+
180
+ .status-badge {
181
+ display: inline-block;
182
+ padding: 0.2rem 0.5rem;
183
+ border-radius: 4px;
184
+ font-size: 0.65rem;
185
+ font-weight: 800;
186
+ text-transform: uppercase;
187
+ white-space: nowrap;
188
+ }
189
+
190
+ /* Jurisdiction Colors */
191
+ .eu--sovereign- { background-color: #dcfce7; color: #166534; }
192
+ .non-eu--eea-swiss- { background-color: #fef9c3; color: #854d0e; }
193
+ .us--eu-endpoint---cloud-act- { background-color: #ffedd5; color: #9a3412; }
194
+ .us--global---cloud-act- { background-color: #fee2e2; color: #991b1b; }
195
+ .other-non-eu { background-color: #f1f5f9; color: #475569; }
196
+
197
+ footer {
198
+ margin-top: 2rem;
199
+ text-align: center;
200
+ font-size: 0.8rem;
201
+ color: #94a3b8;
202
+ }
203
+
204
+ @media (max-width: 1024px) {
205
+ .controls {
206
+ flex-direction: column;
207
+ align-items: stretch;
208
+ }
209
+ }
210
+
211
+ /* ── Header with manage button ─────────────────────────────── */
212
+ .header-row {
213
+ display: flex;
214
+ justify-content: space-between;
215
+ align-items: flex-start;
216
+ gap: 1rem;
217
+ }
218
+ .header-actions {
219
+ display: flex;
220
+ align-items: center;
221
+ gap: 0.75rem;
222
+ padding-top: 0.5rem;
223
+ flex-shrink: 0;
224
+ }
225
+ .btn-manage {
226
+ background: var(--primary-color);
227
+ color: white;
228
+ border: none;
229
+ border-radius: 8px;
230
+ padding: 0.5rem 1rem;
231
+ font-size: 0.85rem;
232
+ font-weight: 600;
233
+ cursor: pointer;
234
+ white-space: nowrap;
235
+ }
236
+ .btn-manage:hover { opacity: 0.88; }
237
+ .data-stale-hint {
238
+ font-size: 0.78rem;
239
+ color: var(--success-color);
240
+ font-weight: 500;
241
+ }
242
+
243
+ /* ── Modal overlay ──────────────────────────────────────────── */
244
+ .modal-overlay {
245
+ position: fixed;
246
+ inset: 0;
247
+ background: rgba(0,0,0,0.45);
248
+ display: flex;
249
+ align-items: center;
250
+ justify-content: center;
251
+ z-index: 1000;
252
+ }
253
+ .modal-panel {
254
+ background: var(--white);
255
+ border-radius: 12px;
256
+ box-shadow: 0 20px 60px rgba(0,0,0,0.2);
257
+ padding: 1.5rem;
258
+ min-width: 640px;
259
+ max-width: 90vw;
260
+ max-height: 85vh;
261
+ overflow-y: auto;
262
+ }
263
+ .modal-header {
264
+ display: flex;
265
+ justify-content: space-between;
266
+ align-items: center;
267
+ margin-bottom: 1.25rem;
268
+ }
269
+ .modal-header h2 {
270
+ margin: 0;
271
+ font-size: 1.1rem;
272
+ color: var(--text-color);
273
+ }
274
+ .modal-close {
275
+ background: none;
276
+ border: none;
277
+ font-size: 1.1rem;
278
+ cursor: pointer;
279
+ color: #94a3b8;
280
+ line-height: 1;
281
+ padding: 2px 6px;
282
+ }
283
+ .modal-close:hover { color: var(--text-color); }
284
+
285
+ /* ── Management table ───────────────────────────────────────── */
286
+ .server-warning {
287
+ background: #fff7ed;
288
+ border: 1px solid #fdba74;
289
+ border-radius: 8px;
290
+ padding: 0.75rem 1rem;
291
+ font-size: 0.85rem;
292
+ color: #9a3412;
293
+ margin-bottom: 1rem;
294
+ }
295
+ .server-warning code {
296
+ background: #ffedd5;
297
+ padding: 1px 5px;
298
+ border-radius: 4px;
299
+ font-family: monospace;
300
+ }
301
+ .panel-actions {
302
+ margin-bottom: 1rem;
303
+ }
304
+ .btn-refresh-all {
305
+ background: var(--primary-color);
306
+ color: white;
307
+ border: none;
308
+ border-radius: 8px;
309
+ padding: 0.5rem 1.1rem;
310
+ font-size: 0.85rem;
311
+ font-weight: 600;
312
+ cursor: pointer;
313
+ }
314
+ .btn-refresh-all:disabled { opacity: 0.5; cursor: not-allowed; }
315
+ .btn-refresh-all:not(:disabled):hover { opacity: 0.88; }
316
+
317
+ .management-table {
318
+ width: 100%;
319
+ border-collapse: collapse;
320
+ font-size: 0.85rem;
321
+ }
322
+ .management-table th {
323
+ text-align: left;
324
+ padding: 0.5rem 0.75rem;
325
+ color: #64748b;
326
+ font-weight: 600;
327
+ border-bottom: 2px solid var(--border-color);
328
+ }
329
+ .management-table td {
330
+ padding: 0.6rem 0.75rem;
331
+ border-bottom: 1px solid var(--border-color);
332
+ vertical-align: middle;
333
+ }
334
+ .management-table tr:last-child td { border-bottom: none; }
335
+ .mgmt-section-heading {
336
+ font-size: 0.8rem;
337
+ font-weight: 700;
338
+ text-transform: uppercase;
339
+ letter-spacing: 0.06em;
340
+ color: #64748b;
341
+ margin: 1.5rem 0 0.5rem;
342
+ }
343
+ .mgmt-provider { font-weight: 600; }
344
+ .mgmt-count { color: #475569; font-variant-numeric: tabular-nums; }
345
+ .mgmt-age { color: #94a3b8; font-size: 0.8rem; }
346
+ .btn-refresh {
347
+ background: #f1f5f9;
348
+ border: 1px solid var(--border-color);
349
+ border-radius: 6px;
350
+ padding: 0.25rem 0.6rem;
351
+ cursor: pointer;
352
+ font-size: 1rem;
353
+ line-height: 1.2;
354
+ }
355
+ .btn-refresh:disabled { opacity: 0.4; cursor: not-allowed; }
356
+ .btn-refresh:not(:disabled):hover { background: #e2e8f0; }
357
+
358
+ .badge-ok { background: #dcfce7; color: #166534; padding: 2px 7px; border-radius: 9px; font-size: 0.75rem; }
359
+ .badge-err { background: #fee2e2; color: #991b1b; padding: 2px 7px; border-radius: 9px; font-size: 0.75rem; cursor: help; }
360
+ .badge-script.has-script { background: #dbeafe; color: #1e40af; padding: 2px 7px; border-radius: 9px; font-size: 0.75rem; }
361
+ .badge-script.manual { background: #f1f5f9; color: #475569; padding: 2px 7px; border-radius: 9px; font-size: 0.75rem; }
362
+
363
+ .panel-loading { color: #94a3b8; font-size: 0.85rem; padding: 1rem 0; }
364
+ .panel-error { color: #991b1b; background: #fee2e2; border-radius: 8px; padding: 0.75rem; font-size: 0.85rem; margin-top: 1rem; }
src/App.tsx ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useMemo, useCallback } from 'react'
2
+ import providersData from '../data/providers.json'
3
+ import benchmarksData from '../data/benchmarks.json'
4
+ import { ManagementPanel } from './components/ManagementPanel'
5
+
6
+ interface Model {
7
+ name: string
8
+ category?: string
9
+ type: string
10
+ size_b?: number
11
+ input_price_per_1m?: number
12
+ output_price_per_1m?: number
13
+ price_per_image?: number
14
+ price_per_minute?: number
15
+ price_per_1m_tokens_30d?: number
16
+ currency: string
17
+ capabilities?: string[]
18
+ }
19
+
20
+ interface Provider {
21
+ name: string
22
+ url: string
23
+ headquarters: string
24
+ region: string
25
+ gdpr_compliant: boolean
26
+ eu_endpoints: boolean
27
+ models: Model[]
28
+ }
29
+
30
+ type SortConfig = {
31
+ key: string;
32
+ direction: 'asc' | 'desc';
33
+ } | null;
34
+
35
+ interface BenchmarkEntry {
36
+ slug?: string; // LLMStats slug
37
+ hf_id?: string; // HF leaderboard fullname
38
+ name: string;
39
+ // LLMStats benchmarks
40
+ mmlu?: number;
41
+ mmlu_pro?: number;
42
+ gpqa?: number;
43
+ human_eval?: number;
44
+ math?: number;
45
+ gsm8k?: number;
46
+ mmmu?: number;
47
+ hellaswag?: number;
48
+ ifeval?: number;
49
+ arc?: number;
50
+ drop?: number;
51
+ mbpp?: number;
52
+ mgsm?: number;
53
+ bbh?: number;
54
+ // HF-specific
55
+ hf_math_lvl5?: number;
56
+ hf_musr?: number;
57
+ hf_avg?: number;
58
+ params_b?: number;
59
+ // LiveBench (livebench.ai — contamination-free, monthly updated)
60
+ lb_name?: string;
61
+ lb_global?: number;
62
+ lb_reasoning?: number;
63
+ lb_coding?: number;
64
+ lb_math?: number;
65
+ lb_language?: number;
66
+ lb_if?: number;
67
+ lb_data_analysis?: number;
68
+ // Chatbot Arena (lmarena.ai — real human preference votes)
69
+ arena_name?: string;
70
+ arena_elo?: number; // raw ELO score ~800-1500 (higher = better)
71
+ arena_rank?: number;
72
+ arena_votes?: number;
73
+ // Aider code editing benchmark (aider.chat)
74
+ aider_pass_rate?: number; // 0-1, first-pass success on 133 coding tasks
75
+ }
76
+
77
+ const normalizeName = (s: string) =>
78
+ s.toLowerCase().replace(/[-_.]/g, ' ').replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
79
+
80
+ const EXCHANGE_RATE_EUR_TO_USD = 1.05
81
+
82
+ const CAP_ICON: Record<string, string> = {
83
+ vision: '👁',
84
+ video: '🎬',
85
+ audio: '🎤',
86
+ 'audio-out': '🔊',
87
+ files: '📄',
88
+ 'image-gen': '🎨',
89
+ tools: '🔧',
90
+ reasoning: '💡',
91
+ }
92
+
93
+ function App() {
94
+ const [searchTerm, setSearchTerm] = useState('')
95
+ const [selectedType, setSelectedType] = useState('all')
96
+ const [selectedRegion, setSelectedRegion] = useState('all')
97
+ const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'price', direction: 'asc' });
98
+ const [groupByModel, setGroupByModel] = useState(false);
99
+ const [showBenchmarks, setShowBenchmarks] = useState(false);
100
+ const [showManagement, setShowManagement] = useState(false);
101
+ const [dataVersion, setDataVersion] = useState(0);
102
+
103
+ // Build benchmark lookup maps
104
+ const { nameMap, hfIdMap } = useMemo(() => {
105
+ const nameMap = new Map<string, BenchmarkEntry>();
106
+ const hfIdMap = new Map<string, BenchmarkEntry>();
107
+
108
+ for (const b of benchmarksData as BenchmarkEntry[]) {
109
+ // Name-based lookup (LLMStats names + HF model part)
110
+ nameMap.set(normalizeName(b.name), b);
111
+ if (b.slug) {
112
+ const slugModel = b.slug.split('/').pop() || '';
113
+ if (slugModel) nameMap.set(normalizeName(slugModel), b);
114
+ }
115
+ if (b.hf_id) {
116
+ // Full HF ID lookup (for direct matches from OpenRouter/Requesty)
117
+ hfIdMap.set(normalizeName(b.hf_id), b);
118
+ // Model part only (after "/")
119
+ const modelPart = b.hf_id.split('/').pop() || '';
120
+ const normModel = normalizeName(modelPart);
121
+ nameMap.set(normModel, b);
122
+ // Strip leading word from model part (removes embedded org prefix like "Meta-Llama-...")
123
+ const words = normModel.split(' ');
124
+ if (words.length > 1) nameMap.set(words.slice(1).join(' '), b);
125
+ }
126
+ // LiveBench model name (e.g. "claude-3-5-sonnet-20241022")
127
+ if (b.lb_name) nameMap.set(normalizeName(b.lb_name), b);
128
+ // Chatbot Arena display name
129
+ if (b.arena_name) nameMap.set(normalizeName(b.arena_name), b);
130
+ }
131
+ return { nameMap, hfIdMap };
132
+ }, []);
133
+
134
+ const findBenchmark = useCallback((modelName: string): BenchmarkEntry | undefined => {
135
+ // Strip @region (e.g. @us-east-1) and :effort (e.g. :high) suffixes before normalizing
136
+ const cleanName = modelName.replace(/@[^/]+$/, '').replace(/:[^/]+$/, '');
137
+ const norm = normalizeName(cleanName);
138
+
139
+ // Direct HF ID match (for providers that use "org/model-id" format)
140
+ let modelPart = '';
141
+ if (cleanName.includes('/')) {
142
+ if (hfIdMap.has(norm)) return hfIdMap.get(norm)!;
143
+ // Try model part only (after "/")
144
+ modelPart = normalizeName(cleanName.split('/').pop() || '');
145
+ if (nameMap.has(modelPart)) return nameMap.get(modelPart)!;
146
+ // Strip first word from model part — handles "Meta-Llama-..." vs "Llama-..."
147
+ const modelWords = modelPart.split(' ');
148
+ if (modelWords.length > 1) {
149
+ const stripped = modelWords.slice(1).join(' ');
150
+ if (nameMap.has(stripped)) return nameMap.get(stripped)!;
151
+ }
152
+ }
153
+
154
+ if (nameMap.has(norm)) return nameMap.get(norm);
155
+
156
+ // Longest startsWith match — handles date-suffixed variants like "claude-3-5-sonnet-20241022"
157
+ let best: BenchmarkEntry | undefined;
158
+ let bestLen = 0;
159
+ for (const [key, val] of nameMap) {
160
+ if (norm.startsWith(key)) {
161
+ const rest = norm.slice(key.length);
162
+ if ((rest === '' || /^ \d/.test(rest)) && key.length > bestLen) {
163
+ best = val;
164
+ bestLen = key.length;
165
+ }
166
+ }
167
+ }
168
+ if (best) return best;
169
+
170
+ // Reverse prefix: benchmark key starts with provider name — handles cases where the
171
+ // benchmark stores a base name longer than the provider's model ID.
172
+ // Check both norm and modelPart (for versionless names like "anthropic/claude-haiku-4-5").
173
+ // Pick the highest lb_global among all matches (best variant of the model).
174
+ let bestReverse: BenchmarkEntry | undefined;
175
+ let bestReverseScore = -1;
176
+ for (const [key, val] of nameMap) {
177
+ const score = val.lb_global ?? 0;
178
+ if (
179
+ (key.startsWith(norm + ' ') || (modelPart && key.startsWith(modelPart + ' '))) &&
180
+ score > bestReverseScore
181
+ ) {
182
+ bestReverse = val;
183
+ bestReverseScore = score;
184
+ }
185
+ }
186
+ return bestReverse;
187
+ }, [nameMap, hfIdMap]);
188
+
189
+ const handleDataUpdated = useCallback(() => {
190
+ // Increment version to signal that a page reload would show fresh data
191
+ setDataVersion((v) => v + 1);
192
+ }, []);
193
+
194
+ const getComplianceStatus = (provider: Provider) => {
195
+ const hq = provider.headquarters.toLowerCase();
196
+ const isEU = provider.region === 'EU' || hq === 'germany' || hq === 'france' || hq === 'netherlands';
197
+ const isUS = hq === 'usa';
198
+ const isEEA = provider.region === 'EEA Equivalent' || hq === 'switzerland';
199
+
200
+ if (isEU) return 'EU (Sovereign)';
201
+ if (isEEA) return 'Non-EU (EEA/Swiss)';
202
+ if (isUS && provider.eu_endpoints) return 'US (EU Endpoint / Cloud Act)';
203
+ if (isUS) return 'US (Global / Cloud Act)';
204
+ return 'Other Non-EU';
205
+ };
206
+
207
+ const allModels = useMemo(() => {
208
+ const rawModels: (Model & { provider: Provider; complianceStatus: string })[] = []
209
+
210
+ providersData.providers.forEach((provider: Provider) => {
211
+ const status = getComplianceStatus(provider);
212
+ provider.models.forEach((model) => {
213
+ let cleanName = model.name;
214
+ // Strip provider/ prefix for all models
215
+ if (cleanName.includes('/')) {
216
+ cleanName = cleanName.split('/').pop() || cleanName;
217
+ }
218
+
219
+ rawModels.push({
220
+ ...model,
221
+ name: cleanName,
222
+ provider,
223
+ complianceStatus: status
224
+ })
225
+ })
226
+ })
227
+
228
+ // De-duplicate: filter out models with same name, provider, and price
229
+ const seen = new Set<string>();
230
+ const uniqueModels = rawModels.filter(m => {
231
+ const key = `${m.provider.name}|${m.name}|${m.input_price_per_1m}|${m.output_price_per_1m}`;
232
+ if (seen.has(key)) return false;
233
+ seen.add(key);
234
+ return true;
235
+ });
236
+
237
+ return uniqueModels;
238
+ }, [])
239
+
240
+ const filteredModels = useMemo(() => {
241
+ return allModels.filter((model) => {
242
+ const matchesSearch = model.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
243
+ model.provider.name.toLowerCase().includes(searchTerm.toLowerCase())
244
+ const matchesType = selectedType === 'all' || model.type === selectedType
245
+ const matchesRegion = selectedRegion === 'all' || model.complianceStatus.includes(selectedRegion)
246
+ return matchesSearch && matchesType && matchesRegion
247
+ })
248
+ }, [searchTerm, selectedType, selectedRegion, allModels])
249
+
250
+ const getNormalizedPriceUSD = (model: Model) => {
251
+ const price = model.input_price_per_1m ?? model.price_per_image ?? model.price_per_minute ?? 0
252
+ return model.currency === 'EUR' ? price * EXCHANGE_RATE_EUR_TO_USD : price
253
+ }
254
+
255
+ const sortedModels = useMemo(() => {
256
+ return [...filteredModels].sort((a, b) => {
257
+ if (!sortConfig) return 0;
258
+ let aValue: any;
259
+ let bValue: any;
260
+
261
+ switch (sortConfig.key) {
262
+ case 'provider':
263
+ aValue = a.provider.name;
264
+ bValue = b.provider.name;
265
+ break;
266
+ case 'model':
267
+ aValue = a.name;
268
+ bValue = b.name;
269
+ break;
270
+ case 'size':
271
+ aValue = a.size_b || 0;
272
+ bValue = b.size_b || 0;
273
+ break;
274
+ case 'price':
275
+ aValue = getNormalizedPriceUSD(a);
276
+ bValue = getNormalizedPriceUSD(b);
277
+ break;
278
+ case 'output_price':
279
+ aValue = a.output_price_per_1m ?? 0;
280
+ bValue = b.output_price_per_1m ?? 0;
281
+ break;
282
+ case 'compliance':
283
+ aValue = a.complianceStatus;
284
+ bValue = b.complianceStatus;
285
+ break;
286
+ case 'mmlu':
287
+ case 'gpqa':
288
+ case 'human_eval':
289
+ case 'math':
290
+ case 'gsm8k':
291
+ case 'mmmu':
292
+ case 'ifeval':
293
+ case 'bbh':
294
+ case 'hf_math_lvl5':
295
+ case 'hf_musr':
296
+ case 'hf_avg':
297
+ case 'lb_global':
298
+ case 'lb_reasoning':
299
+ case 'lb_coding':
300
+ case 'lb_math':
301
+ case 'lb_language':
302
+ case 'lb_if':
303
+ case 'lb_data_analysis':
304
+ case 'arena_elo':
305
+ case 'aider_pass_rate': {
306
+ const bA = findBenchmark(a.name);
307
+ const bB = findBenchmark(b.name);
308
+ aValue = bA?.[sortConfig.key as keyof BenchmarkEntry] as number ?? -1;
309
+ bValue = bB?.[sortConfig.key as keyof BenchmarkEntry] as number ?? -1;
310
+ break;
311
+ }
312
+ default:
313
+ return 0;
314
+ }
315
+
316
+ if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
317
+ if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
318
+ return 0;
319
+ });
320
+ }, [filteredModels, sortConfig])
321
+
322
+ const displayModels = useMemo(() => {
323
+ if (!groupByModel) return sortedModels;
324
+
325
+ const groups: Record<string, typeof sortedModels> = {};
326
+ sortedModels.forEach(m => {
327
+ const name = m.name.toLowerCase();
328
+ if (!groups[name]) groups[name] = [];
329
+ groups[name].push(m);
330
+ });
331
+
332
+ const result: typeof sortedModels = [];
333
+ Object.values(groups).forEach(group => {
334
+ result.push(...group);
335
+ });
336
+ return result;
337
+ }, [sortedModels, groupByModel]);
338
+
339
+ const requestSort = (key: string) => {
340
+ let direction: 'asc' | 'desc' = 'asc';
341
+ if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') {
342
+ direction = 'desc';
343
+ }
344
+ setSortConfig({ key, direction });
345
+ };
346
+
347
+ const formatPrice = (price: number | undefined, currency: string) => {
348
+ if (price === undefined) return '-'
349
+ if (price === 0) return 'Free'
350
+ const symbol = currency === 'USD' ? '$' : '€'
351
+ return `${symbol}${price.toFixed(4)}`
352
+ }
353
+
354
+ const getSortIcon = (key: string) => {
355
+ if (sortConfig?.key !== key) return '↕️';
356
+ return sortConfig.direction === 'asc' ? '🔼' : '🔽';
357
+ }
358
+
359
+ return (
360
+ <div className="container">
361
+ <header>
362
+ <div className="header-row">
363
+ <div>
364
+ <h1>AI Provider Comparison</h1>
365
+ <p>Analyze costs, data sovereignty, and model efficiency.</p>
366
+ </div>
367
+ <div className="header-actions">
368
+ {dataVersion > 0 && (
369
+ <span className="data-stale-hint" title="Data was refreshed — reload the page to see updated prices">
370
+ ↻ data updated
371
+ </span>
372
+ )}
373
+ <button className="btn-manage" onClick={() => setShowManagement(true)}>
374
+ ⚙ Manage Data
375
+ </button>
376
+ </div>
377
+ </div>
378
+ </header>
379
+
380
+ {showManagement && (
381
+ <ManagementPanel
382
+ onClose={() => setShowManagement(false)}
383
+ onDataUpdated={handleDataUpdated}
384
+ />
385
+ )}
386
+
387
+ <div className="controls">
388
+ <input
389
+ type="text"
390
+ placeholder="Search models or providers..."
391
+ value={searchTerm}
392
+ onChange={(e) => setSearchTerm(e.target.value)}
393
+ className="search-input"
394
+ />
395
+ <select value={selectedType} onChange={(e) => setSelectedType(e.target.value)} className="type-select">
396
+ <option value="all">All Types</option>
397
+ <option value="chat">Chat (LLM)</option>
398
+ <option value="vision">Vision</option>
399
+ <option value="embedding">Embedding</option>
400
+ <option value="image">Image</option>
401
+ <option value="audio">Audio</option>
402
+ </select>
403
+ <select value={selectedRegion} onChange={(e) => setSelectedRegion(e.target.value)} className="type-select">
404
+ <option value="all">All Jurisdictions</option>
405
+ <option value="EU">EU (Sovereign)</option>
406
+ <option value="Non-EU">Non-EU (Swiss/EEA)</option>
407
+ <option value="US">US (Cloud Act)</option>
408
+ </select>
409
+ <label className="checkbox-label">
410
+ <input
411
+ type="checkbox"
412
+ checked={groupByModel}
413
+ onChange={(e) => setGroupByModel(e.target.checked)}
414
+ />
415
+ Group by Model
416
+ </label>
417
+ <label className="checkbox-label">
418
+ <input
419
+ type="checkbox"
420
+ checked={showBenchmarks}
421
+ onChange={(e) => setShowBenchmarks(e.target.checked)}
422
+ />
423
+ Show Benchmarks
424
+ </label>
425
+ </div>
426
+
427
+ <div className="table-container">
428
+ <table>
429
+ <thead>
430
+ <tr>
431
+ <th onClick={() => requestSort('provider')} className="sortable">Provider {getSortIcon('provider')}</th>
432
+ <th onClick={() => requestSort('compliance')} className="sortable">Jurisdiction {getSortIcon('compliance')}</th>
433
+ <th onClick={() => requestSort('model')} className="sortable">Model {getSortIcon('model')}</th>
434
+ <th>Caps</th>
435
+ <th onClick={() => requestSort('size')} className="sortable">Size (B) {getSortIcon('size')}</th>
436
+ <th onClick={() => requestSort('price')} className="sortable">Input Price (USD) {getSortIcon('price')}</th>
437
+ <th onClick={() => requestSort('output_price')} className="sortable">Output Price {getSortIcon('output_price')}</th>
438
+ {showBenchmarks && <>
439
+ <th onClick={() => requestSort('arena_elo')} className="sortable" title="Chatbot Arena ELO (human preference votes)">Arena ELO {getSortIcon('arena_elo')}</th>
440
+ <th onClick={() => requestSort('aider_pass_rate')} className="sortable" title="Aider code editing benchmark (pass rate, 133 tasks)">Aider {getSortIcon('aider_pass_rate')}</th>
441
+ <th onClick={() => requestSort('lb_global')} className="sortable" title="LiveBench overall average (contamination-free)">LB {getSortIcon('lb_global')}</th>
442
+ <th onClick={() => requestSort('lb_math')} className="sortable" title="LiveBench Mathematics">LB-Math {getSortIcon('lb_math')}</th>
443
+ <th onClick={() => requestSort('lb_coding')} className="sortable" title="LiveBench Coding + Agentic Coding">LB-Code {getSortIcon('lb_coding')}</th>
444
+ <th onClick={() => requestSort('lb_reasoning')} className="sortable" title="LiveBench Reasoning">LB-Reas {getSortIcon('lb_reasoning')}</th>
445
+ <th onClick={() => requestSort('gpqa')} className="sortable" title="Graduate-level reasoning (GPQA)">GPQA {getSortIcon('gpqa')}</th>
446
+ <th onClick={() => requestSort('mmlu_pro')} className="sortable" title="MMLU-Pro knowledge">MMLU-PRO {getSortIcon('mmlu_pro')}</th>
447
+ <th onClick={() => requestSort('ifeval')} className="sortable" title="Instruction following (IFEval)">IFEval {getSortIcon('ifeval')}</th>
448
+ <th onClick={() => requestSort('bbh')} className="sortable" title="Big-Bench Hard reasoning">BBH {getSortIcon('bbh')}</th>
449
+ <th onClick={() => requestSort('hf_math_lvl5')} className="sortable" title="MATH Level 5 (HF leaderboard)">MATH L5 {getSortIcon('hf_math_lvl5')}</th>
450
+ <th onClick={() => requestSort('hf_musr')} className="sortable" title="Multi-step Soft Reasoning (HF leaderboard)">MUSR {getSortIcon('hf_musr')}</th>
451
+ <th onClick={() => requestSort('mmlu')} className="sortable" title="Classic MMLU (LLMStats)">MMLU {getSortIcon('mmlu')}</th>
452
+ <th onClick={() => requestSort('human_eval')} className="sortable" title="HumanEval coding (LLMStats)">HumanEval {getSortIcon('human_eval')}</th>
453
+ </>}
454
+ </tr>
455
+ </thead>
456
+ <tbody>
457
+ {displayModels.map((model, idx) => {
458
+ const isGroupStart = groupByModel && (idx === 0 || displayModels[idx-1].name.toLowerCase() !== model.name.toLowerCase());
459
+ return (
460
+ <tr key={`${model.provider.name}-${model.name}-${idx}`} className={isGroupStart ? 'group-divider' : ''}>
461
+ <td className="provider-cell">{model.provider.name}</td>
462
+ <td>
463
+ <span className={`status-badge ${model.complianceStatus.replace(/\s+|[\(\)\/]/g, '-').toLowerCase()}`}>
464
+ {model.complianceStatus}
465
+ </span>
466
+ </td>
467
+ <td className="model-name">{model.name}</td>
468
+ <td className="caps-cell">
469
+ {(model.capabilities || []).map(cap => (
470
+ <span key={cap} className={`cap-badge cap-${cap}`} title={cap}>{CAP_ICON[cap] ?? cap}</span>
471
+ ))}
472
+ </td>
473
+ <td className="size-cell">{model.size_b ? `${model.size_b}B` : '-'}</td>
474
+ <td>{formatPrice(model.input_price_per_1m, model.currency)}</td>
475
+ <td>{formatPrice(model.output_price_per_1m, model.currency)}</td>
476
+ {showBenchmarks && (() => {
477
+ const bm = findBenchmark(model.name);
478
+ const fmt = (v?: number) => Number.isFinite(v) ? `${(v! * 100).toFixed(0)}%` : '–';
479
+ return <>
480
+ <td className="benchmark-cell">{bm?.arena_elo !== undefined ? Math.round(bm.arena_elo) : '–'}</td>
481
+ <td className="benchmark-cell">{fmt(bm?.aider_pass_rate)}</td>
482
+ <td className="benchmark-cell">{fmt(bm?.lb_global)}</td>
483
+ <td className="benchmark-cell">{fmt(bm?.lb_math)}</td>
484
+ <td className="benchmark-cell">{fmt(bm?.lb_coding)}</td>
485
+ <td className="benchmark-cell">{fmt(bm?.lb_reasoning)}</td>
486
+ <td className="benchmark-cell">{fmt(bm?.gpqa)}</td>
487
+ <td className="benchmark-cell">{fmt(bm?.mmlu_pro)}</td>
488
+ <td className="benchmark-cell">{fmt(bm?.ifeval)}</td>
489
+ <td className="benchmark-cell">{fmt(bm?.bbh)}</td>
490
+ <td className="benchmark-cell">{fmt(bm?.hf_math_lvl5)}</td>
491
+ <td className="benchmark-cell">{fmt(bm?.hf_musr)}</td>
492
+ <td className="benchmark-cell">{fmt(bm?.mmlu)}</td>
493
+ <td className="benchmark-cell">{fmt(bm?.human_eval)}</td>
494
+ </>;
495
+ })()}
496
+ </tr>
497
+ )
498
+ })}
499
+ </tbody>
500
+ </table>
501
+ </div>
502
+
503
+ <footer>
504
+ <p>* All prices normalized to USD for comparison using 1 EUR = {EXCHANGE_RATE_EUR_TO_USD} USD.</p>
505
+ <p>Sorted by input price by default.</p>
506
+ </footer>
507
+ </div>
508
+ )
509
+ }
510
+
511
+ export default App
src/components/ManagementPanel.tsx ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from 'react'
2
+
3
+ interface ProviderStatus {
4
+ name: string
5
+ key: string
6
+ modelCount: number
7
+ lastUpdated: string | null
8
+ hasScript: boolean
9
+ refreshing: boolean
10
+ }
11
+
12
+ interface BenchmarkStatus {
13
+ entryCount: number
14
+ lastUpdated: string | null
15
+ refreshing: boolean
16
+ }
17
+
18
+ interface FetchResult {
19
+ provider: string
20
+ success: boolean
21
+ error?: string
22
+ }
23
+
24
+ function formatAge(iso: string | null): string {
25
+ if (!iso) return 'never'
26
+ const diff = Date.now() - new Date(iso).getTime()
27
+ const mins = Math.floor(diff / 60000)
28
+ const hours = Math.floor(mins / 60)
29
+ const days = Math.floor(hours / 24)
30
+ if (days > 0) return `${days}d ago`
31
+ if (hours > 0) return `${hours}h ago`
32
+ if (mins > 0) return `${mins}m ago`
33
+ return 'just now'
34
+ }
35
+
36
+ interface Props {
37
+ onClose: () => void
38
+ onDataUpdated: () => void
39
+ }
40
+
41
+ export function ManagementPanel({ onClose, onDataUpdated }: Props) {
42
+ const [providers, setProviders] = useState<ProviderStatus[]>([])
43
+ const [benchmarks, setBenchmarks] = useState<BenchmarkStatus | null>(null)
44
+ const [loading, setLoading] = useState(true)
45
+ const [error, setError] = useState<string | null>(null)
46
+ const [results, setResults] = useState<Record<string, { success: boolean; error?: string }>>({})
47
+ const [bmResult, setBmResult] = useState<{ success: boolean; error?: string } | null>(null)
48
+ const [refreshingAll, setRefreshingAll] = useState(false)
49
+ const [serverAvailable, setServerAvailable] = useState(true)
50
+
51
+ const fetchStatus = useCallback(async () => {
52
+ try {
53
+ const res = await fetch('/api/status')
54
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
55
+ const data = await res.json()
56
+ setProviders(data.providers)
57
+ if (data.benchmarks) setBenchmarks(data.benchmarks)
58
+ setServerAvailable(true)
59
+ setError(null)
60
+ } catch (e: any) {
61
+ setServerAvailable(false)
62
+ setError('Management server not running. Start it with: node server.js')
63
+ } finally {
64
+ setLoading(false)
65
+ }
66
+ }, [])
67
+
68
+ useEffect(() => {
69
+ fetchStatus()
70
+ }, [fetchStatus])
71
+
72
+ const refreshProvider = async (key: string, name: string) => {
73
+ setProviders((prev) =>
74
+ prev.map((p) => (p.name === name ? { ...p, refreshing: true } : p))
75
+ )
76
+ setResults((r) => ({ ...r, [name]: { success: false } }))
77
+
78
+ try {
79
+ const res = await fetch(`/api/fetch/${key}`, { method: 'POST' })
80
+ const data: FetchResult = await res.json()
81
+ setResults((r) => ({ ...r, [name]: { success: data.success, error: data.error } }))
82
+ if (data.success) onDataUpdated()
83
+ } catch {
84
+ setResults((r) => ({ ...r, [name]: { success: false, error: 'Request failed' } }))
85
+ }
86
+
87
+ await fetchStatus()
88
+ }
89
+
90
+ const refreshBenchmarks = async () => {
91
+ setBenchmarks((b) => b ? { ...b, refreshing: true } : null)
92
+ setBmResult(null)
93
+ try {
94
+ const res = await fetch('/api/fetch/benchmarks', { method: 'POST' })
95
+ const data = await res.json()
96
+ setBmResult({ success: data.success, error: data.error })
97
+ if (data.success) onDataUpdated()
98
+ } catch {
99
+ setBmResult({ success: false, error: 'Request failed' })
100
+ }
101
+ await fetchStatus()
102
+ }
103
+
104
+ const refreshAll = async () => {
105
+ setRefreshingAll(true)
106
+ setResults({})
107
+ try {
108
+ const res = await fetch('/api/fetch', { method: 'POST' })
109
+ const data = await res.json()
110
+ const resultMap: Record<string, { success: boolean; error?: string }> = {}
111
+ for (const r of data.results ?? []) {
112
+ resultMap[r.provider] = { success: r.success, error: r.error }
113
+ }
114
+ setResults(resultMap)
115
+ onDataUpdated()
116
+ } catch {
117
+ setError('Refresh all failed')
118
+ }
119
+ setRefreshingAll(false)
120
+ await fetchStatus()
121
+ }
122
+
123
+ const scriptCount = providers.filter((p) => p.hasScript).length
124
+
125
+ return (
126
+ <div className="modal-overlay" onClick={onClose}>
127
+ <div className="modal-panel" onClick={(e) => e.stopPropagation()}>
128
+ <div className="modal-header">
129
+ <h2>Data Management</h2>
130
+ <button className="modal-close" onClick={onClose}>✕</button>
131
+ </div>
132
+
133
+ {!serverAvailable && (
134
+ <div className="server-warning">
135
+ <strong>Management server offline.</strong> Run <code>node server.js</code> in the project root to enable live updates.
136
+ </div>
137
+ )}
138
+
139
+ {loading && <div className="panel-loading">Loading status…</div>}
140
+
141
+ {!loading && serverAvailable && (
142
+ <>
143
+ <div className="panel-actions">
144
+ <button
145
+ className="btn-refresh-all"
146
+ onClick={refreshAll}
147
+ disabled={refreshingAll || scriptCount === 0}
148
+ >
149
+ {refreshingAll ? '⟳ Refreshing all…' : `⟳ Refresh all (${scriptCount} providers)`}
150
+ </button>
151
+ </div>
152
+
153
+ <table className="management-table">
154
+ <thead>
155
+ <tr>
156
+ <th>Provider</th>
157
+ <th>Models</th>
158
+ <th>Last updated</th>
159
+ <th>Status</th>
160
+ <th></th>
161
+ </tr>
162
+ </thead>
163
+ <tbody>
164
+ {providers.map((p) => {
165
+ const result = results[p.name]
166
+ const isRefreshing = p.refreshing || refreshingAll
167
+ return (
168
+ <tr key={p.name}>
169
+ <td className="mgmt-provider">{p.name}</td>
170
+ <td className="mgmt-count">{p.modelCount}</td>
171
+ <td className="mgmt-age">{formatAge(p.lastUpdated)}</td>
172
+ <td className="mgmt-status">
173
+ {result ? (
174
+ result.success ? (
175
+ <span className="badge-ok">✓ updated</span>
176
+ ) : (
177
+ <span className="badge-err" title={result.error}>✗ failed</span>
178
+ )
179
+ ) : (
180
+ <span className={`badge-script ${p.hasScript ? 'has-script' : 'manual'}`}>
181
+ {p.hasScript ? 'auto' : 'manual'}
182
+ </span>
183
+ )}
184
+ </td>
185
+ <td>
186
+ {p.hasScript && (
187
+ <button
188
+ className="btn-refresh"
189
+ onClick={() => refreshProvider(p.key, p.name)}
190
+ disabled={isRefreshing}
191
+ >
192
+ {isRefreshing ? '⟳' : '↻'}
193
+ </button>
194
+ )}
195
+ </td>
196
+ </tr>
197
+ )
198
+ })}
199
+ </tbody>
200
+ </table>
201
+
202
+ {benchmarks && (
203
+ <>
204
+ <h3 className="mgmt-section-heading">Benchmark Data</h3>
205
+ <table className="management-table">
206
+ <thead>
207
+ <tr>
208
+ <th>Dataset</th>
209
+ <th>Entries</th>
210
+ <th>Last updated</th>
211
+ <th>Status</th>
212
+ <th></th>
213
+ </tr>
214
+ </thead>
215
+ <tbody>
216
+ <tr>
217
+ <td className="mgmt-provider">Benchmarks</td>
218
+ <td className="mgmt-count">{benchmarks.entryCount.toLocaleString()}</td>
219
+ <td className="mgmt-age">{formatAge(benchmarks.lastUpdated)}</td>
220
+ <td className="mgmt-status">
221
+ {bmResult ? (
222
+ bmResult.success ? (
223
+ <span className="badge-ok">✓ updated</span>
224
+ ) : (
225
+ <span className="badge-err" title={bmResult.error}>✗ failed</span>
226
+ )
227
+ ) : (
228
+ <span className="badge-script has-script">auto</span>
229
+ )}
230
+ </td>
231
+ <td>
232
+ <button
233
+ className="btn-refresh"
234
+ onClick={refreshBenchmarks}
235
+ disabled={benchmarks.refreshing}
236
+ >
237
+ {benchmarks.refreshing ? '⟳' : '↻'}
238
+ </button>
239
+ </td>
240
+ </tr>
241
+ </tbody>
242
+ </table>
243
+ </>
244
+ )}
245
+ </>
246
+ )}
247
+
248
+ {error && !loading && <div className="panel-error">{error}</div>}
249
+ </div>
250
+ </div>
251
+ )
252
+ }
src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App'
4
+ import './App.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
tsconfig.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
6
+ "allowJs": false,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": false,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "module": "ESNext",
13
+ "moduleResolution": "Node",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "noEmit": true,
17
+ "jsx": "react-jsx"
18
+ },
19
+ "include": ["src"],
20
+ "references": [{ "path": "./tsconfig.node.json" }]
21
+ }
tsconfig.node.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "module": "ESNext",
5
+ "moduleResolution": "Node",
6
+ "allowSyntheticDefaultImports": true
7
+ },
8
+ "include": ["vite.config.ts"]
9
+ }
vercel.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "buildCommand": "npm run build",
3
+ "outputDirectory": "dist",
4
+ "installCommand": "npm install",
5
+ "framework": "vite",
6
+ "rewrites": [
7
+ { "source": "/(.*)", "destination": "/index.html" }
8
+ ]
9
+ }
vite.config.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ proxy: {
8
+ '/api': {
9
+ target: 'http://localhost:3001',
10
+ changeOrigin: true,
11
+ },
12
+ },
13
+ },
14
+ })