openvibers/openvibe.report Commits

AI pages read the real catalogue: every Exchange model, its makers, prices and measured speed (#161)

OP OpenVibers committed 4 days ago · 4cebf76f15 · 12 files, +132 −22

* AI pages read the real catalogue: every Exchange model, its makers, prices and measured speed

The catalogue was read from ai.openvibe.services, whose vhost does not pass /api/v1/exchange/catalog
(404 every time), so the leaderboard showed three models with no maker, price or speed and the
providers page was empty. Report now reads it where it is public, api.openvibe.exchange.

- Bench's dated names ("gpt-5-nano-2025-08-07") meet the catalogue's ("openai/gpt-5-nano"): one join
  in server/data/ai-join.js, used by the pages and the API alike; the maker falls back to the name's prefix.
- Every model the Exchange sells is listed with its real prices, rated or not ("Not rated yet").
- Providers: the provider's name, reply time p50/p95, error rate, uptime and requests over 7 days,
  as the catalogue measures them; no always-empty tokens/s or first-token columns.
- Styles use Report's own tokens (the sheet named tokens Report does not define, so cards were white
  on the dark theme); the rating band bar that drew 30%-70% for every model is gone; phones show every
  cell under its label instead of hiding columns six and up.
- Top picks the data cannot make yet are named in one line instead of "Unknown model" cards.
- test/ai-api.test.js now calls done(), so its failures fail the run (it failed silently on main).

* A base rating with no games is not a rating

Bench starts every entrant at 1000; the leaderboard showed that as a rating on task boards that say
"No votes yet", and model pages listed seven "tasks it is good at" each at 1000 with no rank. With no
games the rating is null ("Not rated yet"), and a model page lists only tasks it is rated on, under
"Task ratings".

Changes

