Spaces:
Running
Running
CrispStrobe commited on
Commit ·
ffba252
0
Parent(s):
init
Browse files- .gitignore +24 -0
- data/benchmarks.json +0 -0
- data/providers.json +0 -0
- index.html +12 -0
- package-lock.json +0 -0
- package.json +44 -0
- scripts/fetch-benchmarks.js +556 -0
- scripts/fetch-providers.js +186 -0
- scripts/providers/groq.js +155 -0
- scripts/providers/infomaniak.js +141 -0
- scripts/providers/ionos.js +164 -0
- scripts/providers/langdock.js +144 -0
- scripts/providers/mistral.js +176 -0
- scripts/providers/nebius.js +219 -0
- scripts/providers/openrouter.js +121 -0
- scripts/providers/requesty.js +99 -0
- scripts/providers/scaleway.js +152 -0
- scripts/update_openrouter.py +76 -0
- server.js +208 -0
- src/App.css +364 -0
- src/App.tsx +511 -0
- src/components/ManagementPanel.tsx +252 -0
- src/main.tsx +10 -0
- tsconfig.json +21 -0
- tsconfig.node.json +9 -0
- vercel.json +9 -0
- vite.config.ts +14 -0
.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 |
+
})
|