openvibers/openvibe.compare Commits

Move Report's AI comparison pages to Compare (#23)

OP OpenVibers committed 7 h ago · fac992d738 · 16 files, +1495 −38

* Move Report's AI comparison pages to Compare

* Say when Bench cannot answer the AI feed

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex d91aeb2..8ca88c8 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -16,9 +16,14 @@ API**. Server-rendered pages; `openvibe-shared` for the shell.
- `GET /` — the comparison index and search.
- `GET /comparisons/:slug` — one comparison; weight overrides via `?w.<criterion>=`.
--- `GET /things/:id` — one thing across every comparison that contains it; `/models/:org/:model`
-- 301s here.
++- `GET /things/:id` — one thing across every comparison that contains it; an AI model's thing page
++ and `/models/:org/:model` 301 to `/ai/models/:model`.
- `GET /compute` — compute prices from Exchange: filters, sorts and a two/three-offer side-by-side.
++- `GET /ai`, `/ai/leaderboard`, `/ai/providers`, `/ai/models/:model`, `/ai/compare[/:a-vs-:b]`,
++ `/ai/method`, `/ai/feed.xml`, `/ai/:ref` (a Bench task kind or benchmark) and `/ai/arena` — Report's
++ AI comparison pages, with Bench's boards through `openvibe-sdk/bench`, the AI catalogue and Exchange
++ market joined, and Report's measured first token, tokens per second and success beside the prices.
++ `/comparisons/ai-models` and an AI model's `/things/:id` 301 to the AI pages.
- `GET /api/v1/comparisons[/:slug]` and `GET /api/v1/compute` — the same data as JSON, CORS-allowed
for the origins in [server/origins.json](server/origins.json).
- `robots.txt`, `sitemap.xml`, `llms.txt`, Dataset JSON-LD, and `Accept: text/markdown` negotiation —
@@@ -29,8 +34,8 @@ schema with no API in v1).
## Who it reads
--Seven feeds in [server/data.js](server/data.js) `ENDPOINTS`, and the compute book in
--[server/compute.js](server/compute.js) `SOURCE`:
++The feeds in [server/data.js](server/data.js) `ENDPOINTS` and [server/ai-data.js](server/ai-data.js),
++and the compute book in [server/compute.js](server/compute.js) `SOURCE`:
| Feed | What |
|---|---|
@@@ -41,6 +46,8 @@ Seven feeds in [server/data.js](server/data.js) `ENDPOINTS`, and the compute boo
| `billing.openvibe.network/api/v1/plans` | published plans (OpenVibe.Billing) |
| `openvibe.report/api/v1/ai/performance?group=model&days=30` | measured AI time to first token, tokens per second and success rate, public model rows with a 20-call floor (OpenVibe.Report) |
| `openvibe.exchange/api/compute/prices` | reviewed compute tariffs per provider and region (OpenVibe.Exchange) |
++| `bench.openvibe.services/api/v1/{kinds,ratings,benchmarks,rankings,runs,overview}` | AI boards, benchmarks and new runs through `openvibe-sdk/bench` (OpenVibe.Bench) |
++| `api.openvibe.exchange/api/v1/exchange/{catalog,market}` | AI catalogue and dated provider prices (OpenVibe.AI / Exchange) |
It does not call AI, Live, Network or any authenticated API. Nothing here writes anywhere.
@@@ -51,6 +58,9 @@ It does not call AI, Live, Network or any authenticated API. Nothing here writes
health/ready, `/release.json`, `/api/v1`, pages, 404 and 503.
- **Data:** [server/data.js](server/data.js) — fetches the seven feeds (8 s timeout; 3 s for Report), builds the three
comparisons, coalesces concurrent reads and caches for ten minutes; a failed feed becomes `null`.
++- **AI data:** [server/ai-data.js](server/ai-data.js) joins the catalogue/market and Bench's boards with
++ Report's measured rows; [server/ai-views.js](server/ai-views.js) renders the Report pages and the Bench boards.
++ A failed source leaves a worded status and a dash, never a zero; pages cache for two minutes.
- **Compute:** [server/compute.js](server/compute.js) normalizes Exchange's tariffs and derives the
month (730 h) and GPU GB-hour prices; [server/compute-views.js](server/compute-views.js) renders the
page, Markdown and Dataset JSON-LD. A failed feed is not cached.
ddiff --git a/README.md b/README.md
iindex 3d3ce5b..7953c61 100644
---- a/README.md
++++ b/README.md
@@@ -5,10 +5,11 @@ Compare v1 serves public comparisons at https://openvibe.compare. Run `npm ci`,
Data: Exchange price-book market and its reviewed compute tariffs, Report's public AI performance rows (30 days, model group), Bench public arena/verified runs, Codes harness catalog, Billing public plans. Reads are coalesced and cached for ten minutes. Price provenance retains the provider's checked date; a board without an update timestamp labels its date as the observation date. Failed feeds produce explicit unavailable states. The AI models comparison shows Report's measured time to first token p50, tokens per second p50 and success rate beside each provider's price; a model under 20 calls or with Report down is a dash, never zero. There is no private data or paid execution. Compute prices are read from OpenVibe.Exchange's `GET /api/compute/prices`; Compare adds only derived arithmetic (per month at 24/7, per GPU GB-hour), never a copied tariff.
--Pages: `/`, `/comparisons/:slug`, `/things/:id` (URI-encoded identity), and `/compute` (filters by kind, GPU model, region and interruptible; sorts by price per hour, per GPU GB-hour and per month at 24/7; a two- or three-offer side-by-side). JSON at `/api/v1/comparisons[/slug]` and `/api/v1/compute`. Pages negotiate `Accept: text/markdown`. Dataset JSON-LD, sitemap and llms.txt are generated from the same data. Desktop tables and phone cards share ranking logic; compute cards run to 899 px and its table starts at 900 px. Weight URL parameters are `w.<criterion>`; all-zero weights leave things unranked. Builder and version-history fields are reserved, with no write API in v1.
++Pages: `/`, `/comparisons/:slug`, `/things/:id` (URI-encoded identity), `/compute` (filters by kind, GPU model, region and interruptible; sorts by price per hour, per GPU GB-hour and per month at 24/7; a two- or three-offer side-by-side), and the AI pages `/ai` (hub), `/ai/leaderboard`, `/ai/providers`, `/ai/models/:model`, `/ai/compare` and `/ai/compare/:a-vs-:b` (canonical sorted pair), `/ai/method`, `/ai/feed.xml`, `/ai/:ref` (a Bench task kind or benchmark) and `/ai/arena`. The old `/comparisons/ai-models` and an AI model's `/things/:id` 301 to the AI pages. AI ratings and prices come from OpenVibe.AI's catalogue and Exchange market joined to OpenVibe.Bench's boards through `openvibe-sdk/bench`; Report's measured rows sit beside the prices. JSON at `/api/v1/comparisons[/slug]` and `/api/v1/compute`. Pages negotiate `Accept: text/markdown`. Dataset JSON-LD, sitemap and llms.txt are generated from the same data. Desktop tables and phone cards share ranking logic; compute cards run to 899 px and its table starts at 900 px. Weight URL parameters are `w.<criterion>`; all-zero weights leave things unranked. Builder and version-history fields are reserved, with no write API in v1.
Launch: Host register-compare.py registers port 5280 with a lifecycle override until the separate Contracts manifest is released. Use merge-deploy.sh, then ov-vhost-install for deploy/nginx/openvibe.compare.conf. No database or service credentials are required.
<!-- versions:start -->
- openvibe-shared: v4.232.0
++- openvibe-sdk: v0.161.0
<!-- versions:end -->
ddiff --git a/package-lock.json b/package-lock.json
iindex 9b6f302..ac1a016 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -10,6 +10,7 @@
"license": "AGPL-3.0-only",
"dependencies": {
"express": "^4.21.2",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.232.0"
},
"engines": {
@@@ -1027,6 +1028,34 @@
"url": "https://opencollective.com/express"
}
},
++ "node_modules/openvibe-sdk": {
++ "version": "0.161.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
++ "integrity": "sha512-qReF7K9ct7LCLDJKuguwUQ30nc0kmrp+jlRZcFm4adpEvKL8r5G6i6Osbs0ROAMLmXKwnbI4GiZAgAyQ7oN7Ig==",
++ "license": "MIT",
++ "bin": {
++ "openvibe-download": "bin/openvibe-download.js"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
++ }
++ },
"node_modules/openvibe-shared": {
"version": "4.232.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.232.0",
ddiff --git a/package.json b/package.json
iindex 3f21aa4..590fc2f 100644
---- a/package.json
++++ b/package.json
@@@ -14,6 +14,7 @@
"license": "AGPL-3.0-only",
"dependencies": {
"express": "^4.21.2",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.232.0"
}
}
ddiff --git a/public/ai-comparison.css b/public/ai-comparison.css
nnew file mode 100644
iindex 0000000..99de3e5
---- /dev/null
++++ b/public/ai-comparison.css
@@@ -0,0 +1,131 @@
++/* Report's AI comparison pages. Report's own tokens only (--bg-card, --border, --text-*, --accent, --room-*); the kit
++ draws tables (stacked into cards under 600 px), stats, sections, folds and the search pill. */
++.ai-page{display:grid;gap:clamp(20px,3vw,32px);padding-block:clamp(12px,2vw,24px) 48px}
++.ai-page>*{min-width:0}
++
++/* Headers */
++.ai-head{display:grid;gap:14px;padding:clamp(18px,3.4vw,36px) 0 4px;position:relative}
++.ai-head::before{content:"";position:absolute;inset:-24px -16px auto;height:220px;z-index:-1;pointer-events:none;
++ background:radial-gradient(60% 100% at 18% 0,color-mix(in srgb,var(--room-accent,var(--accent)) 16%,transparent),transparent 70%)}
++.ai-head h1{margin:0;font-size:clamp(1.85rem,4.6vw,3.1rem);line-height:1.06;letter-spacing:-.02em;max-width:20ch;text-wrap:balance}
++.ai-head--page h1{font-size:clamp(1.6rem,3.6vw,2.4rem)}
++.ai-kicker{margin:0;font-size:.78rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--room-accent,var(--accent))}
++.ai-kicker a{color:inherit;text-decoration:none}
++.ai-kicker a:hover{text-decoration:underline}
++.ai-lede{margin:0;max-width:62ch;color:var(--text-secondary);font-size:clamp(.98rem,1.6vw,1.08rem);line-height:1.5}
++.ai-lede code{font-size:.85em;color:var(--text-muted);overflow-wrap:anywhere}
++.ai-head .ovk-find{max-width:520px}
++.ai-fine,.ai-data{margin:10px 0 0;color:var(--text-muted);font-size:.86rem}
++.ai-data a{color:var(--text-secondary)}
++.ai-quiet{color:var(--text-muted);font-size:.9em}
++.ai-bad{color:var(--danger)}
++
++/* A model: mark, name, maker and tags */
++.ai-model{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:10px;color:inherit;text-decoration:none;min-width:0}
++.ai-model-text{display:grid;gap:2px;min-width:0}
++.ai-model-text b{font-weight:650;overflow-wrap:anywhere}
++.ai-model:hover b{color:var(--accent);text-decoration:underline;text-underline-offset:3px}
++.ai-model-text small{display:flex;flex-wrap:wrap;align-items:center;gap:6px;color:var(--text-muted);font-size:.8rem}
++.ai-model-text .ovk-badge{font-size:.7rem}
++
++/* Numbers in cells: the value, then what it rests on */
++.ai-board td b,.ai-board td .ai-rating b,.ai-price b{font-variant-numeric:tabular-nums;font-weight:650}
++.ai-board td small,.ai-rating small,.ai-price small{display:block;margin-top:2px;color:var(--text-muted);font-size:.78rem;font-weight:400}
++.ai-rank{display:inline-grid;place-items:center;min-width:2.1em;height:2.1em;padding:0 .4em;border-radius:999px;font-weight:700;font-variant-numeric:tabular-nums;
++ background:var(--bg-secondary,color-mix(in srgb,var(--text-primary) 6%,transparent));color:var(--text-secondary);font-size:.85rem}
++.ai-rank.is-top{background:color-mix(in srgb,var(--room-accent,var(--accent)) 22%,transparent);color:var(--text-primary)}
++.ai-board th.is-primary,.ai-board td:nth-child(2){min-width:13rem}
++
++/* Inside a kit section, the page's own blocks sit on its padding */
++.ovk-section>.ai-fine,.ovk-section>.ai-data,.ovk-section>.ai-note,.ovk-section>.ai-picks,.ovk-section>.ai-chips,.ovk-section>.ai-answers,.ovk-section>.ai-prompt,.ovk-section>ul{margin:0 14px 14px}
++.ovk-section>.ovk-table-wrap+.ai-fine,.ovk-section>.ovk-table-wrap+.ai-data{margin-top:12px}
++.ai-note{margin:0 0 12px;padding:12px 14px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--border));border-radius:12px;background:color-mix(in srgb,var(--accent) 8%,transparent);color:var(--text-secondary);font-size:.92rem}
++.ovk-section>.ai-note{margin:0 14px 12px}
++.ai-page .ovk-table td small{display:block;margin-top:2px;color:var(--text-muted);font-size:.78rem;font-weight:400}
++.ai-page .ovk-table td b{font-variant-numeric:tabular-nums}
++
++/* Top picks */
++.ai-picks{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,230px),1fr));gap:12px}
++.ai-pick{height:100%;box-sizing:border-box;display:grid;align-content:start;gap:8px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--bg-card);color:inherit;text-decoration:none;
++ transition:border-color .15s,transform .15s}
++.ai-pick:hover{border-color:color-mix(in srgb,var(--room-accent,var(--accent)) 60%,var(--border));transform:translateY(-1px)}
++.ai-pick-k{font-size:.74rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--room-accent,var(--accent))}
++.ai-pick-m{display:flex;align-items:center;gap:10px;min-width:0}
++.ai-pick-m b{font-size:1.08rem;overflow-wrap:anywhere}
++.ai-pick-n{font-size:1.25rem;font-weight:700;font-variant-numeric:tabular-nums}
++.ai-pick small{color:var(--text-muted);font-size:.82rem;line-height:1.4}
++
++/* Chip links: tasks, comparisons */
++.ai-chips{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}
++.ai-chips a{display:inline-flex;align-items:center;min-height:40px;padding:0 14px;border:1px solid var(--border);border-radius:999px;background:var(--bg-card);color:var(--text-primary);text-decoration:none;font-size:.9rem}
++.ai-chips a:hover{border-color:var(--accent);color:var(--accent)}
++
++/* Providers */
++.ai-prov{display:inline-flex;align-items:center;gap:8px}
++
++/* Filters */
++.ai-filters{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,150px),1fr));gap:10px 12px;align-items:end;padding:14px;border:1px solid var(--border);border-radius:16px;background:var(--bg-card)}
++.ai-filter{display:grid;gap:5px;min-width:0;font-size:.8rem;color:var(--text-secondary)}
++.ai-filter--q{grid-column:1/-1}
++.ai-filter input,.ai-filter select{box-sizing:border-box;width:100%;min-width:0;min-height:42px;padding:0 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-primary,transparent);color:var(--text-primary);font:inherit;font-size:.95rem}
++.ai-filter input:focus,.ai-filter select:focus{outline:2px solid var(--accent);outline-offset:1px}
++.ai-filters .ovk-btn{min-height:42px}
++
++/* A model page */
++.ai-model-head{display:flex;align-items:center;gap:16px;min-width:0}
++.ai-model-head>div{display:grid;gap:6px;min-width:0}
++.ai-model-head h1{overflow-wrap:anywhere}
++.ai-head--model .ai-lede{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
++.ai-try{position:sticky;bottom:calc(12px + env(safe-area-inset-bottom));z-index:3;display:flex;flex-wrap:wrap;gap:10px;justify-content:center;padding:10px;border:1px solid var(--border);border-radius:999px;
++ background:color-mix(in srgb,var(--bg-card) 92%,transparent);backdrop-filter:blur(10px);width:max-content;max-width:100%;margin-inline:auto;box-shadow:0 10px 30px rgba(0,0,0,.25)}
++
++/* Compare */
++.ai-vs{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:12px}
++.ai-vs-card{display:grid;gap:14px;padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--bg-card);min-width:0}
++.ai-vs-card dl{margin:0;display:grid;gap:10px}
++.ai-vs-card dl div{display:grid;grid-template-columns:7.5rem minmax(0,1fr);gap:10px;align-items:baseline}
++.ai-vs-card dt{color:var(--text-muted);font-size:.82rem}
++.ai-vs-card dd{margin:0}
++.ai-vs-form .ovk-btn{align-self:end}
++
++/* One prompt, many answers */
++.ai-prompt{margin:0 0 12px;font-size:1.05rem;font-weight:600}
++.ai-answers{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:12px}
++.ai-answers article{display:grid;gap:10px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--bg-card);min-width:0}
++.ai-answers p{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--text-secondary)}
++
++@media (max-width:600px){
++ .ai-board th.is-primary,.ai-board td:nth-child(2){min-width:0}
++ .ai-filters{grid-template-columns:1fr 1fr}
++ .ai-model-head{gap:12px}
++}
++
++/* Every section reads as a section: the kit's small label for an action-less section becomes the same heading the
++ sections with actions have, so the page's hierarchy is one level, not two. */
++.ai-page .ovk-section>h2{padding:14px 14px 10px;color:var(--text-primary);font-size:1.02rem;font-weight:700;letter-spacing:0;text-transform:none}
++.ai-cell{display:block;min-width:0}
++@media (max-width:600px){.ai-page .ovk-table--stack td .ai-cell{text-align:right}}
++/* Numbers in a header wrap into a grid instead of scrolling off a phone. */
++.ai-head .ovk-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,140px),1fr));overflow:visible}
++@media (max-width:700px){.ai-try{position:static;width:auto;border-radius:16px;box-shadow:none}}
++.ai-warn{color:var(--warning,#f59e0b)}
++
++/* Compare additions: the same page inside Compare's shell, the measured columns and the Bench board pages. */
++.ai-page{width:100%}
++.ai-page .ai-fine{color:var(--text-muted);font-size:.86rem}
++.ai-page .ai-flag{font-size:.8rem;color:var(--warning,#f59e0b)}
++.ai-page .ai-method{display:grid;gap:10px;max-width:75ch}
++.ai-page .ai-method h2{margin:16px 0 0;font-size:1.12rem}
++.ai-page .ai-method p{margin:0;color:var(--text-secondary);line-height:1.6}
++.ai-page .ovm-unknown{color:var(--text-muted)}
++.ai-page .ovk-table td .ai-cell{display:block;min-width:0}
++@media (max-width:600px){
++ .ai-page .ovk-table--stack td .ai-cell{text-align:right}
++ .ai-page .ovk-table--stack td .ai-cell small{text-align:right}
++ .ai-page .ovk-table--stack td .ai-model{text-align:left}
++ .ai-page .ovk-table--stack td .ai-model-text small{justify-content:flex-start}
++ .ai-page .ai-picks{grid-template-columns:1fr}
++ .ai-page .ai-vs-card dl div{grid-template-columns:minmax(0,1fr);gap:2px}
++ .ai-page .ai-head h1{overflow-wrap:anywhere}
++ .ai-page .ov-parts-bar label{min-height:44px}
++}
ddiff --git a/server/ai-bench.js b/server/ai-bench.js
nnew file mode 100644
iindex 0000000..79c74f5
---- /dev/null
++++ b/server/ai-bench.js
@@@ -0,0 +1,148 @@
++'use strict';
++
++/**
++ * OpenVibe.Bench's public reads, as Report reads them and through openvibe-sdk/bench:
++ * GET /api/v1/kinds every benchmark kind
++ * GET /api/v1/boards/:kind?type=elo|pareto|cost
++ * GET /api/v1/models/:model(/summary) one model across every kind
++ * GET /api/v1/benchmarks[/:id] public benchmarks and their definitions
++ * GET /api/v1/rankings?benchmark=&metric= verified per-model medians
++ * GET /api/v1/runs?benchmark=&model=&kind= public runs newest first
++ * GET /api/v1/overview public counts and the newest runs
++ *
++ * Reads are public and held for sixty seconds; a read that answered once keeps its last good copy while
++ * Bench is down, marked stale with the moment it was read. A read that never answered is null.
++ */
++const { trim, requestJson, TIMEOUT_MS } = require('./ai-source');
++const { createClient } = require('openvibe-sdk/core');
++const { createBenchClient: createSdkBench } = require('openvibe-sdk/bench');
++
++const TTL_MS = 60_000;
++const KIND_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/;
++const BENCHMARK_RE = /^bmk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:+\/~\-]{0,119}$/;
++const BOARD_TYPES = Object.freeze(['elo', 'pareto', 'cost']);
++const METRICS = Object.freeze(['score', 'value', 'speed', 'consistency', 'horizon']);
++
++const isKindId = (value) => typeof value === 'string' && KIND_RE.test(value);
++const isBenchmarkId = (value) => typeof value === 'string' && BENCHMARK_RE.test(value);
++const isModelName = (value) => typeof value === 'string' && MODEL_RE.test(value);
++
++function normaliseMetric(value) {
++ const raw = Array.isArray(value) ? value[0] : value;
++ return METRICS.includes(raw) ? raw : 'score';
++}
++
++function normaliseLimit(value) {
++ const n = parseInt(Array.isArray(value) ? value[0] : value, 10);
++ return Number.isFinite(n) ? Math.min(100, Math.max(1, n)) : 20;
++}
++
++function normaliseTab(value) {
++ const raw = Array.isArray(value) ? value[0] : value;
++ return BOARD_TYPES.includes(raw) ? raw : 'elo';
++}
++
++function createBenchClient({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs = TIMEOUT_MS } = {}) {
++ const baseUrl = trim(config.benchUrl || 'https://bench.openvibe.services');
++ const clock = typeof now === 'function' ? now : () => Date.now();
++ const shared = createSdkBench(createClient({ fetch: fetchImpl, retries: 0 }), { baseUrl });
++
++ const boards = new Map();
++ const models = new Map();
++ const catalog = new Map();
++ const results = new Map();
++
++ function keep(store, key, data, cap) {
++ store.set(key, data);
++ while (store.size > cap) store.delete(store.keys().next().value);
++ }
++
++ async function read(store, key, path, label, cap, loader = null) {
++ const atMs = clock();
++ const hit = store.get(key);
++ if (hit && hit.atMs + TTL_MS > atMs) return { data: hit.data, at: hit.at, stale: false };
++ if (hit && hit.retryAtMs > atMs) return { data: hit.data, at: hit.at, stale: true };
++ let data = null;
++ if (loader) {
++ try { data = await loader(); }
++ catch (err) { log.warn(`[OpenVibe.Compare] ${label} unavailable: ${String(err && err.message || err).slice(0, 160)}`); }
++ } else data = await requestJson(`${baseUrl}${path}`, { fetchImpl, log, timeoutMs, label });
++ if (data === null) {
++ if (hit) hit.retryAtMs = atMs + TTL_MS;
++ return hit ? { data: hit.data, at: hit.at, stale: true } : null;
++ }
++ const entry = { data, atMs, at: new Date(atMs).toISOString() };
++ keep(store, key, entry, cap);
++ return { data, at: entry.at, stale: false };
++ }
++
++ function kinds() {
++ return read(boards, 'kinds', '/api/v1/kinds', 'bench kinds', 500);
++ }
++
++ async function board(kindId, type = 'elo') {
++ if (!isKindId(kindId) || !BOARD_TYPES.includes(type)) return null;
++ return read(
++ boards,
++ `board:${kindId}:${type}`,
++ `/api/v1/boards/${encodeURIComponent(kindId)}?type=${type}`,
++ `bench board (${kindId}, ${type})`,
++ 500,
++ type === 'elo' ? async () => {
++ const data = await shared.ratings.get(kindId, { signal: AbortSignal.timeout(timeoutMs) });
++ return { ...data, board: { ...data.board, rows: (data.board && data.board.rows || []).filter((row) => row.games >= 20) } };
++ } : null,
++ );
++ }
++
++ async function modelSummary(model) {
++ if (!isModelName(model)) return null;
++ return read(models, `model:${model}`, `/api/v1/models/${encodeURIComponent(model)}`, `bench model (${model})`, 200);
++ }
++
++ function benchmarks() {
++ return read(catalog, 'benchmarks', '/api/v1/benchmarks?limit=200', 'bench benchmarks', 100);
++ }
++
++ async function benchmark(id) {
++ if (!isBenchmarkId(id)) return null;
++ return read(catalog, `benchmark:${id}`, `/api/v1/benchmarks/${encodeURIComponent(id)}`, `bench benchmark (${id})`, 500);
++ }
++
++ function overview() {
++ return read(catalog, 'overview', '/api/v1/overview', 'bench overview', 20);
++ }
++
++ async function rankings(benchmarkId, metric = 'score') {
++ if (!isBenchmarkId(benchmarkId) || !METRICS.includes(metric)) return null;
++ return read(results, `rankings:${benchmarkId}:${metric}`, `/api/v1/rankings?benchmark=${encodeURIComponent(benchmarkId)}&metric=${metric}`, `bench rankings (${benchmarkId}, ${metric})`, 500);
++ }
++
++ async function runs({ benchmark = null, model = null, kind = null, limit = 20 } = {}) {
++ if (benchmark != null && !isBenchmarkId(benchmark)) return null;
++ if (model != null && !isModelName(model)) return null;
++ if (kind != null && !isKindId(kind)) return null;
++ const params = new URLSearchParams();
++ if (benchmark) params.set('benchmark', benchmark);
++ if (model) params.set('model', model);
++ if (kind) params.set('kind', kind);
++ params.set('limit', String(normaliseLimit(limit)));
++ const query = params.toString();
++ return read(results, `runs:${query}`, `/api/v1/runs?${query}`, `bench runs (${query})`, 500);
++ }
++
++ async function modelDetail(model) {
++ if (!isModelName(model)) return null;
++ return read(models, `detail:${model}`, `/api/v1/models/${encodeURIComponent(model)}/summary`, `bench model detail (${model})`, 200);
++ }
++
++ return {
++ kinds, board, modelSummary, benchmarks, benchmark, overview, rankings, runs, modelDetail,
++ baseUrl, ttlMs: TTL_MS, isKindId, isBenchmarkId, isModelName, normaliseTab, normaliseMetric, normaliseLimit,
++ };
++}
++
++module.exports = {
++ createBenchClient, TTL_MS, BOARD_TYPES, METRICS, isKindId, isBenchmarkId, isModelName, normaliseTab, normaliseMetric, normaliseLimit,
++};
ddiff --git a/server/ai-catalogue.js b/server/ai-catalogue.js
nnew file mode 100644
iindex 0000000..22c8e48
---- /dev/null
++++ b/server/ai-catalogue.js
@@@ -0,0 +1,59 @@
++'use strict';
++
++// OpenVibe.AI's public catalogue and Exchange market, read the way Report reads them: the catalogue names a
++// model and its providers; the market adds the own-page prices and the measured traffic. Both are held two
++// minutes; a market read that answers once keeps its last good copy (marked stale) while Exchange is down.
++const { requestJson, trim, TIMEOUT_MS } = require('./ai-source');
++
++const TTL_MS = 120_000;
++
++function createAiCatalogue({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs = TIMEOUT_MS } = {}) {
++ const baseUrl = trim(config.aiUrl || 'https://api.openvibe.exchange');
++ let hit = null;
++ let pending = null;
++
++ async function catalog() {
++ if (hit && hit.expires > now()) return hit.value;
++ if (!pending) pending = (async () => {
++ const data = await requestJson(`${baseUrl}/api/v1/exchange/catalog`, { fetchImpl, log, label: 'ai exchange catalog', timeoutMs });
++ if (data && Array.isArray(data.models)) hit = { value: data, expires: now() + TTL_MS };
++ pending = null;
++ return data && Array.isArray(data.models) ? data : null;
++ })();
++ return pending;
++ }
++
++ let marketHit = null;
++ let marketPending = null;
++
++ async function market() {
++ if (marketHit && marketHit.expires > now()) return marketHit.value;
++ if (!marketPending) marketPending = (async () => {
++ const offers = [];
++ const seen = new Set();
++ let cursor = null;
++ let first = null;
++ do {
++ const suffix = cursor == null ? '' : `&cursor=${encodeURIComponent(cursor)}`;
++ const data = await requestJson(`${baseUrl}/api/v1/exchange/market?limit=500${suffix}`, { fetchImpl, log, label: 'ai exchange market', timeoutMs });
++ if (!data || !Array.isArray(data.offers) || !('next_cursor' in data) || (data.next_cursor != null && typeof data.next_cursor !== 'string') || (first && first.generated_at !== data.generated_at) || seen.has(data.next_cursor) || seen.size > 1000) {
++ const value = marketHit ? { ...marketHit.value, stale: true } : null;
++ marketHit = { value, expires: now() + TTL_MS };
++ return value;
++ }
++ first ||= data;
++ offers.push(...data.offers);
++ cursor = data.next_cursor;
++ if (cursor != null) seen.add(cursor);
++ } while (cursor != null);
++ const value = { ...first, offers, next_cursor: null, stale: false };
++ marketHit = { value, expires: now() + TTL_MS };
++ return value;
++ })().finally(() => { marketPending = null; });
++ return marketPending;
++ }
++
++ return { catalog, market, baseUrl, ttlMs: TTL_MS };
++}
++
++module.exports = { createAiCatalogue, TTL_MS };
ddiff --git a/server/ai-data.js b/server/ai-data.js
nnew file mode 100644
iindex 0000000..d8ff25e
---- /dev/null
++++ b/server/ai-data.js
@@@ -0,0 +1,223 @@
++'use strict';
++
++// The AI pages' data: OpenVibe.Bench's boards (through openvibe-sdk/bench), the AI catalogue and Exchange
++// market, and Report's measured model rows. Missing evidence is not zero: a source that does not answer
++// leaves its part with a worded status and a dash, never a made-up value.
++const { createAiCatalogue } = require('./ai-catalogue');
++const { comparisonCatalogue } = require('./ai-market');
++const { findCatalogModel, makerOf } = require('./ai-join');
++const { createBenchClient, isKindId, isModelName, normaliseTab } = require('./ai-bench');
++const { requestJson } = require('./ai-source');
++const { measuredRows, measuredOf } = require('./data');
++
++const TTL_MS = 120_000;
++const PERF_URL = 'https://openvibe.report/api/v1/ai/performance?group=model&days=30';
++
++const number = (v) => v == null || !Number.isFinite(Number(v)) ? null : Number(v);
++const iso = (v) => v && Number.isFinite(Date.parse(v)) ? new Date(v).toISOString() : null;
++const measuredAt = (perf) => iso(perf && perf.range && perf.range.to ? `${perf.range.to}T00:00:00.000Z` : null);
++
++function measuredRow(feed, row, at) {
++ if (!row) return null;
++ const ttft = number(row.ttft_ms && row.ttft_ms.p50);
++ const tps = number(row.tps && row.tps.p50);
++ const success = number(row.success_rate);
++ if (ttft == null && tps == null && success == null) return null;
++ return { ttft, tps, success, calls: number(row.calls), as_of: at, source_url: PERF_URL };
++}
++
++function buildComparison({ kindsRead = null, boards = [], catalogue = null, perf = null, now = new Date().toISOString() } = {}) {
++ const kinds = kindsRead && kindsRead.data && Array.isArray(kindsRead.data.kinds) ? kindsRead.data.kinds : [];
++ const perfRows = measuredRows(perf);
++ const perfAt = measuredAt(perf) || iso(now);
++ const catModels = (catalogue && catalogue.models || []).map((model) => ({ ...model, offers: (model.offers || []).map((offer) => ({ ...offer, source: offer.source || { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: offer.requests_7d != null ? offer.requests_7d : null } })) }));
++ const findCat = (id) => findCatalogModel(catModels, id);
++ const measuredFor = (id, offers = []) => {
++ const row = measuredOf(perfRows, id, { provider_model: offers.map((o) => o.provider_model).find(Boolean) });
++ if (row) return measuredRow(perf, row, perfAt);
++ for (const offer of offers) {
++ const match = measuredOf(perfRows, id, offer);
++ if (match) return measuredRow(perf, match, perfAt);
++ }
++ return null;
++ };
++ const rows = [];
++ for (let i = 0; i < boards.length; i += 1) {
++ const board = boards[i];
++ if (!kinds[i] || !board) continue;
++ const list = board.data && board.data.board && Array.isArray(board.data.board.rows) ? board.data.board.rows : [];
++ for (const entry of list) {
++ const id = entry.subject || entry.model || entry.name;
++ if (!id) continue;
++ const cat = findCat(id);
++ const offers = cat && Array.isArray(cat.offers) ? cat.offers : [];
++ rows.push({ id, routed: cat ? cat.routed !== false : false, kind: kinds[i].id, model_kind: cat && cat.kind || 'chat', maker: makerOf(cat, id), weights: cat && (cat.weights || (cat.open_weights == null ? null : cat.open_weights ? 'open' : 'closed')), status: cat && cat.status || 'current', uncensored: Boolean(cat && cat.uncensored), community: Boolean(cat && cat.community), rating: { value: entry.elo == null || Number(entry.games) === 0 ? null : Number(entry.elo), interval: Number(entry.games) === 0 ? [null, null] : [entry.ci_lower == null ? null : entry.ci_lower, entry.ci_upper == null ? null : entry.ci_upper], source: 'OpenVibe.Bench', date: board.at || null, sample_size: entry.games == null ? null : entry.games }, benchmark: { value: entry.score == null ? null : Number(entry.score), reason: entry.score == null ? 'No benchmark score.' : undefined }, provider_offers: offers, measured: measuredFor(id, offers), source: { name: 'OpenVibe.Bench', date: board.at || null, sample_size: entry.games == null ? null : entry.games } });
++ }
++ }
++ const grouped = new Map();
++ for (const row of rows) {
++ const existing = grouped.get(row.id);
++ if (existing) existing.task_ratings.push({ kind: row.kind, rating: row.rating, benchmark: row.benchmark });
++ else grouped.set(row.id, { ...row, task_ratings: [{ kind: row.kind, rating: row.rating, benchmark: row.benchmark }] });
++ }
++ rows.length = 0;
++ rows.push(...grouped.values());
++ // Every model the Exchange sells is listed with its prices: a model without Bench games yet shows as
++ // not rated, never as missing.
++ const ratedCats = new Set(rows.map((row) => findCat(row.id)).filter(Boolean));
++ for (const cat of catModels) {
++ if (ratedCats.has(cat)) continue;
++ const id = cat.slug || cat.canonical || cat.id;
++ if (!id) continue;
++ const offers = Array.isArray(cat.offers) ? cat.offers : [];
++ rows.push({ id, routed: cat.routed !== false, kind: cat.kind || null, model_kind: cat.kind || 'chat', maker: makerOf(cat, id), weights: cat.weights || (cat.open_weights == null ? null : cat.open_weights ? 'open' : 'closed'), status: cat.status || 'current', uncensored: Boolean(cat.uncensored), community: Boolean(cat.community), rating: { value: null, interval: [null, null], source: 'OpenVibe.Bench', date: null, sample_size: 0 }, benchmark: { value: null, reason: 'Not rated yet.' }, provider_offers: offers, measured: measuredFor(id, offers), source: { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: null }, task_ratings: [], unrated: true });
++ }
++ for (const kind of kinds) {
++ const ranked = rows.map((row) => ({ row, task: row.task_ratings.find((rating) => rating.kind === kind.id) })).filter((x) => x.task).sort((a, b) => (b.task.rating.value == null ? -Infinity : b.task.rating.value) - (a.task.rating.value == null ? -Infinity : a.task.rating.value));
++ ranked.forEach(({ task }, i) => {
++ const [lo, hi] = task.rating.interval || [];
++ if (lo == null || hi == null) return;
++ const overlap = ranked.map((other, j) => ({ other, rank: j + 1 })).filter(({ other }) => other.task.rating.interval && other.task.rating.interval[0] != null && other.task.rating.interval[1] != null && other.task.rating.interval[0] <= hi && other.task.rating.interval[1] >= lo).map(({ rank }) => rank);
++ task.rank = overlap.length ? { min: Math.min(...overlap), max: Math.max(...overlap), position: i + 1 } : null;
++ });
++ }
++ const outPrice = (r) => Math.min(...(r.provider_offers || []).map((o) => o.price && o.price.out != null ? Number(o.price.out) : NaN).filter(Number.isFinite), Infinity);
++ rows.sort((a, b) => (b.rating.value == null ? -Infinity : b.rating.value) - (a.rating.value == null ? -Infinity : a.rating.value)
++ || (b.routed === true) - (a.routed === true)
++ || outPrice(a) - outPrice(b)
++ || String(a.id).localeCompare(String(b.id)));
++ rows.forEach((row, i) => {
++ const [lo, hi] = row.rating.interval || [];
++ if (lo == null || hi == null) row.rank = null;
++ else {
++ const overlap = rows.map((other, j) => ({ other, rank: j + 1 })).filter(({ other }) => other.rating.interval && other.rating.interval[0] != null && other.rating.interval[1] != null && other.rating.interval[0] <= hi && other.rating.interval[1] >= lo).map(({ rank }) => rank);
++ row.rank = overlap.length ? { min: Math.min(...overlap), max: Math.max(...overlap), position: i + 1 } : null;
++ }
++ });
++ const tasks = kinds.map((kind) => ({ id: kind.id, name: kind.name }));
++ const catOffers = catModels.flatMap((m) => (m.offers || []).map((o) => ({ ...o, routed: o.routed !== false, model: m.slug })));
++ const stale = [kindsRead, ...boards].filter((r) => r && r.stale).map((r) => r.at).filter(Boolean).sort()[0] || null;
++ const availability = [];
++ if (!kinds.length) availability.push({ id: 'bench', text: 'OpenVibe.Bench is unavailable; ratings show as not rated.' });
++ if (!catalogue) availability.push({ id: 'catalogue', text: "The AI catalogue and Exchange market are unavailable; only Bench's ratings are listed." });
++ else if (catalogue.stale) availability.push({ id: 'market', text: 'The Exchange market is unavailable; showing the last good copy.' });
++ if (!perf) availability.push({ id: 'perf', text: "OpenVibe.Report's measured AI performance is unavailable; the measured columns show a dash." });
++ const dates = rows.map((r) => r.source && r.source.date).filter(iso).concat([perfAt, catalogue && catalogue.generated_at].filter(iso));
++ return { rows, providers: catOffers, tasks, catalogue, availability, stale, measured_at: perfAt, updated_at: dates.sort().at(-1) || iso(now) };
++}
++
++function createAi({ fetchImpl = globalThis.fetch, log = console, now = () => new Date().toISOString(), ttl = TTL_MS, bench = null, catalogue = null } = {}) {
++ const clock = typeof now === 'function' ? now : () => new Date().toISOString();
++ const benchClient = bench || createBenchClient({ fetchImpl, log, now: () => Date.parse(clock()) || Date.now() });
++ const catClient = catalogue || createAiCatalogue({ fetchImpl, log, now: () => Date.parse(clock()) || Date.now() });
++ let cached = null;
++ let expires = 0;
++ let pending = null;
++
++ async function comparison() {
++ const at = clock();
++ if (cached && Date.parse(at) < expires) return cached;
++ if (pending) return pending;
++ pending = (async () => {
++ const [kindsRead, cat, perf] = await Promise.all([
++ benchClient.kinds(),
++ comparisonCatalogue(catClient),
++ requestJson(PERF_URL, { fetchImpl, log, label: 'Report AI performance', timeoutMs: 3000 }),
++ ]);
++ const kinds = kindsRead && kindsRead.data && Array.isArray(kindsRead.data.kinds) ? kindsRead.data.kinds : [];
++ const boards = await Promise.all(kinds.map((kind) => benchClient.board(kind.id, 'elo')));
++ const data = buildComparison({ kindsRead, boards, catalogue: cat, perf, now: at });
++ cached = data;
++ expires = Date.parse(at) + ttl;
++ return data;
++ })().finally(() => { pending = null; });
++ return pending;
++ }
++
++ const readRows = (read) => read && read.data && read.data.board && Array.isArray(read.data.board.rows) ? read.data.board.rows : [];
++
++ async function kindBoard(kindId, tab) {
++ if (!benchClient.isKindId(kindId)) return null;
++ const [kinds, board, cost, benchmarks, runs, overview] = await Promise.all([
++ benchClient.kinds(),
++ benchClient.board(kindId, tab),
++ tab === 'pareto' ? benchClient.board(kindId, 'cost') : Promise.resolve(null),
++ benchClient.benchmarks(),
++ benchClient.runs({ kind: kindId, limit: 8 }),
++ benchClient.overview(),
++ ]);
++ const kind = kinds && kinds.data && Array.isArray(kinds.data.kinds) ? kinds.data.kinds.find((k) => k.id === kindId) : null;
++ if (kinds && !kind) return null;
++ const kindBenchmarks = benchmarks && benchmarks.data && Array.isArray(benchmarks.data.benchmarks)
++ ? benchmarks.data.benchmarks.filter((b) => b.kind === kindId) : [];
++ const rankings = await Promise.all(kindBenchmarks.map((b) => benchClient.rankings(b.id, 'score')));
++ return { kindId, kind, tab, board, cost, kinds, benchmarks, rankings, runs, overview, stale: [kinds, board, cost, benchmarks, runs, overview].filter((r) => r && r.stale).map((r) => r.at).filter(Boolean).sort()[0] || null };
++ }
++
++ async function benchmarkPage(benchmarkId) {
++ if (!benchClient.isBenchmarkId(benchmarkId)) return null;
++ const [benchmark, rankings, runs, overview] = await Promise.all([
++ benchClient.benchmark(benchmarkId), benchClient.rankings(benchmarkId, 'score'), benchClient.runs({ benchmark: benchmarkId, limit: 20 }), benchClient.overview(),
++ ]);
++ const list = overview && overview.data && Array.isArray(overview.data.list) ? overview.data.list : null;
++ if (list && !list.some((b) => b.id === benchmarkId)) return null;
++ return { benchmarkId, benchmark, rankings, runs, overview, stale: [benchmark, rankings, runs, overview].filter((r) => r && r.stale).map((r) => r.at).filter(Boolean).sort()[0] || null };
++ }
++
++ async function method() {
++ const [overview, kinds] = await Promise.all([benchClient.overview(), benchClient.kinds()]);
++ return { overview, kinds, stale: [overview, kinds].filter((r) => r && r.stale).map((r) => r.at).filter(Boolean).sort()[0] || null };
++ }
++
++ async function feed() {
++ const [runs, overview] = await Promise.all([benchClient.runs({ limit: 20 }), benchClient.overview()]);
++ return { runs, overview };
++ }
++
++ async function modelPage(id) {
++ if (!benchClient.isModelName(id)) return null;
++ const [data, detail] = await Promise.all([comparison(), benchClient.modelDetail(id)]);
++ const row = data.rows.find((x) => x.id === id) || null;
++ return { id, detail, row, providers: data.providers, availability: data.availability, sourceDate: detail && detail.at, stale: detail && detail.stale ? detail.at : null };
++ }
++
++ async function compare(models = []) {
++ const data = await comparison();
++ return { models: models.map((id) => data.rows.find((x) => x.id === id) || { id, reason: 'No public model data.' }), options: data.rows.map((x) => x.id), availability: data.availability, stale: data.stale, measured_at: data.measured_at };
++ }
++
++ async function leaderboard(query = {}) {
++ const data = await comparison();
++ const nz = (x) => x == null || x === '' ? NaN : Number(x);
++ let rows = data.rows;
++ if (query.kind) rows = rows.map((x) => {
++ const task = x.task_ratings.find((rating) => rating.kind === query.kind);
++ return task ? { ...x, kind: query.kind, rating: task.rating, benchmark: task.benchmark } : x.kind === query.kind ? x : null;
++ }).filter(Boolean);
++ const type = ['chat', 'embedding', 'stt', 'all'].includes(query.type) ? query.type : 'chat';
++ if (type !== 'all') rows = rows.filter((x) => (x.model_kind || 'chat') === type);
++ if (query.maker) rows = rows.filter((x) => String(x.maker || '').toLowerCase() === String(query.maker).toLowerCase());
++ if (query.weights) rows = rows.filter((x) => x.weights === query.weights || (query.weights === 'open' && x.weights === 'open_weights'));
++ if (query.status !== 'all') rows = rows.filter((x) => x.status === (query.status || 'current'));
++ if (query.uncensored) rows = rows.filter((x) => x.uncensored);
++ if (query.community) rows = rows.filter((x) => x.community);
++ if (query.price) rows = rows.filter((x) => x.provider_offers.some((o) => {
++ const p = nz(o.price && o.price.out);
++ return query.price === 'free' ? (o.free === true || o.free_now === true || p === 0) : !Number.isFinite(p) ? false : query.price === 'low' ? p <= 1 : query.price === 'mid' ? p > 1 && p <= 10 : query.price === 'high' ? p > 10 : true;
++ }));
++ if (query.q) rows = rows.filter((x) => `${x.id} ${x.maker || ''}`.toLowerCase().includes(String(query.q).toLowerCase()));
++ if (query.sort === 'name') rows.sort((a, b) => String(a.id).localeCompare(String(b.id)));
++ if (query.sort === 'price') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => nz(o.price && o.price.out)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => nz(o.price && o.price.out)).filter(Number.isFinite), Infinity));
++ if (query.sort === 'speed') rows.sort((a, b) => Math.min(...a.provider_offers.filter((o) => Number(o.requests_7d) > 0).map((o) => nz(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.filter((o) => Number(o.requests_7d) > 0).map((o) => nz(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity));
++ if (query.sort === 'rating') rows.sort((a, b) => (b.rating.value == null ? -Infinity : b.rating.value) - (a.rating.value == null ? -Infinity : a.rating.value));
++ return { rows, filters: query, availability: data.availability, stale: data.stale, measured_at: data.measured_at };
++ }
++
++ return {
++ comparison, kindBoard, benchmarkPage, method, feed, modelPage, compare, leaderboard,
++ bench: benchClient, catalogue: catClient, readRows, ttlMs: ttl,
++ isKindId: benchClient.isKindId, isBenchmarkId: benchClient.isBenchmarkId, isModelName: benchClient.isModelName, normaliseTab: benchClient.normaliseTab,
++ };
++}
++
++module.exports = { createAi, buildComparison, measuredRow, TTL_MS, PERF_URL };
ddiff --git a/server/ai-join.js b/server/ai-join.js
nnew file mode 100644
iindex 0000000..60cc077
---- /dev/null
++++ b/server/ai-join.js
@@@ -0,0 +1,24 @@
++'use strict';
++
++// Bench names a model as its maker's API does ("gpt-5-nano-2025-08-07"); the Exchange catalogue names it by
++// maker and family ("openai/gpt-5-nano"). They are the same model when the names agree once the maker prefix
++// and a date or channel suffix are dropped. Used by the AI pages so both sides join the same way.
++const bare = (v) => String(v || '').toLowerCase()
++ .replace(/^[^/]+\//, '')
++ .replace(/[-_@:](\d{4}-?\d{2}-?\d{2}|latest|free)$/, '');
++
++function findCatalogModel(models, id) {
++ const list = Array.isArray(models) ? models : [];
++ return list.find((m) => [m.slug, m.canonical, m.id].includes(id))
++ || list.find((m) => [m.slug, m.canonical, m.name, ...(m.offers || []).map((o) => o.model)].some((v) => v && bare(v) === bare(id)))
++ || null;
++}
++
++// The maker: the catalogue's own, else the prefix of the catalogue's name, else the prefix of Bench's name.
++function makerOf(cat, id) {
++ if (cat && cat.maker) return cat.maker;
++ const named = String(cat && (cat.canonical || cat.slug) || id || '');
++ return named.includes('/') ? named.split('/')[0] : null;
++}
++
++module.exports = { bare, findCatalogModel, makerOf };
ddiff --git a/server/ai-market.js b/server/ai-market.js
nnew file mode 100644
iindex 0000000..a510224
---- /dev/null
++++ b/server/ai-market.js
@@@ -0,0 +1,60 @@
++'use strict';
++
++// The AI catalogue and Exchange market joined into the rows the AI pages show. The first offer supplies the
++// displayed prices: lowest known own-page output price, then input price. Unknown prices sort last; never turn
++// null into a free price.
++const { bare, findCatalogModel } = require('./ai-join');
++
++const sameEndpoint = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
++const amount = (v) => v == null || !Number.isFinite(Number(v)) ? Infinity : Number(v);
++
++function mergeMarket(catalogue, market) {
++ if (!market || !Array.isArray(market.offers)) return catalogue;
++ const models = new Map();
++ for (const offer of market.offers) {
++ const key = bare(offer.model);
++ const cat = findCatalogModel(catalogue && catalogue.models, offer.model);
++ let model = models.get(key);
++ if (!model) {
++ model = { ...cat, slug: cat && cat.slug || offer.model, canonical: cat && cat.canonical || offer.model,
++ name: offer.name, kind: offer.kind, maker: offer.maker, open_weights: offer.open_weights,
++ context: offer.context, max_output: offer.max_output, licence: offer.licence,
++ field_sources: offer.field_sources, routed: false, uncensored: false, offers: [] };
++ models.set(key, model);
++ }
++ const measured = offer.routed && cat && cat.offers && cat.offers.find((o) => o.provider === offer.provider && sameEndpoint(o.model, offer.provider_model));
++ model.offers.push({ ...(measured || {}), ...offer, model: offer.provider_model, license: offer.licence,
++ source: { name: 'Provider price page', url: offer.price_source, date: offer.price_checked_at, sample_size: measured && measured.requests_7d != null ? measured.requests_7d : null } });
++ model.routed ||= offer.routed;
++ model.uncensored ||= offer.uncensored;
++ }
++ // Routed models with no own-page pricebook entry still remain discoverable.
++ for (const cat of catalogue && catalogue.models || []) {
++ const key = bare(cat.slug || cat.canonical);
++ const model = models.get(key);
++ if (!model) {
++ models.set(key, { ...cat, routed: true, offers: (cat.offers || []).map((o) => ({ ...o, routed: true })) });
++ continue;
++ }
++ for (const offer of cat.offers || []) {
++ model.routed = true;
++ const matched = model.offers.find((o) => o.provider === offer.provider && sameEndpoint(o.model, offer.model));
++ if (matched) { Object.assign(matched, { ...offer, ...matched, routed: true }); continue; }
++ // A routed endpoint may not have a pricebook observation yet. Keep its measurements,
++ // but only a sourced page price may compete with the market's advertised prices.
++ model.offers.push({ ...offer, routed: true,
++ price: /^https:\/\//.test(offer.price_source || '') ? offer.price : null,
++ source: offer.source || { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: offer.requests_7d != null ? offer.requests_7d : null } });
++ }
++ }
++ for (const model of models.values()) model.offers.sort((a, b) => amount(a.price && a.price.out) - amount(b.price && b.price.out) || amount(a.price && a.price.in) - amount(b.price && b.price.in));
++ return { ...catalogue, generated_at: market.generated_at, metadata: market.metadata, stale: market.stale || false, models: [...models.values()] };
++}
++
++async function comparisonCatalogue(client) {
++ if (!client) return null;
++ const [catalogue, market] = await Promise.all([client.catalog(), client.market && client.market()]);
++ return mergeMarket(catalogue, market);
++}
++
++module.exports = { mergeMarket, comparisonCatalogue };
ddiff --git a/server/ai-source.js b/server/ai-source.js
nnew file mode 100644
iindex 0000000..2e19d72
---- /dev/null
++++ b/server/ai-source.js
@@@ -0,0 +1,48 @@
++'use strict';
++
++// The AI pages read public feeds only. One short failure log line, Compare's 8 s timeout and a
++// parameter rule: a source that cannot answer is null, never an exception the page has to catch.
++const TIMEOUT_MS = 8_000;
++
++const trim = (s) => String(s || '').replace(/\/+$/, '');
++
++function reasonOf(err) {
++ if (!err) return 'no response';
++ if (err.name === 'TimeoutError' || err.name === 'AbortError') return 'timeout';
++ return String(err.message || err).replace(/\s+/g, ' ').slice(0, 160);
++}
++
++function warn(log, message) {
++ const line = `[OpenVibe.Compare] ${String(message).replace(/\s+/g, ' ').slice(0, 240)}`;
++ try {
++ const fn = (log && (log.warn || log.error)) || console.warn;
++ fn.call(log || console, line);
++ } catch {
++ /* logging must never fail the page */
++ }
++}
++
++async function requestJson(url, { fetchImpl = globalThis.fetch, log = console, label = 'source', timeoutMs = TIMEOUT_MS } = {}) {
++ let res;
++ try {
++ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
++ ? AbortSignal.timeout(timeoutMs)
++ : undefined;
++ res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });
++ } catch (err) {
++ warn(log, `${label} failed: ${reasonOf(err)}`);
++ return null;
++ }
++ if (!res || !res.ok) {
++ if (!res || res.status !== 404) warn(log, `${label} failed: HTTP ${res ? res.status : 'no response'}`);
++ return null;
++ }
++ try {
++ return await res.json();
++ } catch (err) {
++ warn(log, `${label} failed: ${reasonOf(err)}`);
++ return null;
++ }
++}
++
++module.exports = { TIMEOUT_MS, trim, warn, reasonOf, requestJson };
ddiff --git a/server/ai-views.js b/server/ai-views.js
nnew file mode 100644
iindex 0000000..1869253
---- /dev/null
++++ b/server/ai-views.js
@@@ -0,0 +1,643 @@
++'use strict';
++
++// Compare's AI pages: Report's AI comparison pages moved here (hub, leaderboard, providers, a model, a
++// comparison, the Bench boards and the method). One way to show each thing everywhere:
++// - a model is its maker's mark, a readable name and its tags;
++// - a rating is the number with its spread and the votes it rests on, or "Not rated yet";
++// - a price is the cheapest provider's own-page price;
++// - speed and uptime are measured on the Exchange's real traffic, shown only where traffic went;
++// - Report's measured first token, tokens per second and success sit beside the prices, and are a dash
++// when Report has no row (under 20 calls in 30 days, or the source is down).
++// A column or section earns its place: it shows when some row has a value, and a missing value reads in words.
++const kit = require('openvibe-shared/kit');
++const { parts, PARTS_CSS } = require('openvibe-shared/showcase');
++const marks = require('openvibe-shared/ai-marks');
++const metrics = require('openvibe-shared/ov-metrics');
++const v = require('./views');
++
++const esc = v.esc;
++const BENCH = 'https://bench.openvibe.services';
++const PERF = 'https://openvibe.report/api/v1/ai/performance?group=model&days=30';
++const value = (x) => x == null || !Number.isFinite(Number(x)) ? null : Number(x);
++const idPath = (id) => `/ai/models/${encodeURIComponent(id || '')}`;
++const act = (href, label) => kit.button({ href, label, kind: 'quiet', icon: 'forward' });
++const quiet = (text) => `<span class="ai-quiet">${esc(text)}</span>`;
++const money = (x) => value(x) == null ? null : Number(x) === 0 ? 'Free' : kit.num(x, 'money');
++const ms = (x) => value(x) == null ? null : kit.num(x, 'ms');
++const share = (x) => { const n = x != null && typeof x === 'object' ? x.value : x; return value(n) == null ? null : `${(Number(n) * 100).toFixed(Number(n) > 0 && Number(n) < 0.01 ? 2 : 1)}%`; };
++const word = { ms: metrics.format.ms, tps: metrics.format.tps, rate: metrics.format.rate };
++const measuredWord = { ttft: metrics.format.ms, tps: metrics.format.tps, success: metrics.format.rate };
++const sourceHint = (s) => { const x = s && typeof s === 'object' ? s : { name: s }; return `Source: ${x.name || 'not listed'}${x.date ? ` · ${String(x.date).slice(0, 10)}` : ''}${x.sample_size != null ? ` · n = ${x.sample_size}` : ''}`; };
++const readData = (r) => (r && r.data != null ? r.data : null);
++
++const HEAD = `<link rel="stylesheet" href="/ai-comparison.css"><link rel="stylesheet" href="/shared/kit-data.css"><link rel="stylesheet" href="/shared/showcase.css"><style>${marks.CSS}\n${PARTS_CSS}</style>`;
++function page(title, description, body, path, ld = []) {
++ return v.page(title, `<div class="ai-page">${body}</div>`, path, ld, { head: HEAD, description });
++}
++function monthLabel() {
++ return new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
++}
++function benchState(stale) {
++ if (!stale) return '';
++ return `<p class="ai-fine" role="status">OpenVibe.Bench is unavailable. Showing the last good copy, read ${esc(String(stale).slice(0, 16).replace('T', ' '))} UTC.</p>`;
++}
++function availabilityBlock(data = {}) {
++ const notes = (data.availability || []).map((x) => `<p class="ai-note" role="status">${esc(x.text)}</p>`);
++ const stale = data.stale ? benchState(data.stale) : '';
++ return stale + notes.join('');
++}
++
++// ── A model ──
++function makerName(row) {
++ const key = marks.family(row && row.maker) || marks.family(row && row.id);
++ return key ? marks.NAMES[key] : (row && row.maker) || null;
++}
++const shortName = (id) => String(id || '').replace(/^[^/]+\//, '') || String(id || '');
++function tags(row) {
++ const t = [];
++ if (typeof row.routed === 'boolean') t.push(kit.badge(row.routed ? 'Routed here' : 'Not routed here'));
++ if (row.weights === 'open' || row.weights === 'open_weights') t.push(kit.badge('Open weights'));
++ if (row.uncensored) t.push(kit.badge('18+', 'warn'));
++ if (row.community) t.push(kit.badge('Community machines'));
++ return t.join('');
++}
++function modelCell(row, { size = 28 } = {}) {
++ const maker = makerName(row);
++ return `<a class="ai-model" href="${idPath(row.id)}" title="${esc(row.id)}">${marks.mark(row.maker || row.id, { size, label: false })}`
++ + `<span class="ai-model-text"><b>${esc(shortName(row.id))}</b><small>${esc(maker || 'Maker not listed')}${tags(row) ? ` ${tags(row)}` : ''}</small></span></a>`;
++}
++
++// ── Numbers on a model ──
++const rated = (row) => row && row.rating && value(row.rating.value) != null;
++function ratingCell(row, { range = false } = {}) {
++ if (!rated(row)) return quiet('Not rated yet');
++ const n = Math.round(Number(row.rating.value));
++ const [lo, hi] = (row.rating.interval || []).map(value);
++ const games = value(row.rating.sample_size);
++ const spread = lo != null && hi != null ? (range ? `${Math.round(lo)}–${Math.round(hi)}` : `± ${Math.round((hi - lo) / 2)}`) : '';
++ const votes = games != null ? `${games.toLocaleString('en-US')} ${games === 1 ? 'game' : 'games'}` : '';
++ return `<span class="ai-rating" title="${esc(sourceHint(row.rating))}"><b>${n}</b>${spread || votes ? `<small>${esc([spread, votes].filter(Boolean).join(' · '))}</small>` : ''}</span>`;
++}
++const rankText = (row, i) => row.rank && row.rank.min != null ? (row.rank.min === row.rank.max ? String(row.rank.min) : `${row.rank.min}–${row.rank.max}`) : (rated(row) ? String(i + 1) : '–');
++const offersOf = (row) => (row && row.provider_offers || []).filter(Boolean);
++const measuredOffer = (o) => o.routed !== false && Number(o.requests_7d) > 0;
++function cheapest(row) {
++ const priced = offersOf(row).filter((o) => o.price && value(o.price.out) != null);
++ return priced.sort((a, b) => Number(a.price.out) - Number(b.price.out))[0] || null;
++}
++function fastest(row) {
++ const timed = offersOf(row).filter((o) => measuredOffer(o) && o.speed && value(o.speed.p50_ms) != null);
++ return timed.sort((a, b) => Number(a.speed.p50_ms) - Number(b.speed.p50_ms))[0] || null;
++}
++function priceCell(row) {
++ const o = cheapest(row);
++ if (!o) return quiet('No price listed');
++ const n = offersOf(row).filter((x) => x.price && value(x.price.out) != null).length;
++ const via = n > 1 ? `cheapest of ${n} providers` : `via ${providerName(o)}`;
++ return `<span class="ai-price" title="${esc(sourceHint(o.source || { name: o.provider_name }))}"><b>${esc(money(o.price.out))}</b> out · ${esc(money(o.price.in) || '–')} in<small>per 1M tokens, ${esc(via)}</small></span>`;
++}
++function speedCell(row) {
++ const o = fastest(row);
++ if (!o) return quiet('No traffic yet');
++ return `<span title="${esc(sourceHint({ name: 'OpenVibe.AI, real traffic', sample_size: o.requests_7d }))}"><b>${esc(ms(o.speed.p50_ms))}</b><small>typical reply · p95 ${esc(ms(o.speed.p95_ms) || '–')}</small></span>`;
++}
++function uptimeCell(row) {
++ const o = fastest(row) || offersOf(row).find(measuredOffer);
++ if (!o || share(o.uptime) == null) return quiet('No traffic yet');
++ return `<b${Number(o.uptime) < 0.95 ? ' class="ai-bad"' : ''}>${esc(share(o.uptime))}</b><small>${esc(Number(o.requests_7d).toLocaleString('en-US'))} requests in 7 days</small>`;
++}
++function measuredCell(row, key) {
++ const m = row && row.measured;
++ const text = m && measuredWord[key] ? measuredWord[key](m[key]) : '—';
++ if (!m || m[key] == null) return `<span class="ovm-unknown" title="Report lists a model after 20 calls in 30 days">${esc(text)}</span>`;
++ return `<span title="${esc(`OpenVibe.Report public model row; ${m.calls == null ? 'calls not reported' : `${Number(m.calls).toLocaleString('en-US')} calls in 30 days`}`)}"><b>${esc(text)}</b><small>${m.calls == null ? '30-day row' : `${Number(m.calls).toLocaleString('en-US')} calls`}</small></span>`;
++}
++function measuredNote(data = {}) {
++ if ((data.availability || []).some((x) => x.id === 'perf')) return '';
++ return `<p class="ai-fine">Measured columns: OpenVibe.Report's public model rows over 30 days, matched exactly to the model or its provider model name. A model needs 20 calls to be listed; a dash is not a zero. <a href="${PERF}">Report's open data</a>.</p>`;
++}
++
++// ── The leaderboard table ──
++function board(rows, { full = false, measured = true } = {}) {
++ const has = (fn) => rows.some(fn);
++ const columns = [
++ has(rated) && { key: 'rank', label: 'Rank', num: true, html: true },
++ { key: 'model', label: 'Model', primary: true, html: true },
++ has(rated) && { key: 'rating', label: 'Rating', html: true },
++ has((r) => cheapest(r)) && { key: 'price', label: 'Price', html: true },
++ measured && { key: 'ttft', label: 'First token p50', html: true },
++ measured && { key: 'tps', label: 'Tokens/s p50', html: true },
++ measured && { key: 'success', label: 'Success', html: true },
++ has((r) => fastest(r)) && { key: 'speed', label: 'Reply time', html: true },
++ full && has((r) => offersOf(r).some((o) => measuredOffer(o) && share(o.uptime) != null)) && { key: 'uptime', label: 'Uptime 7 d', html: true },
++ full && has((r) => r.benchmark && value(r.benchmark.value) != null) && { key: 'bench', label: 'Bench score', html: true },
++ ].filter(Boolean);
++ const data = rows.map((r, i) => ({
++ rank: `<span class="ai-rank${rated(r) && i < 3 ? ' is-top' : ''}">${esc(rankText(r, i))}</span>`,
++ model: modelCell(r),
++ rating: ratingCell(r),
++ price: priceCell(r),
++ ttft: measuredCell(r, 'ttft'),
++ tps: measuredCell(r, 'tps'),
++ success: measuredCell(r, 'success'),
++ speed: speedCell(r),
++ uptime: uptimeCell(r),
++ bench: r.benchmark && value(r.benchmark.value) != null ? `<b>${esc(kit.num(r.benchmark.value))}</b>` : quiet('No runs yet'),
++ }));
++ const none = rows.length && !has(rated) ? `<p class="ai-note">No model has blind votes or runs yet, so nothing is ranked: these are the models with their prices and measured speed. <a href="https://openvibe.exchange/arena">Vote in the Arena</a> to rate them.</p>` : '';
++ const wrapped = data.map((d) => Object.fromEntries(Object.entries(d).map(([k, val]) => [k, k === 'model' ? val : `<span class="ai-cell">${val}</span>`])));
++ return none + kit.table({ columns, rows: wrapped, label: 'AI models', empty: 'No model matches these filters.' }).replace('class="ovk-table"', 'class="ovk-table ai-board"');
++}
++
++function dataLinks() {
++ return `<p class="ai-data">Data: <a href="/api/v1/comparisons/ai-models">JSON</a> · <a href="/ai/method">How it is measured</a></p>`;
++}
++
++// ── /ai ──
++const PICK_LABELS = { overall: 'Best overall', value: 'Best value', fastest: 'Fastest', open: 'Best open weights', code: 'Best for code', writing: 'Best for writing', uncensored: 'Best uncensored 18+', free: 'Best free' };
++function pickNumber(id, row) {
++ if (id === 'value' || id === 'free') { const o = cheapest(row); return !o ? '' : Number(o.price.out) === 0 ? 'Free' : `${money(o.price.out)} per 1M out`; }
++ if (id === 'fastest') { const o = fastest(row); return o ? `${ms(o.speed.p50_ms)} typical reply` : ''; }
++ return rated(row) ? `Rating ${Math.round(row.rating.value)}` : '';
++}
++function hub(data = {}) {
++ const rows = data.rows || [];
++ const month = monthLabel();
++ const chat = rows.filter((r) => (r.model_kind || 'chat') === 'chat');
++ const ratedRows = chat.filter(rated);
++ const best = (list) => [...list].sort((a, b) => b.rating.value - a.rating.value)[0];
++ const nz = (x) => x == null || x === '' ? NaN : Number(x);
++ const cheapestOf = (r) => Math.min(...(r.provider_offers || []).map((o) => nz(o.price && o.price.out)).filter(Number.isFinite), Infinity);
++ const fastestOf = (r) => Math.min(...(r.provider_offers || []).filter((o) => Number(o.requests_7d) > 0).map((o) => nz(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity);
++ const free = (r) => (r.provider_offers || []).some((o) => o.free === true || o.free_now === true || nz(o.price && o.price.out) === 0);
++ const taskRows = (kind) => ratedRows.map((row) => { const task = row.task_ratings.find((x) => x.kind === kind && x.rating && x.rating.value != null); return task ? { ...row, kind, rating: task.rating, benchmark: task.benchmark } : null; }).filter(Boolean);
++ const choose = (id, model, why) => ({ id, model: model || null, why: model ? why : 'Not enough source data to establish this pick.' });
++ const picks = [
++ choose('overall', best(ratedRows), 'Highest rating from blind votes and open runs.'),
++ choose('value', ratedRows.filter((r) => Number.isFinite(cheapestOf(r))).sort((a, b) => cheapestOf(a) - cheapestOf(b))[0], 'Lowest output price among rated models.'),
++ choose('fastest', chat.filter((r) => Number.isFinite(fastestOf(r))).sort((a, b) => fastestOf(a) - fastestOf(b))[0], 'Fastest typical reply on real traffic this week.'),
++ choose('open', best(ratedRows.filter((r) => ['open', 'open_weights'].includes(r.weights))), 'Highest-rated model with open weights.'),
++ choose('code', best(taskRows('code')), 'Highest rating on code tasks.'),
++ choose('writing', best(taskRows('writing')), 'Highest rating on writing tasks.'),
++ choose('uncensored', best(ratedRows.filter((r) => r.uncensored)), 'Highest-rated uncensored model; 18+.'),
++ choose('free', best(ratedRows.filter(free)) || chat.find(free), 'Free to use now through the Exchange.'),
++ ];
++ const providers = data.providers || [];
++ const tasks = data.tasks || [];
++ const others = rows.length - chat.length;
++ const providerNames = new Set(providers.map(providerName).filter(Boolean));
++ const measuredOffers = providers.filter(measuredOffer);
++ const made = picks.filter((p) => p.model);
++ const pending = picks.filter((p) => !p.model).map((p) => PICK_LABELS[p.id] || p.id);
++ const pickList = made.map((p) => `<li><a class="ai-pick" href="${idPath(p.model.id)}"><span class="ai-pick-k">${esc(PICK_LABELS[p.id] || p.id)}</span>`
++ + `<span class="ai-pick-m">${marks.mark(p.model.maker || p.model.id, { size: 32, label: false })}<b>${esc(shortName(p.model.id))}</b></span>`
++ + `${pickNumber(p.id, p.model) ? `<span class="ai-pick-n">${esc(pickNumber(p.id, p.model))}</span>` : ''}<small>${esc(p.why || '')}</small></a></li>`).join('');
++ const pairs = ratedRows.slice(0, 16).reduce((a, r, i, all) => { if (i % 2 === 0 && all[i + 1]) a.push([r, all[i + 1]]); return a; }, []).slice(0, 8);
++ const sourceDate = rows.map((r) => r.source && r.source.date).find(Boolean) || 'date not reported by source';
++ const faq = [
++ ['How are ratings made?', `${ratedRows.length} models have a rating from blind pairwise votes and open runs on OpenVibe.Bench (Bradley-Terry with bootstrap intervals), dated ${sourceDate}.`],
++ ['Where do prices come from?', `Each provider's own price page, read by OpenVibe.AI's pricebook; ${providers.length} provider and model offers are listed.`],
++ ['What does the rank band mean?', 'Models whose rating intervals overlap share a band: the data cannot tell them apart yet.'],
++ ['Why does a model say "Not rated yet"?', 'No blind votes or runs on it so far. Its prices and measured speed are still listed.'],
++ ['How is speed measured?', `On real requests through OpenVibe.Exchange over seven days: ${measuredOffers.length} provider endpoints had traffic.`],
++ ['How often does this change?', 'Ratings and prices refresh within two minutes of the sources changing.'],
++ ];
++ const body = `<header class="ai-head"><p class="ai-kicker">AI comparison · ${esc(month)}</p><h1>AI model leaderboard — ${esc(month)}</h1>`
++ + `<p class="ai-lede">${rows.length} models from ${providerNames.size} providers. Ratings come from blind votes, prices from each provider's own page, and speed and uptime from real traffic. Measured first-token time, tokens per second and success come from OpenVibe.Report.</p>`
++ + availabilityBlock(data)
++ + kit.find({ action: '/ai/leaderboard', placeholder: 'Search a model or maker', label: 'Search models' })
++ + kit.stats([[ratedRows.length, 'rated'], [rows.length, 'models listed'], [providerNames.size, 'providers'], [measuredOffers.length, 'measured endpoints']], { label: 'The leaderboard in numbers' })
++ + `</header>`
++ + (pickList || pending.length ? kit.section({ title: 'Top picks', body: `${pickList ? `<ul class="ai-picks">${pickList}</ul>` : ''}${pending.length ? `<p class="ai-fine">Not enough data yet for ${esc(pending.join(', '))}.</p>` : ''}` }) : '')
++ // A phone shows the parts below the top picks one at a time as tabs; every part stays in the HTML.
++ + parts({ name: 'board', label: 'The leaderboard\'s parts', items: [
++ { id: 'leaderboard', label: 'Leaderboard', count: chat.length, html: kit.section({ title: 'Leaderboard', count: chat.length, actions: [act('/ai/leaderboard', chat.length > 25 ? `All ${chat.length} models` : 'Filter and sort')], body: board(chat.slice(0, 25)) + (others ? `<p class="ai-fine">Chat models only. ${others} embedding and speech ${others === 1 ? 'model is' : 'models are'} in the <a href="/ai/leaderboard?type=all">full list</a>.</p>` : '') + dataLinks() + measuredNote(data) }) },
++ { id: 'tasks', label: 'By task', count: tasks.length || '', html: (tasks.length ? kit.section({ title: 'Boards by task', body: `<ul class="ai-chips">${tasks.slice(0, 16).map((x) => `<li><a href="/ai/${encodeURIComponent(x.id)}">${esc(x.name || x.id)}</a></li>`).join('')}</ul>` }) : '') },
++ { id: 'providers', label: 'Providers', count: measuredOffers.length || '', html: (measuredOffers.length ? kit.section({ title: 'Providers', actions: [act('/ai/providers', 'Every provider')], body: providerTable(measuredOffers.slice(0, 6)) }) : '') },
++ { id: 'compare', label: 'Compare', html: (pairs.length ? kit.section({ title: 'Popular comparisons', body: `<ul class="ai-chips">${pairs.map(([a, b]) => `<li><a href="/ai/compare/${encodeURIComponent(a.id)}-vs-${encodeURIComponent(b.id)}">${esc(shortName(a.id))} vs ${esc(shortName(b.id))}</a></li>`).join('')}</ul>` }) : '') },
++ { id: 'questions', label: 'Questions', html: kit.section({ title: 'Questions', body: faq.map(([q, a]) => kit.fold({ title: q, body: `<p>${esc(a)}</p>` })).join('') }) },
++ ] })
++ + scriptLd({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map(([q, a]) => ({ '@type': 'Question', name: q, acceptedAnswer: { '@type': 'Answer', text: a } })) })
++ + scriptLd({ '@context': 'https://schema.org', '@type': 'Dataset', name: 'OpenVibe AI model leaderboard', url: 'https://openvibe.compare/ai', variableMeasured: ['Bench rating', 'Provider price', 'Provider reply time', 'Provider uptime', 'Measured first token time', 'Measured tokens per second', 'Measured success rate'], distribution: { '@type': 'DataDownload', contentUrl: 'https://openvibe.compare/api/v1/comparisons/ai-models', encodingFormat: 'application/json' } });
++ return page(`AI model leaderboard — ${month}`, `${rows.length} AI models compared: ratings from blind votes, provider prices and measured speed.`, body, '/ai');
++}
++
++function scriptLd(obj) {
++ return `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
++}
++
++// ── /ai/leaderboard ──
++function leaderboard(data = {}) {
++ const rows = data.rows || [];
++ const filters = data.filters || {};
++ const select = (name, label, opts) => `<label class="ai-filter"><span>${esc(label)}</span><select name="${name}">${opts.map(([val, t]) => `<option value="${esc(val)}"${String(filters[name] || '') === val ? ' selected' : ''}>${esc(t)}</option>`).join('')}</select></label>`;
++ const text = (name, label, placeholder) => `<label class="ai-filter"><span>${esc(label)}</span><input name="${name}" value="${esc(filters[name] || '')}" placeholder="${esc(placeholder)}"></label>`;
++ const form = `<form class="ai-filters" action="/ai/leaderboard">`
++ + `<label class="ai-filter ai-filter--q"><span>Search</span><input name="q" type="search" value="${esc(filters.q || '')}" placeholder="Model or maker"></label>`
++ + select('type', 'Type', [['chat', 'Chat models'], ['embedding', 'Embeddings'], ['stt', 'Speech to text'], ['all', 'All']])
++ + select('sort', 'Sort by', [['rating', 'Rating'], ['price', 'Output price'], ['speed', 'Reply time'], ['name', 'Name']])
++ + select('weights', 'Weights', [['', 'Any'], ['open', 'Open weights'], ['closed', 'Closed']])
++ + select('price', 'Price', [['', 'Any price'], ['free', 'Free'], ['low', 'Low'], ['mid', 'Mid'], ['high', 'High']])
++ + text('maker', 'Maker', 'e.g. openai')
++ + text('kind', 'Task', 'e.g. design.page')
++ + select('status', 'Status', [['current', 'Current'], ['all', 'All']])
++ + select('uncensored', '18+', [['', 'Any'], ['true', 'Uncensored only']])
++ + select('community', 'Runs on', [['', 'Anywhere'], ['true', 'Community machines']])
++ + `<button class="ovk-btn ovk-btn--primary">Apply</button></form>`;
++ const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI model leaderboard</h1>`
++ + `<p class="ai-lede">${rows.length} ${rows.length === 1 ? 'model' : 'models'}${rows.filter(rated).length ? `, ${rows.filter(rated).length} rated` : ''}. Rated models first, then the rest with their prices.</p></header>`
++ + availabilityBlock(data) + form + board(rows, { full: true }) + measuredNote(data) + dataLinks();
++ return page('AI model leaderboard', 'Every AI model with its rating, provider prices and measured speed; filter and sort.', body, '/ai/leaderboard', [{ '@context': 'https://schema.org', '@type': 'Dataset', name: 'OpenVibe AI model leaderboard', url: 'https://openvibe.compare/ai/leaderboard' }]);
++}
++
++// ── /ai/providers ──
++const ownServer = (p) => /^(local|whisper)$/i.test(String(p.provider || '')) || /on this host|\blocal\b/i.test(String(p.provider_name || ''));
++const providerName = (p) => ownServer(p) ? 'OpenVibe server' : String(p.provider_name || p.provider || 'Provider').replace(/\s*\((?:the |an? )[^)]*\)\s*$/i, '');
++const providerMark = (p) => ownServer(p) ? marks.mark('openvibe', { size: 24, label: false }) : marks.mark(p.provider || p.provider_name, { size: 24, label: false });
++function providerTable(list) {
++ const notUp = (p) => p.routed !== false && ['down', 'degraded'].includes(String(p.status || 'up'));
++ const statusCell = (p) => {
++ if (p.routed === false) return quiet('Not routed here');
++ if (!notUp(p)) return 'Up';
++ const down = String(p.status) === 'down';
++ const why = p.status_reason ? `<small>${esc(p.status_reason)}</small>` : '';
++ const since = p.status_since ? `<small>since ${esc(String(p.status_since).slice(0, 16).replace('T', ' '))} UTC</small>` : '';
++ return `<b class="${down ? 'ai-bad' : 'ai-warn'}">${down ? 'Down' : 'Degraded'}</b>${why}${since}`;
++ };
++ const rows = list.map((p) => ({
++ provider: `<span class="ai-prov">${providerMark(p)}<b>${esc(providerName(p))}</b></span>`,
++ model: `<a href="${idPath(p.model)}">${esc(shortName(p.model))}</a>`,
++ status: statusCell(p),
++ price: p.price && value(p.price.out) != null ? `<b>${esc(money(p.price.out))}</b> out · ${esc(money(p.price.in) || '–')} in` : quiet('No price listed'),
++ reply: p.routed === false ? quiet('Not routed here') : measuredOffer(p) && p.speed && value(p.speed.p50_ms) != null ? `<b>${esc(ms(p.speed.p50_ms))}</b><small>p95 ${esc(ms(p.speed.p95_ms) || '–')}</small>` : quiet('Not measured yet'),
++ errors: p.routed === false ? quiet('Not routed here') : measuredOffer(p) && share(p.error_rate) != null ? `<b class="${Number(typeof p.error_rate === 'object' ? p.error_rate.value : p.error_rate) > 0.05 ? 'ai-bad' : ''}">${esc(share(p.error_rate))}</b>` : quiet('Not measured yet'),
++ uptime: p.routed === false ? quiet('Not routed here') : measuredOffer(p) && share(p.uptime) != null ? `<b class="${Number(p.uptime) < 0.95 ? 'ai-bad' : ''}">${esc(share(p.uptime))}</b>` : quiet('Not measured yet'),
++ requests: p.routed === false ? quiet('Not routed here') : measuredOffer(p) ? Number(p.requests_7d).toLocaleString('en-US') : quiet('Not measured yet'),
++ }));
++ return kit.table({
++ columns: [
++ { key: 'provider', label: 'Provider', primary: true, html: true },
++ { key: 'model', label: 'Model', html: true },
++ list.some(notUp) && { key: 'status', label: 'Status', html: true },
++ { key: 'price', label: 'Price per 1M', html: true },
++ { key: 'reply', label: 'Reply time', html: true },
++ { key: 'errors', label: 'Errors', html: true },
++ { key: 'uptime', label: 'Uptime 7 d', html: true },
++ { key: 'requests', label: 'Requests 7 d', html: true },
++ ].filter(Boolean),
++ rows: rows.map((d) => Object.fromEntries(Object.entries(d).map(([k, val]) => [k, k === 'provider' ? val : `<span class="ai-cell">${val}</span>`]))), label: 'Providers', empty: 'No provider is listed yet.',
++ });
++}
++function providers(data = {}) {
++ const sorted = [...(data.providers || [])].sort((a, b) => (Number(b.requests_7d) || 0) - (Number(a.requests_7d) || 0));
++ const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI providers</h1>`
++ + `<p class="ai-lede">Every model and provider offer in the market. Prices come from each provider's own page. Reply time, errors and uptime are measured on real requests over the last seven days; offers outside the router say “Not routed here”, while routed offers without measurements say “Not measured yet”. Status is measured from the Exchange catalogue: down while an offer's circuit is open, degraded when more than 5% of at least 10 requests in the last hour failed, with the plain reason.</p></header>`
++ + availabilityBlock(data) + providerTable(sorted);
++ return page('AI providers', 'AI providers compared: price per million tokens, reply time, error rate and uptime measured on real traffic.', body, '/ai/providers');
++}
++
++// ── /ai/models/:id ──
++function model(data = {}) {
++ const id = data.id;
++ const detail = readData(data.detail) || {};
++ const row = data.row || null;
++ const list = data.providers || [];
++ const self = row || { id, provider_offers: list.filter((p) => p.model === id) };
++ const offers = offersOf(self).length ? offersOf(self) : list.filter((p) => p.model === id);
++ const tasks = (self.task_ratings || []).filter((t) => t.rating && value(t.rating.value) != null);
++ const maker = makerName(self);
++ const top = cheapest({ provider_offers: offers });
++ const quick = fastest({ provider_offers: offers });
++ const measured = self.measured || null;
++ const facts = kit.stats([
++ [rated(self) ? Math.round(self.rating.value) : 'Not rated yet', 'rating'],
++ [top ? money(top.price.out) : null, 'cheapest output / 1M'],
++ [measured && measured.ttft != null ? word.ms(measured.ttft) : null, 'first token p50'],
++ [measured && measured.tps != null ? word.tps(measured.tps) : null, 'tokens/s p50'],
++ [measured && measured.success != null ? word.rate(measured.success) : null, 'success'],
++ [quick ? ms(quick.speed.p50_ms) : null, 'typical reply'],
++ [offers.length || null, offers.length === 1 ? 'provider' : 'providers'],
++ ], { label: `${id} in numbers` });
++ const taskTable = tasks.length ? kit.table({
++ columns: [{ key: 'task', label: 'Task', primary: true, html: true }, { key: 'rank', label: 'Rank' }, { key: 'rating', label: 'Rating', html: true }],
++ rows: tasks.map((t) => ({ task: `<a href="/ai/${encodeURIComponent(t.kind)}">${esc(t.kind)}</a>`, rank: t.rank ? (t.rank.min === t.rank.max ? String(t.rank.min) : `${t.rank.min}–${t.rank.max}`) : 'Not enough data', rating: ratingCell({ rating: t.rating }, { range: true }) })),
++ label: 'Task ratings',
++ }) : `<p class="ai-fine">Not rated on any task yet: no blind votes or runs on this model so far.</p>`;
++ const benchmarks = (detail.benchmarks || []).length ? kit.table({
++ columns: [{ key: 'name', label: 'Benchmark', primary: true }, { key: 'score', label: 'Score' }, { key: 'runs', label: 'Runs' }, { key: 'last', label: 'Last run' }],
++ rows: detail.benchmarks.map((b) => ({ name: b.title || b.benchmark_id, score: b.score == null ? 'Not measured' : String(b.score), runs: b.runs == null ? 'Not reported' : String(b.runs), last: b.last_run ? String(b.last_run).slice(0, 10) : '–' })),
++ label: 'Benchmarks', caption: `OpenVibe.Bench${data.sourceDate ? ` · snapshot ${String(data.sourceDate).slice(0, 10)}` : ''}`,
++ }) : '';
++ const measuredTable = self.measured ? kit.table({
++ columns: [{ key: 'measure', label: 'Measured', primary: true }, { key: 'p50', label: 'p50' }, { key: 'calls', label: 'Calls' }, { key: 'date', label: 'Window ends' }],
++ rows: [
++ { measure: 'Time to first token p50', p50: word.ms(self.measured.ttft), calls: self.measured.calls == null ? 'Not reported' : String(self.measured.calls), date: self.measured.as_of ? String(self.measured.as_of).slice(0, 10) : '—' },
++ { measure: 'Tokens per second p50', p50: word.tps(self.measured.tps), calls: self.measured.calls == null ? 'Not reported' : String(self.measured.calls), date: self.measured.as_of ? String(self.measured.as_of).slice(0, 10) : '—' },
++ { measure: 'Success rate', p50: word.rate(self.measured.success), calls: self.measured.calls == null ? 'Not reported' : String(self.measured.calls), date: self.measured.as_of ? String(self.measured.as_of).slice(0, 10) : '—' },
++ ], label: 'Measured on real traffic', caption: 'OpenVibe.Report public model row, 30 days',
++ }) : `<p class="ai-fine">No measured row from OpenVibe.Report: a model is listed after 20 calls in 30 days, and a dash is not a zero. <a href="${PERF}">Report's open data</a>.</p>`;
++ const body = `<header class="ai-head ai-head--model"><p class="ai-kicker"><a href="/ai">AI comparison</a></p>`
++ + `<div class="ai-model-head">${marks.mark(self.maker || id, { size: 56 })}<div><h1>${esc(shortName(id))}</h1><p class="ai-lede">${esc(maker || 'Maker not listed')}${tags(self) ? ` ${tags(self)}` : ''} · <code>${esc(id)}</code></p></div></div>`
++ + facts + `</header>`
++ + availabilityBlock(data)
++ + kit.section({ title: 'Providers', count: offers.length, body: providerTable(offers) })
++ + kit.section({ title: 'Measured on real traffic', count: self.measured ? 3 : null, body: measuredTable })
++ + kit.section({ title: 'Task ratings', count: tasks.length || null, body: taskTable + benchmarks })
++ + `<aside class="ai-try"><a class="ovk-btn ovk-btn--primary" href="https://openvibe.chat/vibe?models=${encodeURIComponent(id)}">Try it in Chat</a><a class="ovk-btn ovk-btn--quiet" href="https://openvibe.exchange/models/${encodeURIComponent(id)}">Use it from code</a></aside>`;
++ return page(`${shortName(id)} — AI model`, `${id}: rating, prices across providers and measured speed.`, body, idPath(id), [{ '@context': 'https://schema.org', '@type': 'Product', name: id, brand: maker || 'Unknown maker', url: `https://openvibe.compare${idPath(id)}` }]);
++}
++
++// ── /ai/compare ──
++function compare(data = {}) {
++ const models = data.models || [];
++ const options = data.options || [];
++ const two = models.length === 2;
++ const facts = (m) => [
++ ['Rating', ratingCell(m, { range: true })],
++ ['Price', priceCell(m)],
++ ['First token p50', measuredCell(m, 'ttft')],
++ ['Tokens/s p50', measuredCell(m, 'tps')],
++ ['Success', measuredCell(m, 'success')],
++ ['Reply time', speedCell(m)],
++ ['Providers', offersOf(m).length ? String(offersOf(m).length) : quiet('None listed')],
++ ];
++ const cards = models.map((m) => `<article class="ai-vs-card">${modelCell(m, { size: 40 })}<dl>${facts(m).map(([k, val]) => `<div><dt>${esc(k)}</dt><dd>${val}</dd></div>`).join('')}</dl></article>`).join('');
++ const list = `<datalist id="ai-models">${options.slice(0, 500).map((o) => `<option value="${esc(o)}"></option>`).join('')}</datalist>`;
++ const form = `<form class="ai-filters ai-vs-form" action="/ai/compare"><label class="ai-filter"><span>First model</span><input name="a" list="ai-models" value="${esc(models[0] && models[0].id || '')}" required></label>`
++ + `<label class="ai-filter"><span>Second model</span><input name="b" list="ai-models" value="${esc(models[1] && models[1].id || '')}" required></label><button class="ovk-btn ovk-btn--primary">Compare</button>${list}</form>`;
++ const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>${two ? `${esc(shortName(models[0].id))} vs ${esc(shortName(models[1].id))}` : 'Compare AI models'}</h1>`
++ + `${two ? '' : '<p class="ai-lede">Choose two models to compare: their ratings, prices and measured speed side by side.</p>'}</header>`
++ + availabilityBlock(data) + (cards ? `<div class="ai-vs">${cards}</div>` : '') + form + measuredNote(data);
++ return page(two ? `${shortName(models[0].id)} vs ${shortName(models[1].id)}` : 'Compare AI models', 'Compare AI models side by side: ratings, prices and measured speed.', body, '/ai/compare');
++}
++
++function arena() {
++ return page('AI Arena', 'Compare anonymous AI answers and vote.', `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI Arena</h1><p class="ai-lede">Two anonymous answers to one prompt; you pick the better one, then see which models wrote them. Every vote moves the ratings here.</p></header><p><a class="ovk-btn ovk-btn--primary" href="https://openvibe.exchange/arena">Vote in the Arena</a></p>`, '/ai/arena');
++}
++
++// ── The Bench pages (/ai/:ref): a task kind's board or one benchmark ──
++const JUDGE_SHORT = { human_pairwise: 'blind votes', audience_poll: 'audience polls', rubric: 'a rubric', automatic_grader: 'a grader', test_suite: 'a test suite', game_outcome: 'game results' };
++const ROOM_HOSTS = { chat: 'openvibe.chat', games: 'openvibe.games', search: 'search.openvibe.network', codes: 'openvibe.codes' };
++const rowsOf = (read) => (read && read.data && read.data.board && Array.isArray(read.data.board.rows) ? read.data.board.rows : []);
++const played = (rows) => {
++ const list = rows || [];
++ if (!list.some((r) => r && r.games != null)) return list.length > 0;
++ return list.some((r) => (value(r && r.games) || 0) > 0);
++};
++const roomName = (id) => { const s = String(id || ''); return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; };
++const howRated = (kind) => `Rated by ${JUDGE_SHORT[kind && kind.judge] || 'Bench'}`;
++const METHOD = 'Ratings are Bradley–Terry Elo from Bench, with a 95% interval; a model with fewer than ten games is provisional.';
++const modelPath = (model) => `/ai/models/${encodeURIComponent(model)}`;
++const modelLink = (subject) => `<a href="${modelPath(subject)}">${esc(subject)}</a>`;
++const dayTime = (s) => { const t = Date.parse(String(s || '')); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(0, 10)} ${new Date(t).toISOString().slice(11, 16)} UTC` : String(s || ''); };
++const usd = (x) => { const n = value(x); if (n == null) return '—'; if (!n) return '$0'; const a = Math.abs(n); if (a >= 1000) return `$${n.toLocaleString('en-US', { maximumFractionDigits: 0 })}`; if (a >= 1) return `$${n.toFixed(2)}`; return `$${Number(n.toPrecision(2))}`; };
++const scoreText = (x) => value(x) == null ? '—' : `${Math.round(Number(x) * 10) / 10}`;
++const scoreCell = (x) => value(x) == null ? '—' : `<span title="${esc(String(x))} / score from Bench">${esc(scoreText(x))}</span>`;
++const moneyCell = (x) => value(x) == null ? '—' : `<span title="${esc(String(x))} USD from Bench">${esc(usd(x))}</span>`;
++function msText(x) {
++ const n = value(x);
++ if (n == null) return '—';
++ if (n >= 60_000) return `${(n / 60_000).toFixed(n >= 600_000 ? 0 : 1).replace(/\.0$/, '')} min`;
++ if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1).replace(/\.0$/, '')} s`;
++ return `${Math.round(n)} ms`;
++}
++const benchSpeedCell = (x) => value(x) == null ? '—' : `<span title="${esc(`${value(x)} ms from Bench`)}">${esc(msText(x))}</span>`;
++const benchShare = (x) => { const n = value(x); return n == null ? '—' : `${Math.round(n * 100)}%`; };
++const lastRunText = (x) => x ? `Last run ${dayTime(x)}` : 'No runs yet';
++const provenanceCell = (p) => p === 'verified' ? '<span class="ai-fine">verified</span>' : p === 'submitted' ? '<span class="ai-flag">submitted</span>' : '—';
++const byKindFromOverview = (overview) => {
++ const map = new Map();
++ for (const b of overview && Array.isArray(overview.list) ? overview.list : []) {
++ if (!b || !b.kind || !b.last_run) continue;
++ if (!map.has(b.kind) || String(b.last_run) > String(map.get(b.kind))) map.set(b.kind, b.last_run);
++ }
++ return map;
++};
++function frontier(rows, xKey, yKey) {
++ return rows.map((r) => {
++ const x = value(r[xKey]);
++ const y = value(r[yKey]);
++ const best = x != null && y != null && !rows.some((o) => {
++ if (o === r) return false;
++ const ox = value(o[xKey]);
++ const oy = value(o[yKey]);
++ return ox != null && oy != null && ox <= x && oy >= y && (ox < x || oy > y);
++ });
++ return { ...r, frontier: best, cost: x, quality: y };
++ });
++}
++function paretoRows(pareto, cost) {
++ const costs = new Map(rowsOf(cost).map((r) => [r.subject, value(r.cost_per_solved)]));
++ const rows = rowsOf(pareto).map((r) => ({ ...r, quality: value(r.quality), costPerSolved: costs.get(r.subject) }));
++ return frontier(rows.map((r) => ({ ...r, x: r.costPerSolved, y: r.quality })), 'x', 'y');
++}
++function paretoChart(rows) {
++ const points = rows.filter((r) => r.quality != null && r.costPerSolved != null && r.costPerSolved >= 0);
++ if (!points.length) return '<p class="ai-fine">No quality and cost per solved pairs yet.</p>';
++ return kit.chart({
++ type: 'scatter', title: 'Quality against cost per solved task', note: 'Up and to the left is better. The dashed line joins the models nothing beats on both.',
++ x: { label: 'Cost per solved task (USD, lower is better)', fmt: (x) => usd(x) }, y: { label: 'Quality', fmt: (x) => `${Math.round(x)}%`, zero: false },
++ points: points.map((r) => ({ label: r.subject, x: r.costPerSolved, y: r.quality * 100, best: r.frontier, mark: r.frontier || points.length <= 8, href: modelPath(r.subject) })),
++ height: 260,
++ });
++}
++function scoreCostChart(entries) {
++ const rows = frontier(entries.filter((r) => value(r.cost_usd) != null && value(r.score) != null).map((r) => ({ ...r, x: value(r.cost_usd), y: value(r.score) })), 'x', 'y');
++ if (!rows.length) return '';
++ return kit.chart({
++ type: 'scatter', title: 'Score against cost per run', note: 'Up and to the left is better. The dashed line joins the models nothing beats on both.',
++ x: { label: 'Median cost of a run (USD, lower is better)', fmt: (x) => usd(x) }, y: { label: 'Score (0–100)', fmt: (x) => `${Math.round(x)}`, zero: false },
++ points: rows.map((r) => ({ label: r.model, x: r.cost, y: r.quality, best: r.frontier, mark: r.frontier || rows.length <= 8, href: modelPath(r.model) })),
++ height: 260,
++ });
++}
++const TABS = [{ key: 'elo', label: 'Ranking' }, { key: 'pareto', label: 'Quality and cost' }, { key: 'cost', label: 'Cost per solved' }];
++
++function emptyFor(kind) {
++ const host = kind && kind.judge === 'human_pairwise' ? ROOM_HOSTS[kind.room] : null;
++ if (host) return kit.empty({ icon: 'trophy', title: 'No votes yet', text: `This board fills as people compare two answers blind on ${roomName(kind.room)} and pick the better one.`, action: { href: `https://${host}/`, label: `Vote on ${roomName(kind.room)}`, icon: 'forward' } });
++ return kit.empty({ icon: 'trophy', title: 'No results yet', text: kind ? `This board fills as models are run and ${JUDGE_SHORT[kind.judge] || 'Bench'} scores them.` : 'This board fills as models are run on Bench.' });
++}
++
++function boardPage(data = {}) {
++ const { kindId, kind, tab, board: boardRead, cost, kinds, benchmarks, rankings = [], runs, overview } = data;
++ const name = kind && kind.name || kindId;
++ const rows = rowsOf(boardRead);
++ const lastByKind = byKindFromOverview(readData(overview) || {});
++ const kindLast = lastByKind.get(kindId) || null;
++ const latest = readData(runs);
++ const latestRows = latest && Array.isArray(latest.runs) ? latest.runs : [];
++ const list = readData(benchmarks);
++ const kindBenchmarks = list && Array.isArray(list.benchmarks) ? list.benchmarks.filter((x) => x.kind === kindId) : [];
++ const byModel = new Map();
++ kindBenchmarks.forEach((b, i) => {
++ const r = readData(rankings[i]);
++ const entries = r && r.rankings && Array.isArray(r.rankings.entries) ? r.rankings.entries : [];
++ for (const e of entries) {
++ if (!byModel.has(e.model)) byModel.set(e.model, { model: e.model, scores: [], costs: [], times: [], runs: 0, last_run: null });
++ const x = byModel.get(e.model);
++ if (value(e.score) != null) x.scores.push(value(e.score));
++ if (value(e.cost_usd) != null) x.costs.push(value(e.cost_usd));
++ if (value(e.ms) != null) x.times.push(value(e.ms));
++ x.runs += Number(e.runs) || 0;
++ if (e.last_run && (!x.last_run || e.last_run > x.last_run)) x.last_run = e.last_run;
++ }
++ });
++ const median = (list) => { const s = list.filter((x) => x != null).sort((a, b) => a - b); if (!s.length) return null; const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
++ const registryRows = [...byModel.values()].map((x) => ({ model: x.model, score: median(x.scores), cost: median(x.costs), speed: median(x.times), runs: x.runs, last_run: x.last_run })).sort((a, b) => (b.score == null ? -1 : b.score) - (a.score == null ? -1 : a.score) || a.model.localeCompare(b.model));
++ const registryTable = registryRows.length ? kit.table({ caption: 'Published runs on this kind', columns: [
++ { key: 'model', label: 'Model', primary: true, html: true }, { key: 'score', label: 'Median score', num: true, html: true },
++ { key: 'runs', label: 'Runs', num: true, fmt: 'int' }, { key: 'cost', label: 'Cost/run', num: true, html: true },
++ { key: 'speed', label: 'Speed/run', num: true, html: true }, { key: 'last', label: 'Last run', html: true },
++ ], rows: registryRows.map((r) => ({ model: modelLink(r.model), score: scoreText(r.score), runs: r.runs, cost: moneyCell(r.cost), speed: benchSpeedCell(r.speed), last: r.last_run ? esc(dayTime(r.last_run)) : '—' })) }) : '';
++ let content;
++ if (!boardRead) content = kit.empty({ icon: 'trophy', title: 'Bench is unavailable', text: 'This board comes back as soon as Bench answers.' });
++ else if (!played(rows) && registryRows.length) content = kit.notice({ kind: 'info', text: `No blind vote on this task kind yet. ${registryRows.length} ${registryRows.length === 1 ? 'model has' : 'models have'} verified runs below.` });
++ else if (!played(rows)) content = emptyFor(kind);
++ else if (tab === 'pareto') {
++ const points = paretoRows(boardRead, cost);
++ content = paretoChart(points) + (points.length ? kit.table({ caption: 'The numbers', columns: [
++ { key: 'm', label: 'Model', primary: true, html: true }, { key: 'q', label: 'Quality', num: true, html: true },
++ { key: 'c', label: 'Cost per solved', num: true, html: true }, { key: 'speed', label: 'Speed/run', num: true, html: true },
++ ], rows: points.map((r) => ({ m: `${modelLink(r.subject)}${r.frontier ? ` ${kit.badge('frontier', 'accent')}` : ''}`, q: benchShare(r.quality), c: usd(r.costPerSolved), speed: benchSpeedCell(r.median_ms) })) }) : '');
++ } else if (tab === 'cost') {
++ const crows = rowsOf(cost).length ? rowsOf(cost) : rows;
++ content = kit.table({ caption: 'Cost per solved task', columns: [
++ { key: 'm', label: 'Model', primary: true, html: true }, { key: 'solved', label: 'Solved', num: true, fmt: 'int' },
++ { key: 'entries', label: 'Entries', num: true, fmt: 'int' }, { key: 'total', label: 'Total cost', num: true, html: true },
++ { key: 'per', label: 'Cost per solved', num: true, html: true }, { key: 'speed', label: 'Speed/run', num: true, html: true },
++ ], rows: crows.map((r) => ({ m: modelLink(r.subject), solved: r.solved, entries: r.entries, total: usd(r.total_cost_usd), per: usd(r.cost_per_solved), speed: benchSpeedCell(r.median_ms) })) });
++ } else {
++ const floor = Math.floor((Math.min(...rows.map((r) => value(r.ci_lower) == null ? value(r.elo) == null ? 0 : value(r.elo) : value(r.ci_lower))) - 25) / 50) * 50;
++ content = kit.rank({ label: `${name}: ranking`, base: floor, fmt: 'int', items: rows.map((r) => {
++ const lo = value(r.ci_lower), hi = value(r.ci_upper), elo = value(r.elo);
++ const costText = value(r.median_cost_usd) != null ? `${usd(r.median_cost_usd)}/run` : null;
++ const speedText = value(r.median_ms) != null ? `${msText(r.median_ms)}/run` : null;
++ return { name: r.subject, href: modelPath(r.subject), value: elo, interval: lo != null && hi != null && elo != null ? Math.round(Math.max(hi - elo, elo - lo)) : null,
++ note: [value(r.games) != null ? `${Number(r.games).toLocaleString('en-US')} games` : '', value(r.win_rate) != null ? `${share(r.win_rate)} won` : '', costText, speedText, value(r.games) != null && Number(r.games) < 10 ? 'provisional' : ''].filter(Boolean).join(' · ') };
++ }) }) + `<p class="ai-fine">${esc(METHOD)}</p>`
++ + kit.table({ caption: 'Quality, price and speed side by side', columns: [
++ { key: 'm', label: 'Model', primary: true, html: true }, { key: 'q', label: 'Quality', num: true, html: true },
++ { key: 'games', label: 'Games', num: true, fmt: 'int' }, { key: 'won', label: 'Won', num: true, html: true },
++ { key: 'cost', label: 'Cost/run', num: true, html: true }, { key: 'speed', label: 'Speed/run', num: true, html: true },
++ ], rows: rows.map((r) => ({ m: modelLink(r.subject), q: `<span title="95% interval ${esc(`${r.ci_lower}–${r.ci_upper}`)}">${esc(r.elo == null ? '—' : Math.round(r.elo))}</span>`, games: r.games, won: benchShare(r.win_rate), cost: moneyCell(r.median_cost_usd), speed: benchSpeedCell(r.median_ms) })) });
++ }
++ const lastRunTable = latestRows.length ? kit.table({
++ caption: 'Latest runs of this kind', columns: [
++ { key: 'model', label: 'Model', primary: true, html: true }, { key: 'score', label: 'Score', num: true, html: true },
++ { key: 'cost', label: 'Cost', num: true, html: true }, { key: 'speed', label: 'Time', num: true, html: true },
++ { key: 'when', label: 'Published', html: true }, { key: 'how', label: 'How', html: true },
++ ],
++ rows: latestRows.map((r) => ({ model: modelLink(r.model), score: scoreCell(r.score), cost: usd(r.cost_usd), speed: benchSpeedCell(r.ms), when: esc(dayTime(r.created_at)), how: provenanceCell(r.provenance) })),
++ }) : '<p class="ai-fine">No run of this task kind has been published yet.</p>';
++ const head = kit.head({ title: name, sub: kind ? `${roomName(kind.room)} · ${howRated(kind)}${kindLast ? ` · ${lastRunText(kindLast)}` : ''}` : 'Bench results', back: { href: '/ai', label: 'AI' },
++ tabs: played(rows) ? kit.tabs({ base: `/ai/${encodeURIComponent(kindId)}`, items: TABS, active: tab, label: 'Board view' }) : '' });
++ const body = head + benchState(data.stale)
++ + `<section aria-label="${esc(TABS.find((t) => t.key === tab)?.label || 'Board')}">${content}</section>`
++ + (registryRows.length ? kit.section({ title: 'Verified runs on this task kind', count: registryRows.length, body: (registryRows.length > 1 ? scoreCostChart(registryRows.map((r) => ({ model: r.model, score: r.score, cost_usd: r.cost, ms: r.speed, runs: r.runs, last_run: r.last_run }))) : '') + registryTable }) : '')
++ + kit.section({ title: 'Latest runs', count: latestRows.length, body: lastRunTable })
++ + `<p class="ai-fine">Download the <a href="${BENCH}/api/v1/datasets/${encodeURIComponent(kindId)}.jsonl">dataset JSONL</a> from Bench.</p>`;
++ return page(`${name}: AI`, `${name}: which AI does it best, what it costs and how fast it is.`, body, `/ai/${encodeURIComponent(kindId)}`);
++}
++
++function benchmarkPage(data = {}) {
++ const b = readData(data.benchmark);
++ const rank = readData(data.rankings);
++ const latest = readData(data.runs);
++ const entries = rank && rank.rankings && Array.isArray(rank.rankings.entries) ? rank.rankings.entries : [];
++ const latestRows = latest && Array.isArray(latest.runs) ? latest.runs : [];
++ const meta = b && b.benchmark ? b.benchmark : null;
++ const definition = b && b.definition ? b.definition : null;
++ const tasks = definition && Array.isArray(definition.tasks) ? definition.tasks : [];
++ const version = b && b.version ? b.version.version : null;
++ const last = entries.reduce((at, e) => (!at || (e.last_run && e.last_run > at) ? e.last_run : at), null) || (latestRows[0] && latestRows[0].created_at) || null;
++ if (!meta) {
++ const body = kit.head({ title: 'Benchmark', sub: 'Bench results', back: { href: '/ai', label: 'AI' }, sticky: false })
++ + benchState(data.stale) + kit.empty({ icon: 'trophy', title: 'Bench is unavailable', text: 'This benchmark comes back as soon as Bench answers.' });
++ return page('AI benchmark', 'A benchmark of AI runs from OpenVibe.Bench.', body, '/ai');
++ }
++ const chart = scoreCostChart(entries);
++ const leaderboardTable = entries.length ? kit.table({
++ caption: 'Verified results', columns: [
++ { key: 'model', label: 'Model', primary: true, html: true }, { key: 'score', label: 'Score', num: true, html: true },
++ { key: 'runs', label: 'Runs', num: true, fmt: 'int' }, { key: 'range', label: 'Best / worst', num: true, html: true },
++ { key: 'cost', label: 'Cost/run', num: true, html: true }, { key: 'speed', label: 'Speed/run', num: true, html: true },
++ { key: 'solved', label: 'Cost per solved', num: true, html: true }, { key: 'last', label: 'Last run', html: true },
++ ],
++ rows: entries.map((e) => ({
++ model: modelLink(e.model), score: scoreCell(e.score), runs: e.runs,
++ range: `${scoreCell(e.max_score)} / ${scoreCell(e.min_score)}`, cost: moneyCell(e.cost_usd), speed: benchSpeedCell(e.ms),
++ solved: moneyCell(e.cost_per_solved), last: e.last_run ? esc(dayTime(e.last_run)) : '—',
++ })),
++ }) : kit.empty({ icon: 'trophy', title: 'No verified results yet', text: 'This benchmark has no verified run yet. Submitted runs are labelled on the latest-runs list and never mixed into the ranking.' });
++ const latestTable = latestRows.length ? kit.table({
++ caption: 'Latest runs', columns: [
++ { key: 'model', label: 'Model', primary: true, html: true }, { key: 'score', label: 'Score', num: true, html: true },
++ { key: 'cost', label: 'Cost', num: true, html: true }, { key: 'speed', label: 'Time', num: true, html: true },
++ { key: 'when', label: 'Published', html: true }, { key: 'how', label: 'How', html: true },
++ ],
++ rows: latestRows.map((r) => ({ model: modelLink(r.model), score: scoreCell(r.score), cost: usd(r.cost_usd), speed: benchSpeedCell(r.ms), when: esc(dayTime(r.created_at)), how: provenanceCell(r.provenance) })),
++ }) : '<p class="ai-fine">No run has been published on this benchmark yet.</p>';
++ const body = kit.head({ title: meta.title, sub: `${meta.kind || 'Benchmark'}${version ? ` · version ${version}` : ''} · ${tasks.length} ${tasks.length === 1 ? 'task' : 'tasks'} · ${lastRunText(last)}`, back: { href: '/ai', label: 'AI' }, sticky: false })
++ + benchState(data.stale)
++ + (meta.description ? `<p class="ai-lede">${esc(meta.description)}</p>` : '')
++ + kit.stats([[entries.length, entries.length === 1 ? 'model ranked' : 'models ranked'], [latestRows.length, 'latest runs'], [tasks.length, tasks.length === 1 ? 'task' : 'tasks'], [last ? dayTime(last) : 'No runs yet', 'last run']], { label: `${meta.title} in numbers` })
++ + chart + leaderboardTable
++ + kit.section({ title: 'Latest runs', count: latestRows.length, body: latestTable })
++ + `<p class="ai-fine"><a href="/ai/method">How the runs are judged</a> · <a href="/ai/compare?models=${entries.slice(0, 3).map((e) => encodeURIComponent(e.model)).join(',')}">Compare the leaders</a> · <a href="${BENCH}/api/v1/datasets/benchmarks.jsonl">Bench's open data</a></p>`;
++ return page(`${meta.title}: AI benchmark`, `${meta.title}: verified AI results, cost and speed from OpenVibe.Bench.`, body, `/ai/${encodeURIComponent(meta.id)}`);
++}
++
++function methodPage(data = {}) {
++ const o = readData(data.overview) || {};
++ const list = data.kinds && data.kinds.data && Array.isArray(data.kinds.data.kinds) ? data.kinds.data.kinds : [];
++ const byJudge = new Map();
++ for (const k of list) {
++ if (!byJudge.has(k.judge)) byJudge.set(k.judge, []);
++ byJudge.get(k.judge).push(k);
++ }
++ const judgeRows = [...byJudge.entries()].map(([judge, ks]) => ({
++ how: `<b>${esc(JUDGE_SHORT[judge] || judge)}</b>`, judge: esc(judge), jobs: esc(ks.map((k) => k.name || k.id).join(', ')),
++ }));
++ const body = kit.head({ title: 'How the AI results are judged', sub: 'The method behind every score, price and speed on these pages', back: { href: '/ai', label: 'AI' }, sticky: false })
++ + benchState(data.stale)
++ + `<section class="ai-method"><h2>What a score means</h2>
++<p>A score is a run’s result on one benchmark, from 0 to 100. Bench’s frozen benchmark version names its tasks, its judges and how the task scores combine; the default is the mean of the task scores. A model’s score on a benchmark is the median of its <b>verified</b> runs, so one bad run does not decide the page. The best and worst columns are the runs themselves, not a second estimate.</p>
++<h2>Quality, price and speed</h2>
++<p><b>Quality</b> is that score. <b>Price</b> is the median cost of a run in US dollars, as the service that ran it published it; <b>cost per solved</b> is the run’s cost divided by its success rate (score ÷ 100), so it is the price of one solved task. <b>Cost per solved</b> is the same figure, named plainly. <b>Speed</b> is the median wall-clock time of a run. The quality-against-cost chart marks the frontier: the models nothing beats on both.</p>
++<h2>How runs are judged</h2>
++<p>Each task kind has one main judge. A benchmark may also name more judges in its definition; the exact list is in its public definition at <code>/api/v1/benchmarks/:id</code>.</p>
++${judgeRows.length ? kit.table({ caption: 'Judges in use', columns: [{ key: 'how', label: 'How', html: true }, { key: 'judge', label: 'Bench judge' }, { key: 'jobs', label: 'Task kinds' }], rows: judgeRows }) : `<p class="ai-fine">${esc('Bench did not answer its kind list; the judge rules above are its published contract.')}</p>`}
++<h2>Verified and submitted</h2>
++<p>A <b>verified</b> run was executed by a service (openvibe.codes, Run, a Node worker); a <b>submitted</b> run was posted with a person’s token. The rankings read verified runs only, and a submitted run is labelled wherever the latest runs are listed.</p>
++<h2>How often, and what is missing</h2>
++<p>${o.runs == null ? 'Bench did not answer the public counts.' : `${Number(o.benchmarks || 0).toLocaleString('en-US')} public ${o.benchmarks === 1 ? 'benchmark' : 'benchmarks'}, ${Number(o.tasks || 0).toLocaleString('en-US')} ${o.tasks === 1 ? 'task' : 'tasks'}, ${Number(o.runs || 0).toLocaleString('en-US')} ${o.runs === 1 ? 'run' : 'runs'} from ${Number(o.models || 0).toLocaleString('en-US')} ${o.models === 1 ? 'model' : 'models'}; the newest run was ${o.last_run ? esc(dayTime(o.last_run)) : 'never published'}.`}</p>
++<p class="ai-fine">A model with no verified run has no score here; a benchmark with no run says so. The public reads are Bench’s API at <a href="${BENCH}/docs">bench.openvibe.services/docs</a>; the open data is <a href="${BENCH}/api/v1/datasets/benchmarks.jsonl">benchmarks.jsonl</a> and the per-kind datasets. <a href="/ai/feed.xml">New results (RSS)</a> · <a href="https://openvibe.report/api/v1/ai/results">the same as JSON</a>.</p>`;
++ return page('How the AI results are judged', 'The method behind OpenVibe.Bench’s scores, costs and speeds, and how to read them.', body, '/ai/method');
++}
++
++// ── /ai/feed.xml ──
++function rss({ runs, overview, base = 'https://openvibe.compare' } = {}) {
++ const xml = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++ const list = runs && runs.data && Array.isArray(runs.data.runs) ? runs.data.runs : [];
++ const titles = new Map((overview && overview.data && Array.isArray(overview.data.list) ? overview.data.list : []).map((b) => [b.id, b.title]));
++ const site = String(base || 'https://openvibe.compare').replace(/\/+$/, '');
++ const unavailable = runs && runs.data ? '' : ' OpenVibe.Bench did not answer; no runs are listed.';
++ const items = list.map((r) => {
++ const title = `${r.model} on ${titles.get(r.benchmark_id) || r.benchmark_id}`;
++ const link = `${site}/ai/${encodeURIComponent(r.benchmark_id)}`;
++ const score = r.score == null ? 'no score' : `${Math.round(Number(r.score) * 10) / 10}/100`;
++ const cost = r.cost_usd == null ? 'cost not given' : `$${Number(r.cost_usd).toFixed(4).replace(/0+$/, '').replace(/\.$/, '')}`;
++ const speed = r.ms == null ? 'time not given' : `${Math.round(Number(r.ms))} ms`;
++ const at = Date.parse(r.created_at);
++ return `<item><title>${xml(title)}</title><link>${xml(link)}</link><guid isPermaLink="false">${xml(r.id)}</guid>${Number.isFinite(at) ? `<pubDate>${new Date(at).toUTCString()}</pubDate>` : ''}<description>${xml(`${r.model} scored ${score} on ${titles.get(r.benchmark_id) || r.benchmark_id}, for ${cost} in ${speed} (${r.provenance || 'run'}).`)}</description></item>`;
++ }).join('');
++ const newest = list.length ? Date.parse(list[0].created_at) : NaN;
++ return `<?xml version="1.0" encoding="UTF-8"?>
++<rss version="2.0"><channel><title>OpenVibe AI: new results</title><link>${xml(`${site}/ai`)}</link><description>Verified and submitted AI runs as OpenVibe.Bench publishes them.${xml(unavailable)}</description><language>en</language>${Number.isFinite(newest) ? `<lastBuildDate>${new Date(newest).toUTCString()}</lastBuildDate>` : ''}${items}</channel></rss>`;
++}
++
++module.exports = {
++ hub, leaderboard, providers, model, compare, arena, boardPage, benchmarkPage, methodPage, rss,
++ availabilityBlock, measuredNote, benchState, page, scriptLd, PARTS_CSS,
++};
ddiff --git a/server/app.js b/server/app.js
iindex 837402e..695d3d8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -4,9 +4,11 @@ const path=require('node:path');
const {createData}=require('./data');
const {createCompute,api:computeApi}=require('./compute');
const computeViews=require('./compute-views');
++const {createAi}=require('./ai-data');
++const aiViews=require('./ai-views');
const v=require('./views');
--const origins=new Set(require('./origins.json'));
--function createApp({getData=createData(),getCompute=createCompute()}={}) {
++const origins=new Set(require('./origins.json'));
++function createApp({getData=createData(),getCompute=createCompute(),ai=createAi()}={}) {
const app=express();app.disable('x-powered-by');
app.use((req,res,next)=>{res.set('X-Content-Type-Options','nosniff');res.set('Referrer-Policy','strict-origin-when-cross-origin');next();});
app.use(express.static(path.join(__dirname,'../public')));
@@@ -24,28 +26,57 @@ function createApp({getData=createData(),getCompute=createCompute()}={}) {
app.get('/api/v1/compute',wrap(async(req,res)=>res.json(computeApi(await getCompute()))));
app.get('/api/v1/comparisons',wrap(async(req,res)=>res.json({comparisons:await getData()})));
app.get('/api/v1/comparisons/:slug',wrap(async(req,res)=>{const c=(await getData()).find(c=>c.slug===req.params.slug);return c?res.json(c):res.status(404).json({error:'comparison.not_found'});}));
-- app.get('/robots.txt',(_q,r)=>r.type('text').send('User-agent: *\nAllow: /\nSitemap: https://openvibe.compare/sitemap.xml\n'));
-- app.get('/llms.txt',wrap(async(_q,r)=>{const [cs,compute]=await Promise.all([getData(),getCompute()]);return r.type('text').send('# OpenVibe.Compare\n\nDated comparisons. Missing evidence is not zero.\n\n'+`- [Compute prices](https://openvibe.compare/compute): GPUs and computers from OpenVibe.Exchange, per hour, per GPU GB-hour and per month at 24/7${compute.checked_at?`; checked ${compute.checked_at.slice(0,10)}`:''}.\n`+cs.map(c=>`- [${c.title}](https://openvibe.compare/comparisons/${c.slug}): ${c.summary}`).join('\n')+'\n\nJSON: https://openvibe.compare/api/v1/comparisons\nCompute JSON: https://openvibe.compare/api/v1/compute\nEach page also accepts Accept: text/markdown.\n')}));
++ app.get('/robots.txt',(_q,r)=>r.type('text').send('User-agent: *\nAllow: /\nAllow: /ai\nSitemap: https://openvibe.compare/sitemap.xml\n'));
++ app.get('/llms.txt',wrap(async(_q,r)=>{const [cs,compute,aiData]=await Promise.all([getData(),getCompute(),ai.comparison()]);return r.type('text').send('# OpenVibe.Compare\n\nDated comparisons. Missing evidence is not zero.\n\n'+`- [Compute prices](https://openvibe.compare/compute): GPUs and computers from OpenVibe.Exchange, per hour, per GPU GB-hour and per month at 24/7${compute.checked_at?`; checked ${compute.checked_at.slice(0,10)}`:''}.\n`+`- [AI model leaderboard](https://openvibe.compare/ai): ratings from blind votes, provider prices and measured speed.\n- [AI leaderboard](https://openvibe.compare/ai/leaderboard): every model with filters and sorts.\n- [AI providers](https://openvibe.compare/ai/providers): every provider offer with price, reply time, errors and uptime.\n- [AI method](https://openvibe.compare/ai/method): how a score, a cost and a speed are made.\n- [AI results feed](https://openvibe.compare/ai/feed.xml): new public Bench runs as RSS.\n`+aiData.rows.map(c=>`- [${c.id}](https://openvibe.compare/ai/models/${encodeURIComponent(c.id)}): rating, prices and measured speed.`).join('\n')+'\n'+cs.map(c=>`- [${c.title}](https://openvibe.compare${c.slug==='ai-models'?'/ai':`/comparisons/${c.slug}`}): ${c.summary}`).join('\n')+'\n\nJSON: https://openvibe.compare/api/v1/comparisons\nCompute JSON: https://openvibe.compare/api/v1/compute\nEach page also accepts Accept: text/markdown.\n')}));
app.get('/sitemap.xml',wrap(async(_q,r)=>{
-- const [cs,compute]=await Promise.all([getData(),getCompute()]);const newest=cs.map(c=>c.updated_at).sort().at(-1);const paths=new Map([['/',newest],['/compute',compute.checked_at||newest]]);
-- for(const c of cs){paths.set(`/comparisons/${c.slug}`,c.updated_at);for(const t of c.things)paths.set(v.thingPath(t.id),c.updated_at);}
++ const [cs,compute,aiData]=await Promise.all([getData(),getCompute(),ai.comparison()]);const newest=cs.map(c=>c.updated_at).sort().at(-1);const paths=new Map([['/',newest],['/compute',compute.checked_at||newest],['/ai',aiData.updated_at],['/ai/leaderboard',aiData.updated_at],['/ai/providers',aiData.updated_at],['/ai/method',aiData.updated_at],['/ai/arena',aiData.updated_at]]);
++ for(const c of cs){if(c.slug==='ai-models')continue;paths.set(`/comparisons/${c.slug}`,c.updated_at);for(const t of c.things)paths.set(v.thingPath(t.id),c.updated_at);}
++ for(const row of aiData.rows)paths.set(`/ai/models/${encodeURIComponent(row.id)}`,row.source&&row.source.date||aiData.updated_at);
++ for(const task of aiData.tasks)paths.set(`/ai/${encodeURIComponent(task.id)}`,aiData.updated_at);
r.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+[...paths].map(([p,d])=>`<url><loc>https://openvibe.compare${v.esc(p)}</loc><lastmod>${v.esc(d)}</lastmod></url>`).join('')+'</urlset>');
}));
app.get('/',wrap(async(req,res)=>{
const [all,compute]=await Promise.all([getData(),getCompute()]);res.vary('Accept');
-- if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send('# Compare anything\n\n- [Compute prices](/compute): GPUs and computers from OpenVibe.Exchange, per hour, per GPU GB-hour and per month.\n'+all.map(c=>`- [${c.title}](/comparisons/${c.slug}): ${c.summary}`).join('\n'));
++ if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send('# Compare anything\n\n- [Compute prices](/compute): GPUs and computers from OpenVibe.Exchange, per hour, per GPU GB-hour and per month.\n- [AI model leaderboard](/ai): ratings from blind votes, provider prices and measured speed.\n'+all.map(c=>`- [${c.title}](${c.slug==='ai-models'?'/ai':`/comparisons/${c.slug}`}): ${c.summary}`).join('\n'));
res.send(v.page('Compare anything',v.home(all,compute,String(req.query.q||'').slice(0,120),String(req.query.task||'balanced'),String(req.query.budget||'').slice(0,20))));
}));
app.get('/compute',wrap(async(req,res)=>{const compute=await getCompute();res.vary('Accept');if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send(computeViews.markdown(compute,req.query));res.send(v.page('Compute prices',computeViews.body(compute,req.query),'/compute',[computeViews.jsonLd(compute)]));}));
++ app.get('/ai',wrap(async(_q,r)=>r.send(aiViews.hub(await ai.comparison()))));
++ app.get('/ai/leaderboard',wrap(async(req,res)=>res.send(aiViews.leaderboard(await ai.leaderboard(req.query)))));
++ app.get('/ai/providers',wrap(async(_q,r)=>{const data=await ai.comparison();return r.send(aiViews.providers(data));}));
++ app.get('/ai/arena',(_q,r)=>r.send(aiViews.arena()));
++ app.get('/ai/compare',wrap(async(req,res)=>{
++ // The picker sends a and b; older links send models=a,b.
++ const picked=[req.query.a,req.query.b].map(x=>String(x||'').trim()).filter(Boolean);
++ const models=picked.length?picked:String(req.query.models||'').split(',').map(x=>x.trim()).filter(Boolean);
++ if(models.length===2){const ordered=[...models].sort();return res.redirect(301,`/ai/compare/${encodeURIComponent(ordered[0])}-vs-${encodeURIComponent(ordered[1])}`);}
++ return res.send(aiViews.compare(await ai.compare(models.slice(0,3))));
++ }));
++ app.get('/ai/compare/:pair',wrap(async(req,res)=>{
++ const match=String(req.params.pair).match(/^(.+)-vs-(.+)$/);
++ if(!match)return res.sendStatus(404);
++ const ordered=[decodeURIComponent(match[1]),decodeURIComponent(match[2])].sort();
++ const canonical=`/ai/compare/${encodeURIComponent(ordered[0])}-vs-${encodeURIComponent(ordered[1])}`;
++ if(req.path!==canonical)return res.redirect(301,canonical);
++ return res.send(aiViews.compare(await ai.compare(ordered)));
++ }));
++ app.get('/ai/models/:model',wrap(async(req,res)=>{const data=await ai.modelPage(req.params.model);if(!data)return res.sendStatus(404);return res.send(aiViews.model(data));}));
++ app.get('/ai/method',wrap(async(req,res)=>res.send(aiViews.methodPage(await ai.method()))));
++ app.get('/ai/feed.xml',wrap(async(req,res)=>{const feed=await ai.feed();res.status(200).set('Cache-Control','public, max-age=120').type('application/rss+xml; charset=utf-8').send(aiViews.rss(feed));}));
++ app.get('/ai/:ref',wrap(async(req,res)=>{const ref=String(req.params.ref||'');const bench=ai.isBenchmarkId(ref);const data=bench?await ai.benchmarkPage(ref):await ai.kindBoard(ref,ai.normaliseTab(req.query.tab));if(!data)return res.sendStatus(404);return res.send(bench?aiViews.benchmarkPage(data):aiViews.boardPage(data));}));
++ app.get('/comparisons/ai-models',(_q,r)=>r.redirect(301,'/ai'));
app.get('/comparisons/:slug',wrap(async(req,res)=>{
const c=(await getData()).find(c=>c.slug===req.params.slug);if(!c)return res.sendStatus(404);res.vary('Accept');
if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send(v.markdown(c));
const weights=Object.fromEntries(c.criteria.filter(k=>req.query[`w.${k.id}`]!==undefined).map(k=>[k.id,req.query[`w.${k.id}`]]));
res.send(v.page(c.title,v.comparison(c,weights),req.path,[{'@context':'https://schema.org','@type':'Dataset',name:c.title,description:c.summary,url:`https://openvibe.compare${req.path}`,dateModified:c.updated_at,creator:{'@type':'Organization',name:'OpenVibe'},distribution:{'@type':'DataDownload',encodingFormat:'application/json',contentUrl:`https://openvibe.compare/api/v1/comparisons/${c.slug}`}}]));
}));
-- app.get('/models/:org/:model',(req,res)=>res.redirect(301,v.thingPath(`${req.params.org}/${req.params.model}`)));
++ app.get('/models/:org/:model',(req,res)=>res.redirect(301,`/ai/models/${encodeURIComponent(`${req.params.org}/${req.params.model}`)}`));
app.get('/things/:id',wrap(async(req,res)=>{
-- const cs=(await getData()).filter(c=>c.things.some(t=>t.id===req.params.id));if(!cs.length)return res.sendStatus(404);
++ const all=await getData();
++ const aiModels=all.find(c=>c.slug==='ai-models');
++ if(aiModels && aiModels.things.some(t=>t.id===req.params.id))return res.redirect(301,`/ai/models/${encodeURIComponent(req.params.id)}`);
++ const cs=all.filter(c=>c.things.some(t=>t.id===req.params.id));if(!cs.length)return res.sendStatus(404);
const t=cs[0].things.find(t=>t.id===req.params.id);res.vary('Accept');
if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send(cs.map(c=>v.markdown({...c,things:[t]})).join('\n'));
res.send(v.page(t.name,`<h1>${v.esc(t.name)}</h1><p>${v.esc(t.maker)}</p><a href="${v.esc(t.url)}">Original listing</a>${cs.map(c=>`<h2><a href="/comparisons/${c.slug}">${v.esc(c.title)}</a></h2><dl>${c.criteria.map(k=>`<dt>${v.esc(k.label)} (${v.esc(k.unit)})</dt><dd>${v.number(c.results.find(r=>r.thing===t.id && r.criterion===k.id),k)}</dd>`).join('')}</dl><p>${v.esc(c.method)}</p>`).join('')}`,req.path));
ddiff --git a/server/data.js b/server/data.js
iindex 1f67990..4fa4082 100644
---- a/server/data.js
++++ b/server/data.js
@@@ -31,7 +31,7 @@ function measuredRows(feed) {
return rows;
}
function measuredOf(rows,id,o) {
-- return rows.get(id) || (o.provider_model ? rows.get(o.provider_model) : null) || null;
++ return rows.get(id) || (o.provider_model ? rows.get(o.provider_model) : null) || (o.model ? rows.get(o.model) : null) || null;
}
function build(feeds, at) {
const models=base('ai-models','AI models','ai.models','Provider prices and measured Bench results. Unmeasured quality stays unknown.',at);
@@@ -114,4 +114,4 @@ function createData({fetchImpl=fetch,now=Date.now,ttl=600000}={}) {
return pending;
};
}
--module.exports={build,createData,ENDPOINTS};
++module.exports={build,createData,ENDPOINTS,measuredRows,measuredOf};
ddiff --git a/server/views.js b/server/views.js
iindex fe7a4a5..b653acc 100644
---- a/server/views.js
++++ b/server/views.js
@@@ -47,8 +47,8 @@ function home(all,compute,q='',task='balanced',budget='') {
<p>Task presets adjust preferences. The available arena data does not establish task-specific coding or writing quality. Budget applies to output tokens, not monthly spend.</p>
<div class="picks">${picks.map(({thing:t})=>`<article class="ovk-card"><h2><a href="${thingPath(t.id)}">${esc(t.name)}</a></h2><p>Fits your output-price budget and ranks under these weights${c.results.some(r=>r.criterion==='quality')?'':'; quality has not been measured'}.</p><p>Output price (${esc(c.criteria.find(k=>k.id==='output').unit)}): ${number(c.results.find(r=>r.thing===t.id&&r.criterion==='output'))}</p></article>`).join('')||'<p>No measured picks fit this budget.</p>'}</div>
<form><label>Search comparisons <input name="q" value="${esc(q)}" type="search"></label><button class="ovk-btn ovk-btn--quiet">Search</button></form>
-- <div class="comparison-list">${computeCard(compute,q)}${all.filter(c=>(c.title+' '+c.summary).toLowerCase().includes(q.toLowerCase())).map(c=>`<article class="ovk-card"><h2><a href="/comparisons/${c.slug}">${esc(c.title)}</a></h2><p>${esc(c.summary)}</p>${rank(c).filter(r=>r.score!==null).slice(0,3).map(r=>`<div>${esc(r.thing.name)} <meter min="0" max="100" value="${r.score}">${r.score.toFixed(1)}</meter> ${score(r.score,c)}</div>`).join('') || '<p>No comparable measurements yet.</p>'}<p>${c.things.length} things · ${c.results.length} dated results</p></article>`).join('')}</div><section id="method"><h2>How picks are ranked</h2><p>${esc(c?.method || '')}</p></section>`;
++ <div class="comparison-list">${computeCard(compute,q)}${all.filter(c=>(c.title+' '+c.summary).toLowerCase().includes(q.toLowerCase())).map(c=>`<article class="ovk-card"><h2><a href="${c.slug==='ai-models'?'/ai':`/comparisons/${c.slug}`}">${esc(c.title)}</a></h2><p>${esc(c.summary)}</p>${rank(c).filter(r=>r.score!==null).slice(0,3).map(r=>`<div>${esc(r.thing.name)} <meter min="0" max="100" value="${r.score}">${r.score.toFixed(1)}</meter> ${score(r.score,c)}</div>`).join('') || '<p>No comparable measurements yet.</p>'}<p>${c.things.length} things · ${c.results.length} dated results</p></article>`).join('')}</div><section id="method"><h2>How picks are ranked</h2><p>${esc(c?.method || '')}</p></section>`;
}
function markdown(c){return `# ${c.title}\n\n${c.summary}\n\nUpdated: ${c.updated_at}\n\n`+c.things.map(t=>`## ${t.name}\n\n`+c.criteria.map(k=>{const r=c.results.find(r=>r.thing===t.id&&r.criterion===k.id);if(!r)return `- ${k.label}: ${word(k,null)||'Not measured'}`;const shown=word(k,r.value)||`${r.value} ${k.unit}`;return `- ${k.label}: ${shown} — ${r.as_of} — [${r.source.label}](${r.source.url})`;}).join('\n')).join('\n\n')+`\n\n## Method\n\n${c.method}\n`;}
--function page(title,body,path='/',ld=[]) {return shell.page({name:'OpenVibe.Compare',siteName:'OpenVibe.Compare',title,description:'Compare things with dated sources, transparent methods and your own priorities.',canonical:`https://openvibe.compare${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'compare',apiBase:'https://openvibe.network',links:[{label:'Comparisons',href:'/'},{label:'Compute',href:'/compute'}]},footer:{service:'compare',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}"><link rel="stylesheet" href="/compare.css"><script src="${serve.url('ov-metrics.js')}" defer></script><script src="/compare-account.js" defer></script>`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}
++function page(title,body,path='/',ld=[],opts={}) {return shell.page({name:'OpenVibe.Compare',siteName:'OpenVibe.Compare',title,description:opts.description||'Compare things with dated sources, transparent methods and your own priorities.',canonical:`https://openvibe.compare${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'compare',apiBase:'https://openvibe.network',links:[{label:'Comparisons',href:'/'},{label:'AI',href:'/ai'},{label:'Compute',href:'/compute'}]},footer:{service:'compare',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}"><link rel="stylesheet" href="/compare.css">${opts.head||''}<script src="${serve.url('ov-metrics.js')}" defer></script><script src="/compare-account.js" defer></script>`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}
module.exports={comparison,home,page,markdown,esc,thingPath,number,computeCard};
ddiff --git a/test/compare.test.js b/test/compare.test.js
iindex 9b969cc..4594200 100644
---- a/test/compare.test.js
++++ b/test/compare.test.js
@@@ -1,6 +1,7 @@
'use strict';
const test=require('node:test'),assert=require('node:assert/strict');
const {build,createData,ENDPOINTS}=require('../server/data');
++const {createAi}=require('../server/ai-data');
const {rank}=require('../public/rank');
const at='2026-11-10T00:00:00.000Z';
const fixture={market:{offers:[1,2,3].map(n=>({model:`maker/m${n}`,provider_model:`m${n}`,name:`Model ${n}`,kind:'chat',price:{in:n,out:n*2,currency:'USD',per:'1M tokens'},price_source:'https://example.com/prices',price_checked_at:at}))},harnesses:{harnesses:[{id:'codex',name:'Codex',provider:'openai'}]},runs:{runs:[]},plans:{plans:[]},quality:{board:{rows:[]}}};
@@@ -24,14 +25,16 @@ test('cache coalesces requests, lasts ten minutes, and expires without fabricati
const fail=createData({fetchImpl:async()=>{throw Error('offline');}});assert((await fail()).every(c=>c.results.length===0&&c.availability.length));
});
test('API, phone/table markup, URL weights, Markdown, Dataset, discovery and CORS',async()=>{
-- const {createApp}=require('../server/app');const server=createApp({getData:async()=>data}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));const base=`http://127.0.0.1:${server.address().port}`;
++ const {createApp}=require('../server/app');const ai=await makeAi();const server=createApp({getData:async()=>data,ai}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));const base=`http://127.0.0.1:${server.address().port}`;
try {
let r=await fetch(base+'/api/v1/comparisons',{headers:{Origin:'https://openvibe.network'}});assert.equal(r.status,200);assert.equal(r.headers.get('access-control-allow-origin'),'https://openvibe.network');assert.equal((await r.json()).comparisons.length,3);
r=await fetch(base+'/api/v1/comparisons',{headers:{Origin:'https://openvibe.network.evil.com'}});assert.equal(r.headers.get('access-control-allow-origin'),null);
-- const html=await (await fetch(base+'/comparisons/ai-models?w.input=0')).text();assert.match(html,/phone-cards/);assert.match(html,/desktop-table/);assert.match(html,/<table/);assert.match(html,/value="0" data-weight="input"/);assert.match(html,/Dataset/);assert.match(html,/dateModified/);assert.match(html,/2026-11-10/);assert.match(html,/https:\/\/example.com\/prices/);
-- const md=await (await fetch(base+'/comparisons/ai-models',{headers:{Accept:'text/markdown'}})).text();assert.match(md,/# AI models/);assert.match(md,/2026-11-10.*example.com/);
-- for(const url of ['/','/things/maker%2Fm1','/llms.txt','/sitemap.xml','/robots.txt','/api/v1/comparisons/ai-models'])assert.equal((await fetch(base+url)).status,200,url);
-- const thing=await (await fetch(base+'/things/maker%2Fm1')).text();assert.match(thing,/<details class="evidence">/);assert.match(thing,/example.com\/prices/);
++ const html=require('../server/views').comparison(data[0],{input:0});assert.match(html,/phone-cards/);assert.match(html,/desktop-table/);assert.match(html,/<table/);assert.match(html,/value="0" data-weight="input"/);assert.match(html,/2026-11-10/);assert.match(html,/https:\/\/example.com\/prices/);
++ const page=await (await fetch(base+'/comparisons/openvibe-plans')).text();assert.match(page,/Dataset/);assert.match(page,/dateModified/);
++ const md=await (await fetch(base+'/comparisons/openvibe-plans',{headers:{Accept:'text/markdown'}})).text();assert.match(md,/# OpenVibe plans/);
++ for(const url of ['/','/things/codex','/llms.txt','/sitemap.xml','/robots.txt','/api/v1/comparisons/ai-models'])assert.equal((await fetch(base+url)).status,200,url);
++ const home=await (await fetch(base+'/')).text();assert.match(home,/<h2><a href="\/ai">AI models<\/a><\/h2>/);
++ const thing=await (await fetch(base+'/things/codex')).text();assert.match(thing,/Codex/);assert.match(thing,/openvibe.codes/);
assert.equal((await fetch(base+'/api/v1/comparisons/nope')).status,404);
}finally{await new Promise(r=>server.close(r));}
});
@@@ -42,6 +45,38 @@ test('upstream adapters preserve cohorts, observation dates and unknown quality'
const [models,agents,plans]=build(f,at);assert(!models.results.some(r=>r.criterion==='quality'));assert.equal(models.results.find(r=>r.criterion==='speed').value,12);assert.equal(agents.results.length,1);assert.equal(agents.results[0].value,70);assert.equal(agents.results[0].as_of,at);assert.match(agents.results[0].source.label,/1.2/);assert.equal(plans.results.find(r=>r.criterion==='package-price').value,1.5);assert.equal(plans.results.find(r=>r.criterion==='membership-price').value,4.99);
});
++const BMK='bmk_'+'0'.repeat(26);
++const aiAt='2026-11-13T00:00:00.000Z';
++const aiCatalogue={generated_at:aiAt,models:[
++ {slug:'maker/alpha',canonical:'maker/alpha',name:'alpha',kind:'chat',maker:'Maker',open_weights:false,offers:[{provider:'maker',provider_name:'Maker',model:'alpha',kind:'chat',price:{in:1,out:2,currency:'USD',per:'1M tokens'},price_source:'https://example.com/alpha',price_checked_at:aiAt,requests_7d:30,speed:{p50_ms:500,p95_ms:900},uptime:0.99,error_rate:0.01,routed:true}]},
++ {slug:'maker/beta',canonical:'maker/beta',name:'beta',kind:'chat',maker:'Maker',open_weights:true,offers:[]}
++]};
++const aiMarket={generated_at:aiAt,next_cursor:null,offers:[{model:'alpha',provider_model:'alpha',name:'Alpha',kind:'chat',maker:'Maker',price:{in:1,out:2,currency:'USD',per:'1M tokens'},price_source:'https://example.com/alpha',price_checked_at:aiAt,routed:true,requests_7d:30,speed:{p50_ms:500,p95_ms:900},uptime:0.99,error_rate:0.01}]};
++const aiPerf={range:{from:'2026-10-15',to:'2026-11-13'},rows:[{key:'maker/alpha',calls:30,success_rate:0.98,ttft_ms:{p50:240},tps:{p50:45.2}}]};
++const aiKind={id:'chat.arena',room:'exchange',judge:'human_pairwise',name:'Exchange arena'};
++const aiBoardRow={subject:'maker/alpha',elo:1100,ci_lower:1050,ci_upper:1150,games:30,win_rate:0.6,median_cost_usd:0.02,median_ms:1200,score:80};
++function aiResponse(body){return {ok:true,status:200,headers:{get:()=> 'application/json'},text:async()=>JSON.stringify(body),json:async()=>body};}
++function aiStubFetch({benchDown=false,perfDown=false,marketDown=false}={}) {
++ return async (url)=>{
++ const p=new URL(url).pathname;
++ if(benchDown)throw Error('offline');
++ if(p==='/api/v1/exchange/catalog')return aiResponse(aiCatalogue);
++ if(p==='/api/v1/exchange/market'){if(marketDown)throw Error('offline');return aiResponse(aiMarket);}
++ if(p==='/api/v1/ai/performance'){if(perfDown)throw Error('offline');return aiResponse(aiPerf);}
++ if(p==='/api/v1/kinds')return aiResponse({count:1,kinds:[aiKind]});
++ if(p.startsWith('/api/v1/ratings/'))return aiResponse({board:{kind_id:'chat.arena',rows:[aiBoardRow]}});
++ if(p.startsWith('/api/v1/boards/'))return aiResponse({board:{kind_id:'chat.arena',type:'pareto',rows:[{subject:'maker/alpha',quality:0.8,median_ms:1200}]}});
++ if(p==='/api/v1/benchmarks')return aiResponse({benchmarks:[{id:BMK,title:'Bench A',kind:'chat.arena',runs:1,last_run:aiAt}]});
++ if(p==='/api/v1/benchmarks/'+BMK)return aiResponse({benchmark:{id:BMK,title:'Bench A',kind:'chat.arena'},version:{version:'1'},definition:{tasks:[{id:'t1'}]}});
++ if(p==='/api/v1/rankings')return aiResponse({rankings:{entries:[{model:'maker/alpha',score:80,cost_usd:0.02,ms:1200,runs:1,last_run:aiAt}]}});
++ if(p==='/api/v1/runs')return aiResponse({runs:[{id:'run-1',benchmark_id:BMK,model:'maker/alpha',score:80,cost_usd:0.02,ms:1200,created_at:aiAt,provenance:'verified'}]});
++ if(p==='/api/v1/overview')return aiResponse({benchmarks:1,tasks:1,runs:1,models:1,last_run:aiAt,list:[{id:BMK,title:'Bench A',kind:'chat.arena',runs:1,last_run:aiAt}]});
++ if(p.endsWith('/summary'))return aiResponse({model:'maker/alpha',runs:1,benchmarks:[{benchmark_id:BMK,title:'Bench A',kind:'chat.arena',score:80,runs:1,last_run:aiAt}],recent_runs:[],tasks:[]});
++ return aiResponse(null);
++ };
++}
++async function makeAi(opts){return createAi({fetchImpl:aiStubFetch(opts),log:{warn(){}}});}
++
const measuredFeed={group:'model',range:{from:'2026-10-15',to:'2026-11-13'},rows:[
{group:'model',key:'m1',calls:30,success_rate:0.981,ttft_ms:{p50:240,p90:500,p95:800,n:30},tps:{p50:45.2,p90:70,p95:90,n:30}},
{group:'model',key:'m2',calls:19,success_rate:0.5,ttft_ms:{p50:100,p90:200,p95:300,n:19},tps:{p50:10,p90:20,p95:30,n:19}}
@@@ -64,21 +99,35 @@ test('measured AI columns come from Report public model rows with Shared words a
const md=require('../server/views').markdown(models);
assert.match(md,/- Time to first token p50: 240 ms — 2026-11-13/);assert.match(md,/- Time to first token p50: —/);
});
--test('Report down leaves measured dashes and the page answers 200',async()=>{
-- const {createApp}=require('../server/app');
-- const down=createData({fetchImpl:async url=>{
-- if(url===ENDPOINTS.perf) throw Error('offline');
-- const key=Object.entries(ENDPOINTS).find(([,v])=>v===url)[0];
-- return {ok:true,json:async()=>fixture[key]};
-- }});
-- const server=createApp({getData:down}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
-- const base=`http://127.0.0.1:${server.address().port}`;
++test('each /ai page answers 200 from stubbed sources, measured columns sit beside prices',async()=>{
++ const {createApp}=require('../server/app');const ai=await makeAi();const server=createApp({getData:async()=>data,ai}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));const base=`http://127.0.0.1:${server.address().port}`;
++ try {
++ const paths=['/ai','/ai/leaderboard','/ai/providers','/ai/models/maker%2Falpha','/ai/compare','/ai/compare/maker%2Falpha-vs-maker%2Fbeta','/ai/method','/ai/feed.xml','/ai/arena','/ai/chat.arena',`/ai/${BMK}`];
++ for(const path of paths)assert.equal((await fetch(base+path)).status,200,path);
++ const board=await (await fetch(base+'/ai/leaderboard')).text();assert.match(board,/First token p50/);assert.match(board,/Tokens\/s p50/);assert.match(board,/Success/);assert.match(board,/240 ms/);assert.match(board,/45\.2 tok\/s/);assert.match(board,/98\.0%/);assert.match(board,/Price/);assert.match(board,/ovk-table--stack/);
++ const model=await (await fetch(base+'/ai/models/maker%2Falpha')).text();assert.match(model,/first token p50/);assert.match(model,/tokens\/s p50/);assert.match(model,/success/);assert.match(model,/240 ms/);assert.match(model,/45\.2 tok\/s/);assert.match(model,/98\.0%/);assert.match(model,/canonical" href="https:\/\/openvibe.compare\/ai\/models\/maker%2Falpha/);
++ const feed=await (await fetch(base+'/ai/feed.xml')).text();assert.match(feed,/https:\/\/openvibe.compare\/ai\//);assert.doesNotMatch(feed,/openvibe\.report\/ai/);
++ assert.equal((await fetch(base+'/ai/nope')).status,404);
++ }finally{await new Promise(r=>server.close(r));}
++});
++test('a source down still answers 200 with words; the pair and old AI URLs 301',async()=>{
++ const {createApp}=require('../server/app');const ai=await makeAi({benchDown:true,perfDown:true,marketDown:true});const server=createApp({getData:async()=>data,ai}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));const base=`http://127.0.0.1:${server.address().port}`;
++ try {
++ const hub=await fetch(base+'/ai');assert.equal(hub.status,200);const hubHtml=await hub.text();assert.match(hubHtml,/OpenVibe.Bench is unavailable/);assert.match(hubHtml,/measured AI performance is unavailable/);
++ const board=await fetch(base+'/ai/chat.arena');assert.equal(board.status,200);assert.match(await board.text(),/Bench is unavailable/);
++ const model=await fetch(base+'/ai/models/maker%2Falpha');assert.equal(model.status,200);assert.match(await model.text(),/measured AI performance is unavailable/);
++ const feed=await fetch(base+'/ai/feed.xml');assert.equal(feed.status,200);
++ let r=await fetch(base+'/ai/compare/maker%2Fbeta-vs-maker%2Falpha',{redirect:'manual'});assert.equal(r.status,301);assert.equal(r.headers.get('location'),'/ai/compare/maker%2Falpha-vs-maker%2Fbeta');
++ r=await fetch(base+'/ai/compare?a=maker%2Fbeta&b=maker%2Falpha',{redirect:'manual'});assert.equal(r.status,301);assert.equal(r.headers.get('location'),'/ai/compare/maker%2Falpha-vs-maker%2Fbeta');
++ r=await fetch(base+'/comparisons/ai-models',{redirect:'manual'});assert.equal(r.status,301);assert.equal(r.headers.get('location'),'/ai');
++ r=await fetch(base+'/things/maker%2Fm1',{redirect:'manual'});assert.equal(r.status,301);assert.equal(r.headers.get('location'),'/ai/models/maker%2Fm1');
++ }finally{await new Promise(r=>server.close(r));}
++});
++test('the sitemap, robots and llms.txt name the Compare AI pages',async()=>{
++ const {createApp}=require('../server/app');const ai=await makeAi();const server=createApp({getData:async()=>data,ai}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));const base=`http://127.0.0.1:${server.address().port}`;
try {
-- const r=await fetch(base+'/comparisons/ai-models');assert.equal(r.status,200);
-- const html=await r.text();
-- assert.match(html,/<td data-criterion="ttft"><span class="ovm-unknown">—<\/span><\/td>/);
-- assert.match(html,/<td data-criterion="tps"><span class="ovm-unknown">—<\/span><\/td>/);
-- assert.match(html,/<td data-criterion="success"><span class="ovm-unknown">—<\/span><\/td>/);
-- assert.match(html,/perf is unavailable; no values have been filled in/);
++ const sitemap=await (await fetch(base+'/sitemap.xml')).text();for(const path of ['/ai','/ai/leaderboard','/ai/providers','/ai/method','/ai/arena','/ai/models/maker%2Falpha','/ai/chat.arena'])assert(sitemap.includes(`https://openvibe.compare${path}`),path);assert.doesNotMatch(sitemap,/\/comparisons\/ai-models/);assert.doesNotMatch(sitemap,/\/things\/maker%2Fm1/);
++ const robots=await (await fetch(base+'/robots.txt')).text();assert.match(robots,/Allow: \/ai/);assert.match(robots,/https:\/\/openvibe\.compare\/sitemap\.xml/);
++ const llms=await (await fetch(base+'/llms.txt')).text();assert.match(llms,/https:\/\/openvibe\.compare\/ai/);assert.match(llms,/https:\/\/openvibe\.compare\/ai\/leaderboard/);assert.match(llms,/https:\/\/openvibe\.compare\/ai\/models\/maker%2Falpha/);
}finally{await new Promise(r=>server.close(r));}
});