openvibers/openvibe.report Commits

The AI comparison pages, designed: one way to show a model, a rating, a price and a speed (#163)

OP OpenVibers committed 4 days ago · 7bec27c194 · 6 files, +410 −80

Hub, leaderboard, providers, model and compare are rebuilt on the kit (section, table, stats, find, fold) and
Shared's ai-marks, phone first:
- A model is its maker's mark, a readable name (maker prefix dropped; full id in the title), maker and tags.
- A rating is the number, its spread and the games it rests on, or "Not rated yet"; a price is the cheapest
  provider's own-page price with "via" or "cheapest of N"; reply time and uptime only where traffic went.
- Columns and sections earn their place: Rank and Rating show once something is rated, and until then the
  leaderboard says plainly that nothing is ranked and links to the Arena. Empty sections are not drawn.
- Picks use measured values only: a missing reply time or price is no longer 0 (whisper.cpp was "Fastest" with no
  measurement), "Best ..." needs a rating, and "Free" means free now, not "a provider has a free tier somewhere".
- Chat models by default; embeddings and speech under a Type filter, named on the hub.
- Providers carry their own marks by id (a name saying "OpenAI-compatible" no longer draws OpenAI's), the
  network's own servers read "OpenVibe server", and bad error rates and uptime read red.
- The model page drops four "not published yet" placeholder sections; it has the numbers, providers, task ratings
  and Bench results, and "Try it" goes to Exchange.
- Compare has two model fields with a list of known models (a and b; models=a,b still works).

Changes

ddiff --git a/public/css/ai-comparison.css b/public/css/ai-comparison.css
iindex ba93b60..90dc81a 100644
---- a/public/css/ai-comparison.css
++++ b/public/css/ai-comparison.css
@@@ -1,3 +1,110 @@
--.ai-hero{padding:clamp(1.25rem,4vw,3rem);border-radius:1.25rem;background:linear-gradient(140deg,color-mix(in srgb,var(--room-fill) 75%,transparent),transparent);border:1px solid var(--border)}.ai-hero h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.05;max-width:18ch}.ai-search,.ai-filters{display:flex;flex-wrap:wrap;gap:.65rem;align-items:end;margin:1rem 0}.ai-search label,.ai-filters label{display:grid;gap:.3rem}.ai-search input,.ai-filters input{max-width:100%;min-width:0;padding:.65rem;border:1px solid var(--border);border-radius:.6rem;background:var(--bg-card);color:inherit}.ai-picks,.ai-links,.ai-answer-grid{list-style:none;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));gap:.8rem}.ai-picks a,.ai-answer-grid article{height:100%;box-sizing:border-box;display:grid;gap:.45rem;padding:1rem;border:1px solid var(--border);border-radius:.85rem;color:inherit;text-decoration:none;background:var(--bg-card)}.ai-maker{display:inline-flex;width:max-content;padding:.15rem .5rem;border-radius:999px;background:var(--room-fill);color:var(--room-strong,var(--text-primary));font-size:.75rem;font-weight:600;letter-spacing:.01em}.ai-table-wrap{max-width:100%;overflow-x:auto}.ai-table{width:100%;border-collapse:collapse}.ai-table th,.ai-table td{text-align:left;vertical-align:top;padding:.7rem;border-bottom:1px solid var(--border);white-space:nowrap}.ai-table td:nth-child(2){white-space:normal;min-width:12rem}.ai-rating{font-weight:700}.ai-rating small,.ai-table td small{font-weight:400;color:var(--text-muted)}.ai-missing{color:var(--text-muted);font-size:.9em}.ai-section-head{display:flex;justify-content:space-between;align-items:baseline;gap:1rem}.ai-section-head h2{margin-bottom:.5rem}.ai-answer-grid article{min-width:0;overflow-wrap:anywhere}.ai-answer-grid p{white-space:pre-wrap}.ai-sticky{position:sticky;bottom:.5rem;padding:.85rem;border:1px solid var(--border);border-radius:.75rem;background:var(--bg-card);z-index:3}.ai-filters{padding:1rem;border:1px solid var(--border);border-radius:.75rem}.ai-filters label{font-size:.85rem}details{padding:.7rem 0;border-bottom:1px solid var(--border)}details summary{cursor:pointer;font-weight:600}
--@media(max-width:600px){.ai-table thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}.ai-table,.ai-table tbody,.ai-table tr,.ai-table td{display:block;width:auto}.ai-table tr{display:grid;grid-template-columns:1fr 1fr;gap:.2rem .65rem;padding:.7rem 0;border-bottom:1px solid var(--border)}.ai-table td{padding:.15rem 0;border:0;white-space:normal}.ai-table td:nth-child(1){grid-column:1/-1;font-size:.82rem}.ai-table td:nth-child(2){grid-column:1/-1;min-width:0;font-weight:600}.ai-table td[data-label]::before{display:block;font-size:.72rem;color:var(--text-muted);content:attr(data-label)}.ai-filters{display:grid;grid-template-columns:1fr 1fr}.ai-filters label{min-width:0}.ai-search input{flex:1;min-width:10rem}}
++/* 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}}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a88abf6..2ec0098 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -72,7 +72,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
for (const entry of list) {
const id = entry.subject || entry.model || entry.name;
const cat = findCat(id);
-- rows.push({ id, kind: kinds[i].id, maker: makerOf(cat, id), weights: cat && (cat.weights || (cat.open_weights ? 'open' : null)), 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, entry.ci_upper ?? null], source: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null }, benchmark: { value: entry.score == null ? null : Number(entry.score), reason: entry.score == null ? 'No benchmark score.' : undefined }, provider_offers: cat && Array.isArray(cat.offers) ? cat.offers : [], source: { name: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null } });
++ rows.push({ id, kind: kinds[i].id, model_kind: cat && cat.kind || 'chat', maker: makerOf(cat, id), weights: cat && (cat.weights || (cat.open_weights ? 'open' : null)), 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, entry.ci_upper ?? null], source: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null }, benchmark: { value: entry.score == null ? null : Number(entry.score), reason: entry.score == null ? 'No benchmark score.' : undefined }, provider_offers: cat && Array.isArray(cat.offers) ? cat.offers : [], source: { name: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null } });
}
}
const grouped = new Map();
@@@ -91,7 +91,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
const id = cat.slug || cat.canonical || cat.id;
if (!id) continue;
const source = { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: null };
-- rows.push({ id, kind: cat.kind || null, maker: makerOf(cat, id), weights: cat.weights || (cat.open_weights ? 'open' : null), 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: Array.isArray(cat.offers) ? cat.offers : [], source, task_ratings: [], unrated: true });
++ rows.push({ id, kind: cat.kind || null, model_kind: cat.kind || 'chat', maker: makerOf(cat, id), weights: cat.weights || (cat.open_weights ? 'open' : null), 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: Array.isArray(cat.offers) ? cat.offers : [], source, 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 ?? -Infinity) - (a.task.rating.value ?? -Infinity));
@@@ -118,34 +118,52 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
async function aiComparisonHub() {
const data = await comparisonData();
-- const rated = data.rows.filter((r) => r.rating.value != null);
-- const cheapest = (r) => Math.min(...(r.provider_offers || []).map((o) => Number(o.price && o.price.out)).filter(Number.isFinite), Infinity);
-- const fastest = (r) => Math.min(...(r.provider_offers || []).map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity);
-- const taskRows = (kind) => data.rows.map((row) => { const task = row.task_ratings.find((x) => x.kind === kind); return task ? { ...row, kind, rating: task.rating, benchmark: task.benchmark } : null; }).filter(Boolean);
-- const choose = (id, list, why) => ({ id, model: list || null, why: list ? why : 'Not enough source data to establish this pick.' });
-- data.picks = [choose('overall', rated[0], 'Highest public Bench rating.'), choose('value', rated.filter((r) => Number.isFinite(cheapest(r))).sort((a,b) => cheapest(a)-cheapest(b))[0], 'Lowest listed output price among rated models.'), choose('fastest', data.rows.filter((r) => Number.isFinite(fastest(r))).sort((a,b) => fastest(a)-fastest(b))[0], 'Lowest measured provider p50 response time.'), choose('open', data.rows.find((r) => ['open','open_weights'].includes(r.weights)), 'Listed as open weights in the public catalogue.'), choose('code', taskRows('code').sort((a,b) => (b.rating.value ?? -Infinity) - (a.rating.value ?? -Infinity))[0], 'Top available public code task rating.'), choose('writing', taskRows('writing').sort((a,b) => (b.rating.value ?? -Infinity) - (a.rating.value ?? -Infinity))[0], 'Top available public writing task rating.'), choose('uncensored', data.rows.find((r) => r.uncensored), 'Flagged uncensored by the public catalogue; 18+.'), choose('free', data.rows.find((r) => r.provider_offers.some((o) => o.free_tier)), 'A provider lists a free tier.')];
++ const num = (x) => (x == null || x === '' ? NaN : Number(x));
++ const chat = data.rows.filter((r) => (r.model_kind || 'chat') === 'chat');
++ const rated = chat.filter((r) => r.rating.value != null);
++ const best = (list) => [...list].sort((a, b) => b.rating.value - a.rating.value)[0];
++ const cheapest = (r) => Math.min(...(r.provider_offers || []).map((o) => num(o.price && o.price.out)).filter(Number.isFinite), Infinity);
++ const fastest = (r) => Math.min(...(r.provider_offers || []).filter((o) => Number(o.requests_7d) > 0).map((o) => num(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 || num(o.price && o.price.out) === 0);
++ const taskRows = (kind) => rated.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.' });
++ data.picks = [
++ choose('overall', best(rated), 'Highest rating from blind votes and open runs.'),
++ choose('value', rated.filter((r) => Number.isFinite(cheapest(r))).sort((a, b) => cheapest(a) - cheapest(b))[0], 'Lowest output price among rated models.'),
++ choose('fastest', chat.filter((r) => Number.isFinite(fastest(r))).sort((a, b) => fastest(a) - fastest(b))[0], 'Fastest typical reply on real traffic this week.'),
++ choose('open', best(rated.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(rated.filter((r) => r.uncensored)), 'Highest-rated uncensored model; 18+.'),
++ choose('free', best(rated.filter(free)) || chat.find(free), 'Free to use now through the Exchange.'),
++ ];
++ data.others = data.rows.length - chat.length;
++ data.rows = chat;
return { html: aiComparisonViews.hub(data) };
}
async function aiComparisonLeaderboard(query = {}) {
++ const nz = (x) => (x == null || x === '' ? NaN : Number(x));
const data = await comparisonData();
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 } : 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 = Number(o.price && o.price.out);
-- return query.price === 'free' ? Boolean(o.free_tier) : !Number.isFinite(p) ? false : query.price === 'low' ? p <= 1 : query.price === 'mid' ? p > 1 && p <= 10 : query.price === 'high' ? p > 10 : true;
++ 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) => Number(o.price && o.price.out)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => Number(o.price && o.price.out)).filter(Number.isFinite), Infinity));
-- if (query.sort === 'speed') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity));
++ 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 ?? -Infinity) - (a.rating.value ?? -Infinity));
return { html: aiComparisonViews.leaderboard({ rows, filters: query }) };
}
@@@ -160,7 +178,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
async function aiComparison({ models = [] } = {}) {
const data = await comparisonData();
const selected = models.map((id) => data.rows.find((x) => x.id === id) || { id, reason: 'No public model data.' });
-- return { html: aiComparisonViews.compare({ models: selected }) };
++ return { html: aiComparisonViews.compare({ models: selected, options: data.rows.map((x) => x.id) }) };
}
function aiArena() { return { html: aiComparisonViews.arena() }; }
@@@ -303,7 +321,9 @@ function createPageRoutes(ctx) {
r.get('/ai/providers', async (req, res) => send(req, res, (await publicPages.aiComparisonProviders()).html));
r.get('/ai/arena', (_req, res) => send(_req, res, publicPages.aiArena().html));
r.get('/ai/compare', async (req, res) => {
-- const models = String(req.query.models || '').split(',').map((x) => x.trim()).filter(Boolean);
++ // 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) return res.redirect(301, `/ai/compare/${encodeURIComponent(models[0])}-vs-${encodeURIComponent(models[1])}`);
return send(req, res, (await publicPages.aiComparison({ models })).html);
});
ddiff --git a/server/views/ai-comparison.js b/server/views/ai-comparison.js
iindex 01f65d8..b5b6935 100644
---- a/server/views/ai-comparison.js
++++ b/server/views/ai-comparison.js
@@@ -1,70 +1,273 @@
'use strict';
++// Report's AI comparison pages: the hub, the full leaderboard, providers, a model and a comparison.
++// 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.
++// 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 marks = require('openvibe-shared/ai-marks');
const { asset } = require('../assets');
const { layout } = require('./index');
++
const esc = kit.esc;
const value = (x) => x == null || !Number.isFinite(Number(x)) ? null : Number(x);
--const money = (x) => value(x) == null ? 'Not measured yet' : `$${Number(x).toLocaleString('en-US', { maximumFractionDigits: 4 })}`;
--const sourceHint = (s) => { const x = s && typeof s === 'object' ? s : { name: s }; return `Source: ${x.name || 'source not listed'}${x.date ? ` · ${x.date}` : ''}${x.sample_size != null ? ` · sample ${x.sample_size}` : ' · sample size not reported'}`; };
const idPath = (id) => `/ai/models/${encodeURIComponent(id || '')}`;
--const label = (id) => String(id || 'Unknown model');
--const mark = (maker) => `<span class="ai-maker">${esc(maker || 'Maker not listed')}</span>`;
--const interval = (row) => { const v = row.rating && value(row.rating.value); const bounds = row.rating && row.rating.interval || []; if (v == null) return '<span class="ai-missing">Not enough data</span>'; const lo = value(bounds[0]); const hi = value(bounds[1]); return `<span class="ai-rating" title="${esc(sourceHint(row.rating))}">${Math.round(v)}${lo != null && hi != null ? ` <small>${Math.round(lo)}–${Math.round(hi)}</small>` : ''}</span>`; };
--const firstOffer = (row) => row.provider_offers && row.provider_offers[0] || {};
--const metric = (content, source) => `<span title="${esc(sourceHint(source))}">${content}</span>`;
--const priceCell = (offer, key) => metric(money(offer.price && offer.price[key]), offer.source);
--const cards = (rows) => rows.map((r) => `<li><a href="${idPath(r.id)}">${mark(r.maker)}<b>${esc(label(r.id))}</b><span>${interval(r)}</span></a></li>`).join('');
--const table = (rows, full = false) => `<div class="ai-table-wrap"><table class="ai-table"><thead>${full ? '<tr class="ai-groups"><th rowspan="2">Rank</th><th rowspan="2">Model</th><th colspan="2">Score</th><th colspan="3">Price</th><th colspan="2">Speed</th><th>Reliability</th><th colspan="2">Model details</th></tr><tr><th>Rating and band</th><th>Bench score</th><th>Input / 1M</th><th>Output / 1M</th><th>Cost per solved task</th><th>Tokens/s</th><th>First token</th><th>Uptime 7 d</th><th>Context</th><th>Licence</th></tr>' : '<tr><th>Rank</th><th>Model</th><th>Rating · Bench</th><th>Price out / 1M</th><th>Speed</th></tr>'}</thead><tbody>${rows.map((r, i) => { const o = firstOffer(r); return `<tr><td data-label="Rank">${r.rank && r.rank.min != null ? (r.rank.min === r.rank.max ? r.rank.min : `${r.rank.min}–${r.rank.max}`) : i + 1}</td><td>${mark(r.maker)}<a href="${idPath(r.id)}">${esc(label(r.id))}</a></td><td data-label="Rating · Bench">${interval(r)}${r.kind ? `<small>${esc(r.kind)}</small>` : ''}</td>${full ? `<td data-label="Bench score">${r.benchmark && value(r.benchmark.value) != null ? esc(r.benchmark.value) : '<span class="ai-missing">Not enough data</span>'}</td><td data-label="Input / 1M">${priceCell(o, 'in')}</td>` : ''}<td data-label="Output / 1M">${priceCell(o, 'out')}</td>${full ? `<td data-label="Cost per solved task">${o.cost_per_solved == null ? 'Not measured yet' : money(o.cost_per_solved)}</td>` : ''}<td data-label="Tokens/s">${o.tokens_per_second && value(o.tokens_per_second.value) != null ? `${esc(o.tokens_per_second.value)} tokens/s` : '<span class="ai-missing">Not measured yet</span>'}</td>${full ? `<td data-label="First token">${o.ttft_ms && value(o.ttft_ms.value) != null ? `${esc(o.ttft_ms.value)} ms` : 'Not measured yet'}</td><td data-label="Uptime 7 d">${o.uptime == null ? 'Not measured yet' : `${Math.round(Number(o.uptime) * 100)}%`}</td><td data-label="Context">${o.context || 'Not measured yet'}</td><td data-label="Licence">${esc(o.license || 'Not listed')}</td>` : ''}</tr>`; }).join('')}</tbody></table></div>${full ? '<details class="ai-more"><summary>More columns</summary><p>Additional provider and model details are shown in the grouped table columns.</p></details>' : ''}`;
--function page(title, description, body, path) { return layout({ title, description, body: `<link rel="stylesheet" href="${asset('css/ai-comparison.css')}">${body}`, active: '/ai', path }); }
--function hub({ rows = [], picks = [], providers = [], month = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' }), changed = [], tasks = [], arena = null } = {}) {
-- const pickLabels = { 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' };
-- const pending = picks.filter(p => !p.model).map(p => pickLabels[p.id] || p.id);
-- const pickItems = picks.filter(p => p.model).map(p => `<li><a href="${p.model ? idPath(p.model.id) : '/ai/leaderboard'}">${mark(p.model && p.model.maker)}<b>${esc(pickLabels[p.id] || p.id)}</b><strong>${esc(label(p.model && p.model.id))}</strong><small>${esc(p.why || 'Not enough data yet.')}</small></a></li>`).join('');
-- const pairs = rows.slice(0, 16).reduce((a, r, i, all) => { if (i % 2 === 0 && all[i + 1]) a.push(`<a href="/ai/compare/${encodeURIComponent(r.id)}-vs-${encodeURIComponent(all[i+1].id)}">${esc(r.id)} vs ${esc(all[i+1].id)}</a>`); return a; }, []).slice(0,8);
-- const faq = ['How are ratings made?','Are prices measured by OpenVibe.Report?','What does the rank band mean?','Why is a measurement missing?','What does open weights mean?','How often does this data change?'];
-- const sourceDate = rows.map(r => r.source && r.source.date).find(Boolean) || (providers[0] && providers[0].source && providers[0].source.date) || 'date not reported by source';
-- const answers = [
-- `${rows.filter(r => r.rating && r.rating.value != null).length} models have a Bench rating in this dataset, dated ${sourceDate}.`,
-- `${providers.length} provider and model offers come from OpenVibe.AI, dated ${sourceDate}; Report displays those listed values.`,
-- `${rows.filter(r => r.rating && r.rating.interval && r.rating.interval[0] != null && r.rating.interval[1] != null).length} model rows include an interval; overlapping intervals share a rank band. Bench data dated ${sourceDate}.`,
-- `${rows.filter(r => !r.provider_offers.length || r.provider_offers.some(o => o.speed == null)).length} rows have at least one unreported provider measurement in the current source data, dated ${sourceDate}.`,
-- `${rows.filter(r => ['open','open_weights'].includes(r.weights)).length} models are listed as open weights by the catalogue, dated ${sourceDate}.`,
-- `The source snapshots on this page are dated ${sourceDate}; cached reads refresh within two minutes.`
-- ];
-- const body = `<header class="ai-hero"><p class="rp-kicker">OpenVibe.Report · AI comparison</p><h1>AI model leaderboard — ${esc(month)}</h1><p class="rp-lede">${rows.length} models ranked by people’s votes and by open tests; prices and speed measured on real traffic.</p><form action="/ai/leaderboard" class="ai-search"><label for="ai-find">Find a model</label><input id="ai-find" name="q" type="search" placeholder="Search models or makers"><button>Search</button></form></header>
-- <section><h2>Top picks</h2>${pickItems ? `<ul class="ai-picks">${pickItems}</ul>` : ''}${pending.length ? `<p class="rp-fine">Not enough Bench data yet for: ${esc(pending.join(', '))}.</p>` : ''}</section>
-- <section><div class="ai-section-head"><h2>Leaderboard</h2><a href="/ai/leaderboard">Show all ${rows.length}</a></div>${table(rows.slice(0,25))}<p class="rp-fine">Rating intervals and prices retain their source dates and sample sizes in the <a href="/api/v1/ai/leaderboard">JSON dataset</a>.</p></section>
-- <section><h2>One prompt, many answers</h2>${arena ? `<p>${esc(arena.prompt || 'Today’s public Arena prompt is not available.')}</p><div class="ai-answer-grid">${(arena.answers || []).slice(0,3).map(a=>`<article>${mark(a.maker)}<h3>${esc(a.model || 'Model')}</h3><p>${esc(a.answer || '')}</p></article>`).join('')}</div>` : '<p class="rp-fine">Today’s public Arena prompt is not available.</p>'}</section>
-- <section><h2>Popular comparisons</h2><ul class="ai-links">${pairs.map(x=>`<li>${x}</li>`).join('') || '<li>Comparisons will appear as data becomes available.</li>'}</ul></section>
-- <section><h2>What changed this month</h2><ul>${changed.slice(0,8).map(x=>`<li>${esc(x)}</li>`).join('') || '<li>No dated monthly changes are available yet.</li>'}</ul></section>
-- <section><h2>Boards by task</h2><ul class="ai-links">${tasks.slice(0,12).map(x=>`<li><a href="/ai/${encodeURIComponent(x.id)}">${esc(x.name || x.id)}</a></li>`).join('') || '<li>Task boards are not available yet.</li>'}</ul></section>
-- <section><div class="ai-section-head"><h2>Providers</h2><a href="/ai/providers">All provider measurements</a></div><p>${providers.length} provider and model combinations listed. Missing measurements are shown as “Not measured yet.”</p></section>
-- <section><h2>Frequently asked questions</h2>${faq.map((q,i)=>`<details><summary>${esc(q)}</summary><p>${esc(answers[i])}</p></details>`).join('')}</section>
-- <script type="application/ld+json">${JSON.stringify({ '@context':'https://schema.org','@type':'FAQPage',mainEntity:faq.map((q,i)=>({'@type':'Question',name:q,acceptedAnswer:{'@type':'Answer',text:answers[i]}})) }).replace(/</g,'\\u003c')}</script>
-- <script type="application/ld+json">${JSON.stringify({ '@context':'https://schema.org','@type':'Dataset',name:'OpenVibe AI model leaderboard',url:'https://openvibe.report/ai',variableMeasured:['Bench rating','Provider price','Provider speed'],distribution:{'@type':'DataDownload',contentUrl:'https://openvibe.report/api/v1/ai/leaderboard',encodingFormat:'application/json'} }).replace(/</g,'\\u003c')}</script>`;
-- return page(`AI model leaderboard — ${month}`, 'AI model ratings, provider prices and measured performance.', body, '/ai');
--}
--function leaderboard({ rows = [], filters = {} } = {}) { const select=(name,label,opts)=>`<label>${label}<select name="${name}">${opts.map(([v,t])=>`<option value="${v}"${String(filters[name]||'')===v?' selected':''}>${t}</option>`).join('')}</select></label>`; return page('AI model leaderboard', 'Browse and filter measured AI model results.', `<header><h1>AI model leaderboard</h1><form class="ai-filters" action="/ai/leaderboard"><label>Search<input name="q" type="search" value="${esc(filters.q || '')}"></label><label>Maker<input name="maker" value="${esc(filters.maker || '')}"></label><label>Task kind<input name="kind" value="${esc(filters.kind || '')}"></label>${select('weights','Weights',[['','Any'],['open','Open weights'],['closed','Closed']])}${select('price','Price band',[['','Any price'],['free','Free'],['low','Low'],['mid','Mid'],['high','High']])}${select('status','Status',[['current','Current'],['all','All']])}${select('uncensored','Uncensored',[['','Any'],['true','18+ flagged']])}${select('community','Community machines',[['','Any'],['true','Community']])}${select('sort','Sort by',[['rating','Rating'],['name','Name'],['price','Output price'],['speed','Speed']])}<button>Apply filters</button></form>${table(rows,true)}<p><a href="/api/v1/ai/leaderboard">JSON</a> · <a href="/api/v1/ai/leaderboard?format=csv&amp;limit=100">CSV</a></p></header>`, '/ai/leaderboard'); }
--function providers({ providers = [] } = {}) {
-- // What the Exchange measures on real traffic over seven days: reply time (p50, the whole reply, not the first
-- // token), errors, uptime and how many requests the numbers rest on. Tokens per second are not measured yet,
-- // so there is no column for them.
-- const share = (x) => { const v = x != null && typeof x === 'object' ? x.value : x; return value(v) == null ? 'Not measured yet' : `${(Number(v) * 100).toFixed(Number(v) < 0.01 && Number(v) > 0 ? 2 : 1)}%`; };
-- const ms = (x) => value(x) == null ? 'Not measured yet' : (Number(x) >= 1000 ? `${(Number(x) / 1000).toFixed(1)} s` : `${Math.round(Number(x))} ms`);
-- const count = (x) => value(x) == null ? 'Not measured yet' : Number(x).toLocaleString('en-US');
-- return page('AI providers', 'Provider and model price, speed and reliability measurements.', `<h1>AI providers</h1><p class="rp-lede">Measured by OpenVibe.AI on the Exchange's real traffic over the last seven days. “Not measured yet” means no request has gone through that provider and model in that time.</p><div class="ai-table-wrap"><table class="ai-table"><thead><tr><th>Provider</th><th>Model</th><th>Input / 1M</th><th>Output / 1M</th><th>Reply time p50</th><th>Reply time p95</th><th>Error rate</th><th>Uptime 7d</th><th>Requests 7d</th></tr></thead><tbody>${providers.map((p) => `<tr><td>${esc(p.provider_name || p.provider || p.name || 'Provider')}</td><td><a href="${idPath(p.model)}">${esc(p.model)}</a></td><td data-label="Input / 1M">${priceCell(p, 'in')}</td><td data-label="Output / 1M">${priceCell(p, 'out')}</td><td data-label="Reply time p50">${ms(p.speed && p.speed.p50_ms)}</td><td data-label="Reply time p95">${ms(p.speed && p.speed.p95_ms)}</td><td data-label="Error rate">${p.requests_7d ? share(p.error_rate) : 'Not measured yet'}</td><td data-label="Uptime 7d">${p.requests_7d ? share(p.uptime) : 'Not measured yet'}</td><td data-label="Requests 7d">${count(p.requests_7d)}</td></tr>`).join('')}</tbody></table></div>`, '/ai/providers');
--}
--function model({ id, detail = {}, row = null, providers = [], sourceDate = null } = {}) {
++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 : kit.num(x, 'money');
++const ms = (x) => value(x) == null ? null : kit.num(x, 'ms');
++const share = (x) => { const v = x != null && typeof x === 'object' ? x.value : x; return value(v) == null ? null : `${(Number(v) * 100).toFixed(Number(v) > 0 && Number(v) < 0.01 ? 2 : 1)}%`; };
++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}` : ''}`; };
++
++// ── 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;
++}
++// "openai/gpt-5-nano" reads as "gpt-5-nano" beside OpenAI's mark; the full id stays in the link and the title.
++const shortName = (id) => String(id || '').replace(/^[^/]+\//, '') || String(id || '');
++function tags(row) {
++ const t = [];
++ 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 v = Math.round(Number(row.rating.value));
++ const [lo, hi] = (row.rating.interval || []).map(value);
++ const n = 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 = n != null ? `${n.toLocaleString('en-US')} ${n === 1 ? 'game' : 'games'}` : '';
++ return `<span class="ai-rating" title="${esc(sourceHint(row.rating))}"><b>${v}</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 measured = (o) => 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) => measured(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(measured);
++ 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>`;
++}
++
++// ── The leaderboard table ──
++function board(rows, { full = false } = {}) {
++ 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 },
++ has((r) => fastest(r)) && { key: 'speed', label: 'Reply time', html: true },
++ full && has((r) => offersOf(r).some((o) => measured(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),
++ 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, v]) => [k, k === 'model' ? v : `<span class="ai-cell">${v}</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 page(title, description, body, path) {
++ return layout({ title, description, body: `<link rel="stylesheet" href="${asset('css/ai-comparison.css')}"><style>${marks.CSS}</style><div class="ai-page">${body}</div>`, active: '/ai', path });
++}
++const dataLinks = () => `<p class="ai-data">Data: <a href="/api/v1/ai/leaderboard">JSON</a> · <a href="/api/v1/ai/leaderboard?format=csv&amp;limit=100">CSV</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 ? `${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({ rows = [], picks = [], providers = [], others = 0, month = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' }), changed = [], tasks = [], arena = null } = {}) {
++ const ratedRows = rows.filter(rated);
++ const providerNames = new Set(providers.map(providerName).filter(Boolean));
++ const measuredOffers = providers.filter(measured);
++ 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.</p>`
++ + 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>` : ''}` }) : '')
++ + kit.section({ title: 'Leaderboard', count: rows.length, actions: [act('/ai/leaderboard', rows.length > 25 ? `All ${rows.length} models` : 'Filter and sort')], body: board(rows.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() })
++ + (arena && (arena.answers || []).length ? kit.section({ title: 'One prompt, many answers', body: `<p class="ai-prompt">${esc(arena.prompt || '')}</p><div class="ai-answers">${arena.answers.slice(0, 3).map((a) => `<article>${modelCell({ id: a.model || 'Model', maker: a.maker })}<p>${esc(a.text || '')}</p></article>`).join('')}</div>` }) : '')
++ + (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>` }) : '')
++ + (measuredOffers.length ? kit.section({ title: 'Providers', actions: [act('/ai/providers', 'Every provider')], body: providerTable(measuredOffers.slice(0, 6)) }) : '')
++ + (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>` }) : '')
++ + (changed.length ? kit.section({ title: 'What changed this month', body: `<ul>${changed.slice(0, 8).map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` }) : '')
++ + kit.section({ title: 'Questions', body: faq.map(([q, a]) => kit.fold({ title: q, body: `<p>${esc(a)}</p>` })).join('') })
++ + `<script type="application/ld+json">${JSON.stringify({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map(([q, a]) => ({ '@type': 'Question', name: q, acceptedAnswer: { '@type': 'Answer', text: a } })) }).replace(/</g, '\\u003c')}</script>`
++ + `<script type="application/ld+json">${JSON.stringify({ '@context': 'https://schema.org', '@type': 'Dataset', name: 'OpenVibe AI model leaderboard', url: 'https://openvibe.report/ai', variableMeasured: ['Bench rating', 'Provider price', 'Provider reply time', 'Provider uptime'], distribution: { '@type': 'DataDownload', contentUrl: 'https://openvibe.report/api/v1/ai/leaderboard', encodingFormat: 'application/json' } }).replace(/</g, '\\u003c')}</script>`;
++ return page(`AI model leaderboard — ${month}`, `${rows.length} AI models compared: ratings from blind votes, provider prices and measured speed.`, body, '/ai');
++}
++
++// ── /ai/leaderboard ──
++function leaderboard({ rows = [], filters = {} } = {}) {
++ const select = (name, label, opts) => `<label class="ai-filter"><span>${esc(label)}</span><select name="${name}">${opts.map(([v, t]) => `<option value="${esc(v)}"${String(filters[name] || '') === v ? ' 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>`
++ + form + board(rows, { full: true }) + dataLinks();
++ return page('AI model leaderboard', 'Every AI model with its rating, provider prices and measured speed; filter and sort.', body, '/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' : (p.provider_name || p.provider || 'Provider');
++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 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>`,
++ 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: measured(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('No traffic yet'),
++ errors: measured(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('–'),
++ uptime: measured(p) && share(p.uptime) != null ? `<b class="${Number(p.uptime) < 0.95 ? 'ai-bad' : ''}">${esc(share(p.uptime))}</b>` : quiet('–'),
++ requests: measured(p) ? Number(p.requests_7d).toLocaleString('en-US') : quiet('0'),
++ }));
++ return kit.table({
++ columns: [
++ { key: 'provider', label: 'Provider', primary: true, html: true },
++ { key: 'model', label: 'Model', 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 },
++ ],
++ rows: rows.map((d) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, k === 'provider' ? v : `<span class="ai-cell">${v}</span>`]))), label: 'Providers', empty: 'No provider is listed yet.',
++ });
++}
++function providers({ providers: list = [] } = {}) {
++ const sorted = [...list].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 provider and model the Exchange lists. Prices come from each provider's own page. Reply time, errors and uptime are measured on real requests over the last seven days; a provider with no traffic yet has no measurement.</p></header>`
++ + 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({ id, detail = {}, row = null, providers: list = [], sourceDate = null } = {}) {
detail = detail || {};
-- const offers = providers.filter((p) => p.model === id);
-- const bands = row ? interval(row) : '';
-- const taskRatings = (row && row.task_ratings || []).filter((task) => task.rating && value(task.rating.value) != null);
-- const tasks = taskRatings.length ? `<ul>${taskRatings.map((task) => `<li><a href="/ai/${encodeURIComponent(task.kind)}">${esc(task.kind)}</a>: rank ${task.rank ? (task.rank.min === task.rank.max ? task.rank.min : `${task.rank.min}–${task.rank.max}`) : 'not enough data'} · ${interval({ rating: task.rating })}</li>`).join('')}</ul>` : '<p>Not rated on any task yet: no blind votes or runs on this model so far.</p>';
-- const benchmarkTasks = (detail.benchmarks || []).length ? `<ul>${detail.benchmarks.map((b) => `<li>${esc(b.title || b.benchmark_id)}: score ${b.score == null ? 'not measured' : esc(b.score)} across ${b.runs == null ? 'an unreported number of' : esc(b.runs)} runs${b.last_run ? ` · last run ${esc(b.last_run)}` : ''} · OpenVibe.Bench${sourceDate ? ` · snapshot ${esc(sourceDate)}` : ''}</li>`).join('')}</ul>` : '';
-- return page(`${id} — AI model`, `Ratings and provider measurements for ${id}.`, `<header><p>${mark(row && row.maker)}</p><h1>${esc(id)}</h1><p class="rp-lede">Rating and rank band: ${bands || 'Not enough data yet.'}</p></header><section><h2>30-day rating</h2><p>Not enough data: Bench does not publish a daily rating history.</p></section><section><h2>Prices and speed by provider</h2>${providersView(offers)}</section><section><h2>Task ratings</h2>${tasks}${benchmarkTasks}</section><section><h2>Arena record against the top five</h2><p>Not enough public Arena data yet.</p></section><section><h2>Earlier versions</h2><p>Not published by the available sources yet.</p></section><section><h2>Sample answers</h2><p>Not published by the available sources yet.</p></section><aside class="ai-sticky"><a href="https://openvibe.chat/vibe?models=${encodeURIComponent(id)}">Try it in Chat</a> · <a href="https://ai.openvibe.services/models/${encodeURIComponent(id)}">Use it from code</a></aside><script type="application/ld+json">${JSON.stringify({'@context':'https://schema.org','@type':'Product',name:id,brand:row&&row.maker||'Unknown maker'}).replace(/</g,'\\u003c')}</script>`, idPath(id));
--}
--function providersView(ps) { return `<ul>${ps.map(p=>`<li>${esc(p.provider||p.name||'Provider')} · input ${money(p.price&&p.price.in)} · output ${money(p.price&&p.price.out)} · speed ${p.speed&&p.speed.p50_ms!=null?`${esc(p.speed.p50_ms)} ms p50`:'Not measured yet'}</li>`).join('')||'<li>Not measured yet</li>'}</ul>`; }
--function compare({ models = [] } = {}) { return page('Compare AI models', 'Compare AI models side by side.', `<h1>Compare AI models</h1>${models.length !== 2 ? '<p class="rp-fine">Choose two models to compare. Add two model IDs below.</p>' : ''}<div class="ai-answer-grid">${models.map(m=>`<article><h2>${esc(m.id || m.model || 'Model')}</h2><p>Rating: ${m.rating ? interval({rating:m.rating}) : 'Not enough data yet'}</p><p>Prices and speed: ${providersView(m.offers || [])}</p></article>`).join('')}</div><form action="/ai/compare" class="ai-search"><label for="ai-third">Choose two models to compare</label><input id="ai-third" name="models" placeholder="model-a,model-b"><button>Compare</button></form>`, '/ai/compare'); }
--function arena() { return page('AI Arena', 'Compare anonymous AI answers and vote.', '<h1>AI Arena</h1><p class="rp-lede">Public Arena rounds and votes are provided by OpenVibe.Exchange.</p><p>Arena rounds are not available from this page yet.</p>', '/ai/arena'); }
++ 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 facts = kit.stats([
++ [rated(self) ? Math.round(self.rating.value) : 'Not rated yet', 'rating'],
++ [cheapest({ provider_offers: offers }) ? money(cheapest({ provider_offers: offers }).price.out) : null, 'cheapest output / 1M'],
++ [fastest({ provider_offers: offers }) ? ms(fastest({ provider_offers: offers }).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${sourceDate ? ` · snapshot ${String(sourceDate).slice(0, 10)}` : ''}`,
++ }) : '';
++ 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>`
++ + kit.section({ title: 'Providers', count: offers.length, body: providerTable(offers) })
++ + 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>`
++ + `<script type="application/ld+json">${JSON.stringify({ '@context': 'https://schema.org', '@type': 'Product', name: id, brand: maker || 'Unknown maker' }).replace(/</g, '\\u003c')}</script>`;
++ return page(`${shortName(id)} — AI model`, `${id}: rating, prices across providers and measured speed.`, body, idPath(id));
++}
++
++// ── /ai/compare ──
++function compare({ models = [], options = [] } = {}) {
++ const two = models.length === 2;
++ const facts = (m) => [
++ ['Rating', ratingCell(m, { range: true })],
++ ['Price', priceCell(m)],
++ ['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, v]) => `<div><dt>${esc(k)}</dt><dd>${v}</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>`
++ + (cards ? `<div class="ai-vs">${cards}</div>` : '') + form;
++ 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');
++}
++
module.exports = { hub, leaderboard, providers, model, compare, arena };
ddiff --git a/test/ai-catalogue-join.test.js b/test/ai-catalogue-join.test.js
iindex ce020c8..b40c300 100644
---- a/test/ai-catalogue-join.test.js
++++ b/test/ai-catalogue-join.test.js
@@@ -34,7 +34,7 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
const row = r.text.slice(r.text.indexOf('gpt-5-nano-2025-08-07'));
assert.ok(r.text.includes('gpt-5-nano-2025-08-07'), 'the rated model');
assert.ok(row.includes('$0.05') && row.includes('$0.4'), 'its catalogue prices');
-- assert.ok(r.text.includes('>openai<'), 'its maker from the catalogue slug');
++ assert.ok(r.text.includes('OpenAI') && r.text.includes('ov-aim--openai'), 'its maker, named and marked');
});
await check('a catalogue model without Bench games is listed, not rated', async () => {
const r = await t.get('/ai/leaderboard');
ddiff --git a/test/ai-pages.test.js b/test/ai-pages.test.js
iindex ade2cd1..5dd59d9 100644
---- a/test/ai-pages.test.js
++++ b/test/ai-pages.test.js
@@@ -157,12 +157,12 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
try {
const hub = await fixture.get('/ai');
assert.ok(hub.text.includes('AI model leaderboard — November 2026'));
-- assert.ok(hub.text.includes('1200–1300') && hub.text.includes('1–2') && hub.text.includes('Dataset') && hub.text.includes('FAQPage'));
++ assert.ok(hub.text.includes('± 50') && hub.text.includes('40 games') && hub.text.includes('1–2') && hub.text.includes('Dataset') && hub.text.includes('FAQPage'));
assert.ok(hub.text.includes('Maker A') && !hub.text.includes('letter-avatar'));
const filtered = await fixture.get('/ai/leaderboard?maker=Maker%20A&weights=open&price=low&status=current&kind=code');
assert.ok(filtered.text.includes('maker/model-a') && !filtered.text.includes('maker/model-b'));
const provider = await fixture.get('/ai/providers');
-- assert.ok(provider.text.includes('Provider One') && provider.text.includes('Not measured yet'));
++ assert.ok(provider.text.includes('Provider One') && provider.text.includes('No traffic yet'), 'an offer without requests has no measurement');
assert.strictEqual((await fixture.get('/ai/compare?models=maker%2Fmodel-a,maker%2Fmodel-b')).status, 301);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-a-vs-maker%2Fmodel-b')).status, 200);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-b-vs-maker%2Fmodel-a')).status, 301);
ddiff --git a/test/ai-section.test.js b/test/ai-section.test.js
iindex 7c58c63..3f94b34 100644
---- a/test/ai-section.test.js
++++ b/test/ai-section.test.js
@@@ -105,7 +105,7 @@ const legacy = { model: 'model-a', ratings: [{ kind_id: 'design.page', elo: 1043
await check('one model page uses the comparison profile and available Bench task data', async () => {
const r = await t.get('/ai/models/model-a');
assert.strictEqual(r.status, 200);
-- for (const s of ['30-day rating', 'Not enough data', 'Task ratings', 'Playable games arena', 'snapshot', 'Arena record against the top five', 'Product', 'Try it in Chat', 'Use it from code']) assert.ok(r.text.includes(s), s);
++ for (const s of ['Providers', 'Task ratings', 'Playable games arena', 'snapshot', 'Product', 'Try it in Chat', 'Use it from code']) assert.ok(r.text.includes(s), s);
assert.ok(!r.text.includes('&lt;span title='), 'a stat is escaped HTML, not markup');
});