openvibers/openvibe.report Commits
The AI comparison pages, designed: one way to show a model, a rating, a price and a speed (#163)
Changes
@@@ -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}}@@@ -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);});@@@ -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&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&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 };@@@ -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');@@@ -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);@@@ -105,7 +105,7 @@ const legacy = { model: 'model-a', ratings: [{ kind_id: 'design.page', elo: 1043await 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('<span title='), 'a stat is escaped HTML, not markup');});
@@ -1,3 +1,110 @@
@@ -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}}
@@ -72,7 +72,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
@@ -72,7 +72,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
for (const entry of list) {
for (const entry of list) {
const id = entry.subject || entry.model || entry.name;
const id = entry.subject || entry.model || entry.name;
const cat = findCat(id);
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();
const grouped = new Map();
@@ -91,7 +91,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
@@ -91,7 +91,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
const id = cat.slug || cat.canonical || cat.id;
const id = cat.slug || cat.canonical || cat.id;
if (!id) continue;
if (!id) continue;
const source = { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: null };
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) {
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));
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 =
@@ -118,34 +118,52 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
async function aiComparisonHub() {
async function aiComparisonHub() {
const data = await comparisonData();
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) };
return { html: aiComparisonViews.hub(data) };
}
}
async function aiComparisonLeaderboard(query = {}) {
async function aiComparisonLeaderboard(query = {}) {
+ const nz = (x) => (x == null || x === '' ? NaN : Number(x));
const data = await comparisonData();
const data = await comparisonData();
let rows = data.rows;
let rows = data.rows;
if (query.kind) rows = rows.map((x) => {
if (query.kind) rows = rows.map((x) => {
const task = x.task_ratings.find((rating) => rating.kind === query.kind);
const task = x.task_ratings.find((rating) => rating.kind === query.kind);
return task ? { ...x, kind: query.kind, rating: task.rating, benchmark: task.benchmark } : null;
return task ? { ...x, kind: query.kind, rating: task.rating, benchmark: task.benchmark } : null;
}).filter(Boolean);
}).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.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.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.status !== 'all') rows = rows.filter((x) => x.status === (query.status || 'current'));
if (query.uncensored) rows = rows.filter((x) => x.uncensored);
if (query.uncensored) rows = rows.filter((x) => x.uncensored);
if (query.community) rows = rows.filter((x) => x.community);
if (query.community) rows = rows.filter((x) => x.community);
if (query.price) rows = rows.filter((x) => x.provider_offers.some((o) => {
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.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 === '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));
if (query.sort === 'rating') rows.sort((a, b) => (b.rating.value ?? -Infinity) - (a.rating.value ?? -Infinity));
return { html: aiComparisonViews.leaderboard({ rows, filters: query }) };
return { html: aiComparisonViews.leaderboard({ rows, filters: query }) };
}
}
@@ -160,7 +178,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
@@ -160,7 +178,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
async function aiComparison({ models = [] } = {}) {
async function aiComparison({ models = [] } = {}) {
const data = await comparisonData();
const data = await comparisonData();
const selected = models.map((id) => data.rows.find((x) => x.id === id) || { id, reason: 'No public model data.' });
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() }; }
function aiArena() { return { html: aiComparisonViews.arena() }; }
@@ -303,7 +321,9 @@ function createPageRoutes(ctx) {
@@ -303,7 +321,9 @@ function createPageRoutes(ctx) {
r.get('/ai/providers', async (req, res) => send(req, res, (await publicPages.aiComparisonProviders()).html));
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/arena', (_req, res) => send(_req, res, publicPages.aiArena().html));
r.get('/ai/compare', async (req, res) => {
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])}`);
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);
return send(req, res, (await publicPages.aiComparison({ models })).html);
});
});
@@ -1,70 +1,273 @@
@@ -1,70 +1,273 @@
'use strict';
'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 kit = require('openvibe-shared/kit');
+const marks = require('openvibe-shared/ai-marks');
const { asset } = require('../assets');
const { asset } = require('../assets');
const { layout } = require('./index');
const { layout } = require('./index');
+
const esc = kit.esc;
const esc = kit.esc;
const value = (x) => x == null || !Number.isFinite(Number(x)) ? null : Number(x);
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 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&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&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 || {};
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 };
module.exports = { hub, leaderboard, providers, model, compare, arena };
@@ -34,7 +34,7 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
@@ -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'));
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(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(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 () => {
await check('a catalogue model without Bench games is listed, not rated', async () => {
const r = await t.get('/ai/leaderboard');
const r = await t.get('/ai/leaderboard');
@@ -157,12 +157,12 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
@@ -157,12 +157,12 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
try {
try {
const hub = await fixture.get('/ai');
const hub = await fixture.get('/ai');
assert.ok(hub.text.includes('AI model leaderboard — November 2026'));
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'));
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');
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'));
assert.ok(filtered.text.includes('maker/model-a') && !filtered.text.includes('maker/model-b'));
const provider = await fixture.get('/ai/providers');
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?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-a-vs-maker%2Fmodel-b')).status, 200);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-b-vs-maker%2Fmodel-a')).status, 301);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-b-vs-maker%2Fmodel-a')).status, 301);
@@ -105,7 +105,7 @@ const legacy = { model: 'model-a', ratings: [{ kind_id: 'design.page', elo: 1043
@@ -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 () => {
await check('one model page uses the comparison profile and available Bench task data', async () => {
const r = await t.get('/ai/models/model-a');
const r = await t.get('/ai/models/model-a');
assert.strictEqual(r.status, 200);
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('<span title='), 'a stat is escaped HTML, not markup');
assert.ok(!r.text.includes('<span title='), 'a stat is escaped HTML, not markup');
});
});