openvibers/openvibe.report Commits
AI pages read the real catalogue: every Exchange model, its makers, prices and measured speed (#161)
Changes
@@@ -27,7 +27,7 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000OV_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=…).@@@ -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 |@@@ -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}}++@@@ -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.@@@ -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() {nnew file mode 100644@@@ -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 };@@@ -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, analyticsconst 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 },@@@ -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) => {@@@ -10,16 +10,17 @@ const sourceHint = (s) => { const x = s && typeof s === 'object' ? s : { name: sconst 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().toLocalreturn 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 = [] } = {}) { 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'); }@@@ -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; });nnew file mode 100644@@@ -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; });@@@ -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', '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('<span title='), 'a stat is escaped HTML, not markup');});
@@ -27,7 +27,7 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
@@ -27,7 +27,7 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
OV_BENCH_INTERNAL_URL=https://bench.openvibe.services
OV_BENCH_INTERNAL_URL=https://bench.openvibe.services
# OpenVibe.AI public Exchange catalogue and measured provider offers.
# 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
# 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=…).
# billing.stats.read (GET /api/v1/stats) and billing.balance.read (GET /api/v1/transactions?subject=…).
@@ -182,7 +182,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).
@@ -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_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_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_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_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` |
| `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 |
| `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run |
@@ -1,3 +1,3 @@
@@ -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}}
+
@@ -50,7 +50,7 @@ function load(env = process.env) {
@@ -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
// 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.
// boards render here; Bench stays the benchmark authority that owns the numbers.
benchUrl: trim(env.OV_BENCH_INTERNAL_URL || 'https://bench.openvibe.services'),
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
// 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.
// role); development without DATABASE_URL uses an embedded PGlite database under REPORT_PGLITE_DIR.
@@ -4,7 +4,7 @@ const { requestJson, trim } = require('./source');
@@ -4,7 +4,7 @@ const { requestJson, trim } = require('./source');
const TTL_MS = 120_000;
const TTL_MS = 120_000;
function createAiCatalogue({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs } = {}) {
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 hit = null;
let pending = null;
let pending = null;
async function catalog() {
async function catalog() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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 };
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
* /api/v1/u/:name/stats is public and carries only the sections that person published.
* /api/v1/u/:name/stats is public and carries only the sections that person published.
*/
*/
const express = require('express');
const express = require('express');
+const { findCatalogModel, makerOf } = require('../data/ai-join');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
@@ -113,9 +114,10 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
@@ -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 || [];
const list = board && board.data && board.data.board && board.data.board.rows || [];
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 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,
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 },
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 },
benchmark: entry.score == null ? { value: null, reason: 'No benchmark score.' } : { value: entry.score, source: 'OpenVibe.Bench', date: board.at, sample_size: entry.runs ?? null },
@@ -25,6 +25,7 @@
@@ -25,6 +25,7 @@
* Markdown twin render the very same page; `agent: true` in those views adds the discovery links.
* Markdown twin render the very same page; `agent: true` in those views adds the discovery links.
*/
*/
const express = require('express');
const express = require('express');
+const { findCatalogModel, makerOf } = require('../data/ai-join');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const views = require('../views');
const views = require('../views');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
@@ -64,13 +65,14 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
@@ -64,13 +65,14 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
const kinds = kindsResult && kindsResult.data && kindsResult.data.kinds || [];
const kinds = kindsResult && kindsResult.data && kindsResult.data.kinds || [];
const boards = await Promise.all(kinds.map((kind) => bench.board(kind.id, 'elo')));
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 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 = [];
const rows = [];
for (let i = 0; i < boards.length; i += 1) {
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 || [];
const list = boards[i] && boards[i].data && boards[i].data.board && boards[i].data.board.rows || [];
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 = 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();
const grouped = new Map();
@@ -81,6 +83,16 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
@@ -81,6 +83,16 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
}
}
rows.length = 0;
rows.length = 0;
rows.push(...grouped.values());
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) {
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));
ranked.forEach(({ task }, i) => {
ranked.forEach(({ task }, i) => {
@@ -10,16 +10,17 @@ const sourceHint = (s) => { const x = s && typeof s === 'object' ? s : { name: s
@@ -10,16 +10,17 @@ const sourceHint = (s) => { const x = s && typeof s === 'object' ? s : { name: s
const idPath = (id) => `/ai/models/${encodeURIComponent(id || '')}`;
const idPath = (id) => `/ai/models/${encodeURIComponent(id || '')}`;
const label = (id) => String(id || 'Unknown model');
const label = (id) => String(id || 'Unknown model');
const mark = (maker) => `<span class="ai-maker">${esc(maker || 'Maker not listed')}</span>`;
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 firstOffer = (row) => row.provider_offers && row.provider_offers[0] || {};
const metric = (content, source) => `<span title="${esc(sourceHint(source))}">${content}</span>`;
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 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 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 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 } = {}) {
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 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 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 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 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
@@ -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.`
`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>
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><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>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>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
@@ -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');
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 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 = [] } = {}) { 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 } = {}) {
function model({ id, detail = {}, row = null, providers = [], sourceDate = null } = {}) {
detail = detail || {};
detail = detail || {};
const offers = providers.filter((p) => p.model === id);
const offers = providers.filter((p) => p.model === id);
const bands = row ? interval(row) : '';
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>` : '';
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 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 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'); }
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
const assert = require('assert');
const assert = require('assert');
-const { boot, check } = require('./helpers/boot');
+const { boot, check, done } = require('./helpers/boot');
(async () => {
(async () => {
const bench = {
const bench = {
@@ -36,4 +36,5 @@ const { boot, check } = require('./helpers/boot');
@@ -36,4 +36,5 @@ const { boot, check } = require('./helpers/boot');
assert.ok(csv.text.startsWith('"id","maker","rating"'));
assert.ok(csv.text.startsWith('"id","maker","rating"'));
});
});
} finally { await t.close(); }
} finally { await t.close(); }
+ done();
})().catch((error) => { console.error(error); process.exitCode = 1; });
})().catch((error) => { console.error(error); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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; });
@@ -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', '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('<span title='), 'a stat is escaped HTML, not markup');
assert.ok(!r.text.includes('<span title='), 'a stat is escaped HTML, not markup');
});
});