ddiff --git a/.env.example b/.env.example
iindex e47e2b4..da5732b 100644
---- a/.env.example
++++ b/.env.example
@@@ -27,7 +27,7 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
OV_BENCH_INTERNAL_URL=https://bench.openvibe.services
# OpenVibe.AI public Exchange catalogue and measured provider offers.
--OV_AI_INTERNAL_URL=https://ai.openvibe.services
++OV_AI_INTERNAL_URL=https://api.openvibe.exchange
# OpenVibe.Billing's read API. Report quotes Billing and never computes money. The service needs the grants
# billing.stats.read (GET /api/v1/stats) and billing.balance.read (GET /api/v1/transactions?subject=…).
ddiff --git a/README.md b/README.md
iindex d96fae1..f6e7c06 100644
---- a/README.md
++++ b/README.md
@@@ -182,7 +182,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).
| `OV_NETWORK_URL` | `https://openvibe.network` | the OAuth token endpoint's origin |
| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's loopback origin |
| `OV_BENCH_INTERNAL_URL` | `https://bench.openvibe.services` | Bench's public read origin, optionally an internal origin |
--| `OV_AI_INTERNAL_URL` | `https://ai.openvibe.services` | AI's public Exchange catalogue and measured provider offers |
++| `OV_AI_INTERNAL_URL` | `https://api.openvibe.exchange` | AI's public Exchange catalogue and measured provider offers (`/api/v1/exchange/catalog`; the ai.openvibe.services vhost does not pass it) |
| `OV_BILLING_INTERNAL_URL` | `http://127.0.0.1:4600` | Billing's loopback origin |
| `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` | `report` / unset | Report's client-credentials token for `openvibe.billing` |
| `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run |
ddiff --git a/public/css/ai-comparison.css b/public/css/ai-comparison.css
iindex b560743..ba93b60 100644
---- a/public/css/ai-comparison.css
++++ b/public/css/ai-comparison.css
@@@ -1,3 +1,3 @@
--.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(--room-line,var(--ov-border,#ddd))}.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(--room-line,var(--ov-border,#aaa));border-radius:.6rem;background:var(--room-surface,var(--ov-surface,#fff));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(--room-line,var(--ov-border,#ddd));border-radius:.85rem;color:inherit;text-decoration:none;background:var(--room-surface,var(--ov-surface,#fff))}.ai-maker{display:inline-flex;width:max-content;padding:.2rem .5rem;border-radius:999px;background:var(--room-fill,var(--ov-muted,#eee));font-size:.78rem}.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(--room-line,var(--ov-border,#ddd));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(--room-muted,var(--ov-muted-text,#666))}.ai-interval{display:block;position:relative;width:6rem;height:3px;margin-top:.35rem;background:var(--room-line,var(--ov-border,#ddd));border-radius:3px}.ai-interval i{position:absolute;height:3px;background:var(--room-accent,var(--ov-accent,#466));border-radius:3px}.ai-missing{color:var(--room-muted,var(--ov-muted-text,#666));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(--room-line,var(--ov-border,#ddd));border-radius:.75rem;background:var(--room-surface,var(--ov-surface,#fff));z-index:3}.ai-filters{padding:1rem;border:1px solid var(--room-line,var(--ov-border,#ddd));border-radius:.75rem}.ai-filters label{font-size:.85rem}details{padding:.7rem 0;border-bottom:1px solid var(--room-line,var(--ov-border,#ddd))}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(--room-line,var(--ov-border,#ddd))}.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:nth-child(n+3)::before{display:block;font-size:.72rem;color:var(--room-muted,var(--ov-muted-text,#666));content:attr(data-label)}.ai-table td:nth-child(3)::before{content:'Rating · Bench'}.ai-table td:nth-child(4)::before{content:'Price out / 1M'}.ai-table td:nth-child(5)::before{content:'Speed'}.ai-table td:nth-child(n+6){display:none}.ai-filters{display:grid;grid-template-columns:1fr 1fr}.ai-filters label{min-width:0}.ai-search input{flex:1;min-width:10rem}}
--.ai-interval i{left:30%;width:40%}
++.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}}
++
ddiff --git a/server/config.js b/server/config.js
iindex 17c9a67..4c866eb 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -50,7 +50,7 @@ function load(env = process.env) {
// OpenVibe.Bench's public reads (kinds, boards, models, datasets) — the AI boards under /ai. Public HTML
// boards render here; Bench stays the benchmark authority that owns the numbers.
benchUrl: trim(env.OV_BENCH_INTERNAL_URL || 'https://bench.openvibe.services'),
-- aiUrl: trim(env.OV_AI_INTERNAL_URL || 'https://ai.openvibe.services'),
++ aiUrl: trim(env.OV_AI_INTERNAL_URL || 'https://api.openvibe.exchange'),
// Report's own database (ADR-035). DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner
// role); development without DATABASE_URL uses an embedded PGlite database under REPORT_PGLITE_DIR.
ddiff --git a/server/data/ai-catalogue.js b/server/data/ai-catalogue.js
iindex 02c1ae3..ed4bdef 100644
---- a/server/data/ai-catalogue.js
++++ b/server/data/ai-catalogue.js
@@@ -4,7 +4,7 @@ const { requestJson, trim } = require('./source');
const TTL_MS = 120_000;
function createAiCatalogue({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs } = {}) {
-- const baseUrl = trim(config.aiUrl || 'https://ai.openvibe.services');
++ const baseUrl = trim(config.aiUrl || 'https://api.openvibe.exchange');
let hit = null;
let pending = null;
async function catalog() {
ddiff --git a/server/data/ai-join.js b/server/data/ai-join.js
nnew file mode 100644
iindex 0000000..b4f862f
---- /dev/null
++++ b/server/data/ai-join.js
@@@ -0,0 +1,25 @@
++'use strict';
++
++// Bench names a model as its maker's API does ("gpt-5-nano-2025-08-07"); the Exchange catalogue names it by
++// maker and family ("openai/gpt-5-nano"). They are the same model when the names agree once the maker prefix
++// and a date or channel suffix are dropped. Used by the AI pages and the AI API, so both join the same way.
++
++const bare = (v) => String(v || '').toLowerCase()
++ .replace(/^[^/]+\//, '')
++ .replace(/[-_@:](\d{4}-?\d{2}-?\d{2}|latest|free)$/, '');
++
++function findCatalogModel(models, id) {
++ const list = Array.isArray(models) ? models : [];
++ return list.find((m) => [m.slug, m.canonical, m.id].includes(id))
++ || list.find((m) => [m.slug, m.canonical, m.name, ...(m.offers || []).map((o) => o.model)].some((v) => v && bare(v) === bare(id)))
++ || null;
++}
++
++// The maker: the catalogue's own, else the prefix of the catalogue's name, else the prefix of Bench's name.
++function makerOf(cat, id) {
++ if (cat && cat.maker) return cat.maker;
++ const named = String(cat && (cat.canonical || cat.slug) || id || '');
++ return named.includes('/') ? named.split('/')[0] : null;
++}
++
++module.exports = { bare, findCatalogModel, makerOf };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 8401996..2398405 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -20,6 +20,7 @@
* /api/v1/u/:name/stats is public and carries only the sections that person published.
*/
const express = require('express');
++const { findCatalogModel, makerOf } = require('../data/ai-join');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
@@@ -113,9 +114,10 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
const list = board && board.data && board.data.board && board.data.board.rows || [];
for (const entry of list) {
const id = entry.subject || entry.model || entry.name;
-- const catalogue = catalogModels.find((m) => m.slug === id || m.canonical === id || m.id === id) || null;
-- const elo = entry.elo != null && Number.isFinite(Number(entry.elo)) ? Number(entry.elo) : null;
-- rows.push({ id, kind: kinds[i].id, maker: catalogue && (catalogue.maker || (String(catalogue.slug || '').includes('/') ? String(catalogue.slug).split('/')[0] : null)) || null, weights: catalogue && (catalogue.weights || (catalogue.open_weights === true ? 'open' : null)) || null,
++ const catalogue = findCatalogModel(catalogModels, id);
++ // Bench starts every entrant at its base rating; with no games that number is not a rating.
++ const elo = entry.elo != null && Number.isFinite(Number(entry.elo)) && Number(entry.games) !== 0 ? Number(entry.elo) : null;
++ rows.push({ id, kind: kinds[i].id, maker: makerOf(catalogue, id), weights: catalogue && (catalogue.weights || (catalogue.open_weights === true ? 'open' : null)) || null,
status: catalogue && catalogue.status || 'current', uncensored: catalogue && catalogue.uncensored === true,
community: Boolean(catalogue && catalogue.community), rating: elo == null ? { value: null, reason: 'Bench has no rating.' } : { value: elo, interval: [entry.ci_lower ?? null, entry.ci_upper ?? null], source: 'OpenVibe.Bench', date: board.at, sample_size: entry.games ?? null },
benchmark: entry.score == null ? { value: null, reason: 'No benchmark score.' } : { value: entry.score, source: 'OpenVibe.Bench', date: board.at, sample_size: entry.runs ?? null },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a39ffc0..a88abf6 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -25,6 +25,7 @@
* Markdown twin render the very same page; `agent: true` in those views adds the discovery links.
*/
const express = require('express');
++const { findCatalogModel, makerOf } = require('../data/ai-join');
const contracts = require('openvibe-contracts');
const views = require('../views');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
@@@ -64,13 +65,14 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
const kinds = kindsResult && kindsResult.data && kindsResult.data.kinds || [];
const boards = await Promise.all(kinds.map((kind) => bench.board(kind.id, 'elo')));
const catModels = (catalogue && catalogue.models || []).map((model) => ({ ...model, offers: (model.offers || []).map((offer) => ({ ...offer, source: offer.source || { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: offer.requests_7d ?? null } })) }));
++ const findCat = (id) => findCatalogModel(catModels, id);
const rows = [];
for (let i = 0; i < boards.length; i += 1) {
const list = boards[i] && boards[i].data && boards[i].data.board && boards[i].data.board.rows || [];
for (const entry of list) {
const id = entry.subject || entry.model || entry.name;
-- const cat = catModels.find((m) => [m.slug, m.canonical, m.id].includes(id));
-- rows.push({ id, kind: kinds[i].id, maker: cat && (cat.maker || String(id).split('/')[0]), 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 ? null : Number(entry.elo), interval: [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 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 } });
}
}
const grouped = new Map();
@@@ -81,6 +83,16 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
}
rows.length = 0;
rows.push(...grouped.values());
++ // Every model the Exchange sells is listed with its prices and speed, rated or not: a model without
++ // Bench games yet shows as not rated, never as missing.
++ const ratedCats = new Set(rows.map((row) => findCat(row.id)).filter(Boolean));
++ for (const cat of catModels) {
++ if (ratedCats.has(cat)) continue;
++ const id = cat.slug || cat.canonical || cat.id;
++ if (!id) continue;
++ const 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 });
++ }
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));
ranked.forEach(({ task }, i) => {
ddiff --git a/server/views/ai-comparison.js b/server/views/ai-comparison.js
iindex 745800c..01f65d8 100644
---- a/server/views/ai-comparison.js
++++ b/server/views/ai-comparison.js
@@@ -10,16 +10,17 @@ const sourceHint = (s) => { const x = s && typeof s === 'object' ? s : { name: s
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>${lo != null && hi != null ? `<span class="ai-interval" role="img" aria-label="Rating interval ${Math.round(lo)} to ${Math.round(hi)}"><i></i></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>${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>${interval(r)}${r.kind ? `<small>${esc(r.kind)}</small>` : ''}</td>${full ? `<td>${r.benchmark && value(r.benchmark.value) != null ? esc(r.benchmark.value) : '<span class="ai-missing">Not enough data</span>'}</td><td>${priceCell(o, 'in')}</td>` : ''}<td>${priceCell(o, 'out')}</td>${full ? `<td>${o.cost_per_solved == null ? 'Not measured yet' : money(o.cost_per_solved)}</td>` : ''}<td>${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>${o.ttft_ms && value(o.ttft_ms.value) != null ? `${esc(o.ttft_ms.value)} ms` : 'Not measured yet'}</td><td>${o.uptime == null ? 'Not measured yet' : `${Math.round(Number(o.uptime) * 100)}%`}</td><td>${o.context || 'Not measured yet'}</td><td>${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>' : ''}`;
++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 pickItems = picks.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 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';
@@@ -32,7 +33,7 @@ function hub({ rows = [], picks = [], providers = [], month = new Date().toLocal
`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><ul class="ai-picks">${pickItems || '<li>Pick data is not available yet.</li>'}</ul></section>
++ <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>
@@@ -45,15 +46,23 @@ function hub({ rows = [], picks = [], providers = [], month = new Date().toLocal
return page(`AI model leaderboard — ${month}`, 'AI model ratings, provider prices and measured performance.', body, '/ai');
}
function leaderboard({ rows = [], filters = {} } = {}) { const select=(name,label,opts)=>`<label>${label}<select name="${name}">${opts.map(([v,t])=>`<option value="${v}"${String(filters[name]||'')===v?' selected':''}>${t}</option>`).join('')}</select></label>`; return page('AI model leaderboard', 'Browse and filter measured AI model results.', `<header><h1>AI model leaderboard</h1><form class="ai-filters" action="/ai/leaderboard"><label>Search<input name="q" type="search" value="${esc(filters.q || '')}"></label><label>Maker<input name="maker" value="${esc(filters.maker || '')}"></label><label>Task kind<input name="kind" value="${esc(filters.kind || '')}"></label>${select('weights','Weights',[['','Any'],['open','Open weights'],['closed','Closed']])}${select('price','Price band',[['','Any price'],['free','Free'],['low','Low'],['mid','Mid'],['high','High']])}${select('status','Status',[['current','Current'],['all','All']])}${select('uncensored','Uncensored',[['','Any'],['true','18+ flagged']])}${select('community','Community machines',[['','Any'],['true','Community']])}${select('sort','Sort by',[['rating','Rating'],['name','Name'],['price','Output price'],['speed','Speed']])}<button>Apply filters</button></form>${table(rows,true)}<p><a href="/api/v1/ai/leaderboard">JSON</a> · <a href="/api/v1/ai/leaderboard?format=csv&amp;limit=100">CSV</a></p></header>`, '/ai/leaderboard'); }
--function providers({ providers = [] } = {}) { return page('AI providers', 'Provider and model price, speed and reliability measurements.', `<h1>AI providers</h1><p class="rp-lede">Provider measurements come from OpenVibe.AI. “Not measured yet” means that value is not reported by the source.</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>Tokens/s</th><th>First token</th><th>Error rate</th><th>Uptime 7d</th></tr></thead><tbody>${providers.map(p=>`<tr><td>${esc(p.provider || p.name || 'Provider')}</td><td><a href="${idPath(p.model)}">${esc(p.model)}</a></td><td>${priceCell(p, 'in')}</td><td>${priceCell(p, 'out')}</td><td>Not measured yet</td><td>Not measured yet</td><td>${p.error_rate && value(p.error_rate.value)!=null?`${Math.round(p.error_rate.value*100)}%`:'Not measured yet'}</td><td>${p.uptime==null?'Not measured yet':`${Math.round(Number(p.uptime)*100)}%`}</td></tr>`).join('')}</tbody></table></div>`, '/ai/providers'); }
++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 } = {}) {
detail = detail || {};
const offers = providers.filter((p) => p.model === id);
const bands = row ? interval(row) : '';
-- const taskRatings = row && row.task_ratings || [];
-- 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 enough task rating data yet.</p>';
++ 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>Tasks it is good at</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));
++ 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'); }
ddiff --git a/test/ai-api.test.js b/test/ai-api.test.js
iindex 1f216c3..4779562 100644
---- a/test/ai-api.test.js
++++ b/test/ai-api.test.js
@@@ -1,6 +1,6 @@
'use strict';
const assert = require('assert');
--const { boot, check } = require('./helpers/boot');
++const { boot, check, done } = require('./helpers/boot');
(async () => {
const bench = {
@@@ -36,4 +36,5 @@ const { boot, check } = require('./helpers/boot');
assert.ok(csv.text.startsWith('"id","maker","rating"'));
});
} finally { await t.close(); }
++ done();
})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/test/ai-catalogue-join.test.js b/test/ai-catalogue-join.test.js
nnew file mode 100644
iindex 0000000..ce020c8
---- /dev/null
++++ b/test/ai-catalogue-join.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++
++// Bench names a model as its maker's API does ("gpt-5-nano-2025-08-07"), the Exchange catalogue by maker
++// and family ("openai/gpt-5-nano"). The leaderboard joins the two, and lists the catalogue's models that
++// have no Bench games yet with their real prices, as not rated.
++const assert = require('assert');
++const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot');
++
++(async () => {
++ const source = stubFetch();
++ const fetchImpl = async (url, opts) => {
++ const u = new URL(String(url));
++ if (u.port === '5231' && u.pathname === '/api/v1/exchange/catalog') {
++ return jsonResponse({ generated_at: '2026-11-09T19:00:00Z', models: [
++ { slug: 'openai/gpt-5-nano', canonical: 'openai/gpt-5-nano', name: 'gpt-5-nano', kind: 'chat', open_weights: false,
++ offers: [{ provider: 'openai', provider_name: 'OpenAI', model: 'gpt-5-nano', price: { in: 0.05, out: 0.4 }, speed: { p50_ms: 900 } }] },
++ { slug: 'deepseek/deepseek-chat', canonical: 'deepseek/deepseek-chat', name: 'deepseek-chat', kind: 'chat', open_weights: true,
++ offers: [{ provider: 'deepseek', provider_name: 'DeepSeek', model: 'deepseek-chat', price: { in: 0.28, out: 0.42 } }] },
++ ] });
++ }
++ if (u.port !== '5230') return source(url, opts);
++ if (u.pathname === '/api/v1/kinds') return jsonResponse({ count: 1, kinds: [{ id: 'design.dataviz', room: 'design', name: 'Data viz' }] });
++ if (u.pathname === '/api/v1/boards/design.dataviz') {
++ if (u.searchParams.get('type') !== 'elo') return jsonResponse({ board: { kind_id: 'design.dataviz', rows: [] } });
++ return jsonResponse({ board: { kind_id: 'design.dataviz', type: 'elo', rows: [{ subject: 'gpt-5-nano-2025-08-07', elo: 1000, ci_lower: 960, ci_upper: 1040, games: 4 }, { subject: 'model-zero', elo: 1000, games: 0 }] } });
++ }
++ return jsonResponse({}, 404);
++ };
++ const t = await boot({ fetchImpl, env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230', OV_AI_INTERNAL_URL: 'http://127.0.0.1:5231' } });
++ try {
++ await check('a dated Bench name meets its catalogue model: maker and prices', async () => {
++ const r = await t.get('/ai/leaderboard');
++ assert.strictEqual(r.status, 200);
++ 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');
++ });
++ await check('a catalogue model without Bench games is listed, not rated', async () => {
++ const r = await t.get('/ai/leaderboard');
++ assert.ok(r.text.includes('deepseek/deepseek-chat'), 'the unrated model is listed');
++ assert.ok(r.text.indexOf('gpt-5-nano-2025-08-07') < r.text.indexOf('deepseek/deepseek-chat'), 'rated models come first');
++ assert.ok(r.text.includes('$0.28'), 'with its real price');
++ });
++ await check('a base rating with no games is not a rating', async () => {
++ const r = await t.get('/api/v1/ai/leaderboard');
++ const zero = r.json().rows.find((x) => x.id === 'model-zero');
++ assert.ok(zero, 'listed');
++ assert.strictEqual(zero.rating.value, null);
++ const page = await t.get('/ai/models/model-zero');
++ assert.ok(page.text.includes('Not rated on any task yet'), 'the model page says so');
++ });
++ await check('the providers page lists every offer', async () => {
++ const r = await t.get('/ai/providers');
++ assert.ok(r.text.includes('OpenAI') && r.text.includes('DeepSeek'), 'both providers');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/test/ai-section.test.js b/test/ai-section.test.js
iindex 0ab5b52..7c58c63 100644
---- a/test/ai-section.test.js
++++ b/test/ai-section.test.js
@@@ -105,7 +105,7 @@ const legacy = { model: 'model-a', ratings: [{ kind_id: 'design.page', elo: 1043
await check('one model page uses the comparison profile and available Bench task data', async () => {
const r = await t.get('/ai/models/model-a');
assert.strictEqual(r.status, 200);
-- for (const s of ['30-day rating', 'Not enough data', 'Tasks it is good at', '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 ['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);
assert.ok(!r.text.includes('&lt;span title='), 'a stat is escaped HTML, not markup');
});