openvibers/openvibe.report Commits
AI boards over Bench: /ai, per-kind Elo, Pareto and cost per solved, per-model pages (#5)
Changes
@@@ -23,6 +23,9 @@ OV_NETWORK_URL=https://openvibe.network# OpenVibe.Live's Rundown and after-show reports (GET /api/rundown/*, /api/recap/*).OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000++# Bench's public read API; set to an internal origin if Report and Bench share one.++OV_BENCH_INTERNAL_URL=https://bench.openvibe.services++# 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=…).OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600@@@ -33,6 +33,7 @@ OpenVibe.Live in both modes.| **Report's tables** | `report_metric_days`, `report_metric_windows`, `report_streams` | the same routes while `REPORT_SOURCE=report` || **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` || **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |++| **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/models/:model` | `/ai`, `/ai/:kind`, `/ai/models/:model` || **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts |A source that does not answer is null, after one short log line; the page (or API route) becomes a 404. The service@@@ -72,9 +73,14 @@ All server-rendered in `server/views/index.js`; every dynamic value goes through| `/rundown/s/:username` | one streamer's episodes and topics || `/after-show/:streamId` | one after-show report || `/c/:username` | one channel's latest after-show reports |++| `/ai` | benchmark kinds grouped by room, with leaders and game counts |++| `/ai/:kind` | Elo, Pareto quality against cost per solved, and cost per solved tables |++| `/ai/models/:model` | a model's ratings and entries across kinds |A bad path parameter (slug, stream id, username) or a source that answered nothing renders `views.notFound()` withstatus 404. Pages are `Cache-Control: public, max-age=120`.++AI pages use a 60-second Bench read cache and serve its last good copy with the read time during an outage. Their++HTML is `no-store` so the outage notice stays current.## API@@@ -135,6 +141,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | Network's loopback origin (its manifest's `internalOrigin`) || `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_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 |@@@ -32,6 +32,25 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.rp-tabs { display: inline-flex; gap: 4px; margin-top: 16px; padding: 4px; border-radius: 999px; background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); }.rp-tabs a { padding: 7px 14px; border-radius: 999px; color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: .86rem; font-weight: 700; }.rp-tabs a[aria-current] { background: var(--room-fill); color: var(--room-on); }++.rp-ai-room { margin-top: 28px; }++.rp-ai-room h2 { margin-bottom: 12px; }++.rp-ai-room .rp-topic h3 { margin: 0; font-size: 1.05rem; }++.rp-ai-room .rp-topic p { margin: 0; }++.rp-ai-room .rp-topic .rp-fine { margin: 0; }++.rp-ai-scroll { overflow-x: auto; margin: 20px 0; border: 1px solid var(--border, #1f2d47); border-radius: 16px; }++.rp-ai-table { width: 100%; border-collapse: collapse; min-width: 650px; font-variant-numeric: tabular-nums; }++.rp-ai-table th, .rp-ai-table td { padding: 11px 14px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border, #1f2d47); }++.rp-ai-table thead th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted, #7386a3); }++.rp-ai-table tbody tr:last-child > * { border-bottom: 0; }++.rp-ai-table a { overflow-wrap: anywhere; }++.rp-flag { display: inline-block; font-size: .72rem; color: var(--room-strong); margin-left: 5px; }++.rp-ai-chart { margin: 20px 0; padding: 12px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-card, #0f1729); }++.rp-ai-chart svg { display: block; width: 100%; height: auto; }++.rp-ai-chart line { stroke: var(--text-muted, #7386a3); stroke-width: 1; }++.rp-ai-chart text { fill: var(--text-secondary, #96a7c2); font-size: 11px; }++.rp-ai-chart .rp-ai-frontier { fill: var(--room-accent); stroke: var(--bg-card, #0f1729); stroke-width: 1; }++.rp-ai-chart .rp-ai-point { fill: var(--text-muted, #7386a3); }++.rp-ai-chart figcaption { margin-top: 6px; font-size: .8rem; color: var(--text-muted, #7386a3); }.rp-counts { display: flex; flex-wrap: wrap; gap: 6px 20px; margin: 14px 0 0; color: var(--text-secondary, #96a7c2); }.rp-counts b { color: var(--text-primary, #e6edf7); font-size: 1.15rem; font-variant-numeric: tabular-nums; }@@@ -131,3 +150,15 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.rp-eps dt code { font: 600 .92rem/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--room-strong); overflow-wrap: anywhere; }.rp-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }@media (prefers-reduced-motion: reduce) { .rp-topic { transition: none; } }++++/* AI boards: rooms side by side, tables that fit a phone, and a chart that scrolls rather than shrinking its labels */++.rp-ai-rooms { display: grid; gap: 0 24px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); align-items: start; }++.rp-ai-rooms .rp-topics { grid-template-columns: 1fr; }++.rp-ai-table a { text-decoration: none; font-weight: 700; }++.rp-ai-table a:hover, .rp-ai-table a:focus-visible { text-decoration: underline; }++.rp-ai-chart { overflow-x: auto; }++@media (max-width: 640px) {++ .rp-ai-table { min-width: 0; font-size: .86rem; }++ .rp-ai-table th, .rp-ai-table td { padding: 9px 7px; }++ .rp-ai-chart svg { min-width: 720px; }++}@@@ -6,6 +6,7 @@** /, /stats, /rundown, /rundown/t/:slug, /rundown/e/:streamId, /rundown/s/:username,* /after-show/:streamId, /c/:username the pages (http/pages.js)++ * /ai, /ai/models/:model, /ai/:kind the AI boards, from OpenVibe.Bench (http/pages.js)* /api/v1/* the JSON API (http/api.js)* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)* /api/health, /api/ready, /release.json, /release-metrics the plumbing (loopback scrape for /metrics)@@@ -26,6 +27,7 @@ const { openStore } = require('./db');const { createNetworkClient } = require('./data/network');const { createLiveClient } = require('./data/live');const { createBillingClient } = require('./data/billing');++const { createBenchClient } = require('./data/bench');const { createAnalytics } = require('./analytics');const { createShadow } = require('./analytics/shadow');const { createApi } = require('./http/api');@@@ -51,11 +53,12 @@ async function createApp(opts = {}) {const network = opts.network || createNetworkClient({ config, fetchImpl, log, now });const live = opts.live || createLiveClient({ config, fetchImpl, log, now });const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });++ const bench = opts.bench || createBenchClient({ config, fetchImpl, log, now });const analytics = opts.analytics || createAnalytics({ config, store, network, billing, now, log });const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);views.configure({ baseUrl: config.baseUrl });-- const ctx = { config, log, fetchImpl, now, store, network, live, billing, analytics, shadow };++ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow };const app = express();app.disable('x-powered-by');app.set('trust proxy', config.trustProxy);@@@ -7,6 +7,7 @@* Report reads Network's open stats and Live's Rundown and after-show reports over loopback. It also has its own* PostgreSQL database (migrations/) for the analytics it ingests, and reads revenue from Billing. REPORT_SOURCE* chooses which side the pages and GET /api/v1/stats answer from: `network` (today's behaviour) or `report`.++ * The AI boards under /ai read OpenVibe.Bench's public boards the same way.*/require('dotenv').config();@@@ -44,6 +45,10 @@ function load(env = process.env) {networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),liveUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),++ // 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'),++// 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.db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.REPORT_PGLITE_DIR || '' },nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++++/**++ * OpenVibe.Bench's public reads (plan 2026-10-28 §4.12):++ * GET /api/v1/kinds every benchmark kind++ * GET /api/v1/boards/:kind?type=elo|pareto|cost++ * GET /api/v1/models/:model one model across every kind++ *++ * Reads are public and held for sixty seconds in this process. A read that has answered once keeps its copy++ * for as long as Bench is down: the client answers the last good one with the moment it was read (`at`) and++ * `stale: true`, so a board page stays a page — never an error page. A read that has never answered is null++ * after one short log line. A kind id, board type or model name that cannot exist is refused before any request++ * is made.++ */++const { trim, requestJson, TIMEOUT_MS } = require('./source');++++const TTL_MS = 60_000;++const KIND_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/;++const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:+\/~\-]{0,119}$/;++const BOARD_TYPES = Object.freeze(['elo', 'pareto', 'cost']);++++const isKindId = (value) => typeof value === 'string' && KIND_RE.test(value);++++/** A model as Bench pins it: one path segment (its own /models/:model route cannot serve more), 120 characters at most. */++const isModelName = (value) => typeof value === 'string' && MODEL_RE.test(value);++++/** ?tab=elo|pareto|cost; anything else is the Elo board, exactly as the page tabs read it. */++function normaliseTab(value) {++ const raw = Array.isArray(value) ? value[0] : value;++ return BOARD_TYPES.includes(raw) ? raw : 'elo';++}++++/**++ * opts: config ({ benchUrl }), fetchImpl, log, now, timeoutMs++ */++function createBenchClient({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs = TIMEOUT_MS } = {}) {++ const baseUrl = trim(config.benchUrl || 'http://127.0.0.1:5230');++ const clock = typeof now === 'function' ? now : () => Date.now();++++ // The last good copy of every read, kept beyond its minute so a Bench outage never empties a page. Kinds and++ // boards are a fixed small set; models are capped, the oldest inserted out first.++ const boards = new Map();++ const models = new Map();++++ function keep(store, key, data, cap) {++ store.set(key, data);++ while (store.size > cap) store.delete(store.keys().next().value);++ }++++ /** One read: the fresh copy, the last good one (stale) while Bench is down, or null when there is none yet. */++ async function read(store, key, path, label, cap) {++ const atMs = clock();++ const hit = store.get(key);++ if (hit && hit.atMs + TTL_MS > atMs) return { data: hit.data, at: hit.at, stale: false };++ if (hit && hit.retryAtMs > atMs) return { data: hit.data, at: hit.at, stale: true };++ const data = await requestJson(`${baseUrl}${path}`, { fetchImpl, log, timeoutMs, label });++ if (data === null) {++ if (hit) hit.retryAtMs = atMs + TTL_MS;++ return hit ? { data: hit.data, at: hit.at, stale: true } : null;++ }++ const entry = { data, atMs, at: new Date(atMs).toISOString() };++ keep(store, key, entry, cap);++ return { data, at: entry.at, stale: false };++ }++++ /** Every kind: { count, kinds: [{ id, room, entry, judge, name, metrics, created_at }] }. */++ function kinds() {++ return read(boards, 'kinds', '/api/v1/kinds', 'bench kinds', 500);++ }++++ /** One kind's board: { board: { kind_id, category, period, type, rows } }. */++ async function board(kindId, type = 'elo') {++ if (!isKindId(kindId) || !BOARD_TYPES.includes(type)) return null;++ return read(++ boards,++ `board:${kindId}:${type}`,++ `/api/v1/boards/${encodeURIComponent(kindId)}?type=${type}`,++ `bench board (${kindId}, ${type})`,++ 500,++ );++ }++++ /** One model across every kind: { model, ratings, entries, total_cost_usd }. */++ async function modelSummary(model) {++ if (!isModelName(model)) return null;++ return read(models, `model:${model}`, `/api/v1/models/${encodeURIComponent(model)}`, `bench model (${model})`, 200);++ }++++ return { kinds, board, modelSummary, baseUrl, ttlMs: TTL_MS, isKindId, isModelName, normaliseTab };++}++++module.exports = { createBenchClient, TTL_MS, BOARD_TYPES, isKindId, isModelName, normaliseTab };@@@ -32,7 +32,7 @@ function siteUpdated() {}}--function createDiscoveryRoutes({ config, live }) {++function createDiscoveryRoutes({ config, live, bench }) {const r = express.Router();const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');const abs = (p) => `${site}${p}`;@@@ -45,13 +45,18 @@ function createDiscoveryRoutes({ config, live }) {});r.get('/sitemap.xml', async (_req, res) => {-- const data = await live.topics();++ const [data, ai] = await Promise.all([live.topics(), bench.kinds()]);const topics = (data && Array.isArray(data.topics)) ? data.topics : [];++ const kinds = (ai && Array.isArray(ai.data.kinds)) ? ai.data.kinds : [];const lastmod = siteUpdated();const pages = [{ loc: abs('/'), changefreq: 'daily', priority: 1.0 },{ loc: abs('/stats'), changefreq: 'daily', priority: 0.9 },{ loc: abs('/rundown'), changefreq: 'daily', priority: 0.9 },++ { loc: abs('/ai'), changefreq: 'daily', priority: 0.9 },++ ...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({++ loc: abs(`/ai/${encodeURIComponent(k.id)}`), changefreq: 'daily', priority: 0.7,++ })),...topics.filter((t) => t && typeof t.slug === 'string').map((t) => ({loc: abs(`/rundown/t/${encodeURIComponent(t.slug)}`),changefreq: 'weekly',@@@ -71,6 +76,7 @@ function createDiscoveryRoutes({ config, live }) {{ title: 'OpenVibe.Report', url: abs('/'), note: 'the Report room' },{ title: 'Open numbers', url: abs('/stats'), note: 'page views, accounts and traffic for every OpenVibe site' },{ title: 'The Rundown', url: abs('/rundown'), note: 'what streamers talked about, topic by topic' },++ { title: 'AI boards', url: abs('/ai'), note: 'Bench ratings, quality and cost across rooms' },] },{ title: 'Machine-readable', links: [{ title: 'Open stats (JSON)', url: abs('/api/v1/stats?days=7'), note: '?days=7, 30 or 90' },@@@ -21,11 +21,11 @@ const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('.const CACHE = 'public, max-age=120';--function createPageRoutes({ network, live, analytics = null }) {++function createPageRoutes({ network, live, bench, analytics = null }) {const r = express.Router();-- function send(res, html, status = 200) {-- res.status(status).set('Cache-Control', CACHE).type('html').send(html);++ function send(res, html, status = 200, cacheControl = CACHE) {++ res.status(status).set('Cache-Control', cacheControl).type('html').send(html);}function notFound(res) {return send(res, views.notFound(), 404);@@@ -50,6 +50,30 @@ function createPageRoutes({ network, live, analytics = null }) {return send(res, views.stats({ stats, days }));});++ r.get('/ai', async (_req, res) => {++ const kinds = await bench.kinds();++ const list = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];++ const boards = await Promise.all(list.map((kind) => bench.board(kind.id, 'elo')));++ return send(res, views.aiIndex({ kinds, boards }), 200, 'no-store');++ });++++ r.get('/ai/models/:model', async (req, res) => {++ const model = req.params.model;++ if (!bench.isModelName(model)) return notFound(res);++ const summary = await bench.modelSummary(model);++ return send(res, views.aiModel({ model, summary }), 200, 'no-store');++ });++++ r.get('/ai/:kind', async (req, res) => {++ const kindId = req.params.kind;++ if (!bench.isKindId(kindId)) return notFound(res);++ const tab = bench.normaliseTab(req.query.tab);++ const [kinds, board, cost] = await Promise.all([bench.kinds(), bench.board(kindId, tab), tab === 'pareto' ? bench.board(kindId, 'cost') : Promise.resolve(null)]);++ const kind = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds.find((k) => k.id === kindId) : null;++ if (kinds && !kind) return notFound(res);++ return send(res, views.aiBoard({ kindId, kind, tab, board, cost, kinds }), 200, 'no-store');++ });++r.get('/rundown', async (_req, res) => {const [rundown, topics] = await Promise.all([live.rundown(), live.topics()]);if (!rundown || !topics) return notFound(res);@@@ -18,6 +18,7 @@ const { asset } = require('../assets');const NAME = 'OpenVibe.Report';const NETWORK = 'https://openvibe.network';++const BENCH = 'https://bench.openvibe.services';const ROOM = rooms.get('report');const esc = kit.esc;let RELEASE = 'dev';@@@ -38,9 +39,40 @@ const pct = (v) => { const x = n(v); if (x == null) return ''; return `<span claconst who = (u) => (u ? u.display_name || u.username || '' : '');const AI = '<span class="rp-ai" title="Written by AI from the stream\'s own speech, chat and numbers">Written by AI</span>';++// ── The AI boards (OpenVibe.Bench, plan 2026-10-28 §4.12): every number is Bench's; Report renders it ──++/** Money as Bench quotes it (Report never derives a price): dollars, cents, or fractions of a cent. */++const usd = (v) => {++ const x = n(v);++ if (x == null) return '—';++ if (!x) return '$0';++ const a = Math.abs(x);++ if (a >= 1000) return `$${x.toLocaleString('en-US', { maximumFractionDigits: 0 })}`;++ if (a >= 1) return `$${x.toFixed(2)}`;++ return `$${Number(x.toPrecision(2))}`;++};++/** A share of games won or tasks passed: 0.8333 → 83%. */++const share = (v) => { const x = n(v); return x == null ? '—' : `${Math.round(x * 100)}%`; };++/** The rating with its 95% interval as text — "1043 ± 38" — the exact bounds in the title. */++function rating(r) {++ const elo = n(r && r.elo);++ if (elo == null) return '—';++ const lo = n(r && r.ci_lower), hi = n(r && r.ci_upper);++ if (lo == null || hi == null) return `${Math.round(elo)}`;++ const half = Math.round(Math.max(Math.abs(hi - elo), Math.abs(elo - lo)));++ return `<span title="95% interval ${Math.round(lo)}–${Math.round(hi)} Elo">${Math.round(elo)} ± ${half}</span>`;++}++/** Provisional: Bench marks it on the boards; a summary's games say the same thing (fewer than ten). */++const provisional = (r) => Boolean(r && (r.provisional != null ? r.provisional : n(r.games) != null && Number(r.games) < 10));++const flag = (r) => (provisional(r) ? ' <span class="rp-flag" title="Fewer than ten games: this rating can still move a lot">provisional</span>' : '');++const roomName = (id) => { const s = String(id || ''); return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; };++const judgeWord = (j) => ({ human_pairwise: 'voted on blind, two at a time', rubric: 'scored on a rubric', automatic_grader: 'graded automatically', test_suite: 'judged by a test suite', game_outcome: 'judged by the game outcome' }[j] || j || '—');++/** A kind's game count: every model's games added and halved, because two models play each game. */++const gamesOf = (rows) => Math.round((rows || []).reduce((sum, r) => sum + (n(r && r.games) || 0), 0) / 2);++const NAV = [{ label: 'Numbers', href: '/stats', icon: 'fa-chart-line' },{ label: 'The Rundown', href: '/rundown', icon: 'fa-newspaper' },++ { label: 'AI boards', href: '/ai', icon: 'fa-robot' },{ label: 'API', href: '/developers', icon: 'fa-code', more: true },];@@@ -50,7 +82,7 @@ function layout({ title, description, canonical = null, path = null, robots = 'iservice: 'report',apiBase: NETWORK,links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && active.startsWith(l.href)) })),-- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }],++ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }, { label: 'AI boards', href: '/ai', icon: 'fa-robot' }],history: { type: 'page', title: title || NAME },};const footer = { service: 'report', variant: 'full', mount: '#ov-footer', brandName: NAME };@@@ -176,6 +208,7 @@ function home({ stats, rundown, topics } = {}) {explore: { title: 'More on Report', items: [{ title: 'Every site\'s numbers', text: 'Page views, people, streams and how fast each service answers', href: '/stats', icon: 'ov:gauge' },{ title: 'The Rundown', text: 'What streamers talked about, topic by topic', href: '/rundown', icon: 'ov:news' },++ { title: 'AI boards', text: 'Every benchmark kind, its leader, quality against cost and cost per solved task', href: '/ai', icon: 'ov:ai' },{ title: 'Status', text: 'Whether every OpenVibe service is up right now', href: 'https://openvibe.network/status', icon: 'ov:ping' },] },dev: { title: 'For developers', lede: 'Every number here is open JSON, readable from any site.', samples: [@@@ -310,8 +343,92 @@ function developers() {return layout({ title: 'The Report API', description: 'Open JSON for the network\'s numbers, The Rundown and after-show reports.', body, active: '/developers', path: '/developers' });}++function benchState(...reads) {++ const stale = reads.filter((r) => r && r.stale);++ if (!stale.length) return '';++ const oldest = stale.map((r) => r.at).sort()[0];++ return `<p class="rp-fine" role="status">Bench is unavailable. Showing the last good copy, read ${esc(dayTime(oldest))}.</p>`;++}++++const aiPath = (kind) => `/ai/${encodeURIComponent(kind)}`;++const modelPath = (model) => `/ai/models/${encodeURIComponent(model)}`;++const modelLink = (subject) => `<a href="${modelPath(subject)}">${esc(subject)}</a>`;++const datasetLink = (kind) => `<p class="rp-fine">Download the <a href="${BENCH}/api/v1/datasets/${encodeURIComponent(kind)}.jsonl">dataset JSONL</a> from Bench.</p>`;++const emptyBoard = () => '<p class="rp-fine">No results yet for this board.</p>';++++function aiIndex({ kinds, boards = [] }) {++ const list = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];++ const groups = new Map();++ list.forEach((kind, i) => {++ const room = String(kind.room || 'Other');++ if (!groups.has(room)) groups.set(room, []);++ const rows = boards[i] && boards[i].data.board && boards[i].data.board.rows || [];++ groups.get(room).push({ kind, rows, available: Boolean(boards[i]) });++ });++ const sections = [...groups].sort(([a], [b]) => a.localeCompare(b)).map(([room, items]) => `<section class="rp-ai-room" aria-label="${esc(room)}"><h2>${esc(roomName(room))}</h2><ul class="rp-topics">${items.map(({ kind, rows, available }) => {++ const leader = rows[0];++ const href = aiPath(kind.id);++ return `<li class="rp-topic"><h3><a href="${href}">${esc(kind.name || kind.id)}</a></h3><p class="rp-fine">${esc(kind.id)} · ${esc(judgeWord(kind.judge))}</p><p>${available ? `Leader: ${leader ? modelLink(leader.subject) : 'No leader yet'} · ${exact(gamesOf(rows))} games` : 'Board temporarily unavailable'}</p><p class="rp-meta"><a href="${href}">Elo</a> · <a href="${href}?tab=pareto">Pareto</a> · <a href="${href}?tab=cost">Cost per solved</a></p></li>`;++ }).join('')}</ul></section>`).join('');++ const body = `<header class="rp-page-h"><p class="rp-kicker">Benchmarks</p><h1>AI boards</h1><p class="rp-lede">Every benchmark kind across OpenVibe rooms, with ratings, quality and cost.</p></header>${benchState(kinds, ...boards)}${sections ? `<div class="rp-ai-rooms">${sections}</div>` : '' }${sections ? '' : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;++ return layout({ title: 'AI boards', description: 'AI benchmark leaders and boards across OpenVibe rooms.', body, active: '/ai', path: '/ai' });++}++++function aiTable(headers, rows) {++ return `<div class="rp-ai-scroll"><table class="rp-ai-table"><thead><tr>${headers.map((h) => `<th scope="col">${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table></div>`;++}++++function paretoRows(pareto, cost) {++ const costs = new Map((cost && cost.data.board && cost.data.board.rows || []).map((r) => [r.subject, n(r.cost_per_solved)]));++ const rows = (pareto && pareto.data.board && pareto.data.board.rows || []).map((r) => ({ ...r, quality: n(r.quality), costPerSolved: costs.get(r.subject) }));++ return rows.map((r) => ({ ...r, frontier: r.quality != null && r.costPerSolved != null && !rows.some((other) => other !== r && other.quality != null && other.costPerSolved != null && other.quality >= r.quality && other.costPerSolved <= r.costPerSolved && (other.quality > r.quality || other.costPerSolved < r.costPerSolved)) }));++}++++function paretoChart(rows) {++ const points = rows.filter((r) => r.quality != null && r.costPerSolved != null && r.costPerSolved >= 0);++ if (!points.length) return '<p class="rp-fine">No quality and cost per solved pairs yet.</p>';++ const maxCost = Math.max(0.001, ...points.map((r) => r.costPerSolved));++ const x = (v) => 70 + 650 * v / maxCost;++ const y = (v) => 285 - 235 * Math.max(0, Math.min(1, v));++ const marks = points.map((r) => {++ const px = x(r.costPerSolved).toFixed(1), py = y(r.quality).toFixed(1);++ return `<g><circle cx="${px}" cy="${py}" r="${r.frontier ? 5 : 3}" class="${r.frontier ? 'rp-ai-frontier' : 'rp-ai-point'}"><title>${esc(r.subject)}: ${share(r.quality)} quality, ${usd(r.costPerSolved)} per solved task${r.frontier ? ', frontier' : ''}</title></circle>${r.frontier ? `<text x="${px}" y="${(Number(py) - 9).toFixed(1)}" text-anchor="middle">${esc(r.subject)}</text>` : ''}</g>`;++ }).join('');++ return `<figure class="rp-ai-chart"><svg viewBox="0 0 800 340" role="img" aria-labelledby="rp-ai-chart-title rp-ai-chart-desc"><title id="rp-ai-chart-title">Quality against cost per solved task</title><desc id="rp-ai-chart-desc">Each point is a model. Labelled points form the best quality and cost frontier. The table below gives exact values.</desc><line x1="70" y1="285" x2="720" y2="285"/><line x1="70" y1="50" x2="70" y2="285"/><text x="70" y="307" text-anchor="middle">$0</text><text x="720" y="307" text-anchor="end">${esc(usd(maxCost))}</text><text x="45" y="285" text-anchor="end">0%</text><text x="45" y="55" text-anchor="end">100%</text><text x="395" y="332" text-anchor="middle">Cost per solved task (USD, lower is better)</text><text x="16" y="170" transform="rotate(-90 16 170)" text-anchor="middle">Quality (higher is better)</text>${marks}</svg><figcaption>Labelled points are on the quality and cost frontier.</figcaption></figure>`;++}++++function aiBoard({ kindId, kind, tab, board, cost, kinds }) {++ const name = kind && kind.name || kindId;++ const rows = board && board.data.board && board.data.board.rows || [];++ const tabs = [['elo', 'Elo'], ['pareto', 'Pareto'], ['cost', 'Cost per solved']].map(([id, label]) => `<a href="${aiPath(kindId)}${id === 'elo' ? '' : `?tab=${id}`}"${tab === id ? ' aria-current="page"' : ''}>${label}</a>`).join('');++ let content;++ if (tab === 'elo') content = rows.length ? aiTable(['Rank', 'Model', 'Rating (95% interval)', 'Games', 'Win rate'], rows.map((r, i) => `<tr><td>${i + 1}</td><th scope="row">${modelLink(r.subject)}${flag(r)}</th><td>${rating(r)}</td><td>${exact(r.games)}</td><td>${share(r.win_rate)}</td></tr>`)) : emptyBoard();++ if (tab === 'pareto') {++ const points = paretoRows(board, cost);++ content = `${paretoChart(points)}${points.length ? aiTable(['Model', 'Quality', 'Cost per solved', 'Frontier'], points.map((r) => `<tr><th scope="row">${modelLink(r.subject)}</th><td>${share(r.quality)}</td><td>${usd(r.costPerSolved)}</td><td>${r.frontier ? 'Frontier' : '—'}</td></tr>`)) : ''}`;++ }++ if (tab === 'cost') content = rows.length ? aiTable(['Rank', 'Model', 'Solved', 'Entries', 'Total cost', 'Cost per solved'], rows.map((r, i) => `<tr><td>${i + 1}</td><th scope="row">${modelLink(r.subject)}</th><td>${exact(r.solved)}</td><td>${exact(r.entries)}</td><td>${usd(r.total_cost_usd)}</td><td>${usd(r.cost_per_solved)}</td></tr>`)) : emptyBoard();++ if (!board) content = '<p class="rp-fine">Bench results are temporarily unavailable.</p>';++ const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a>${kind ? ` · ${esc(roomName(kind.room))}` : ''}</p><h1>${esc(name)}</h1><p class="rp-lede">${kind ? esc(judgeWord(kind.judge).replace(/^./, (c) => c.toUpperCase())) : 'Bench results'}.</p><nav class="rp-tabs" aria-label="Board view">${tabs}</nav></header>${benchState(kinds, board, cost)}<section aria-label="${esc(tab)} board">${content}</section>${datasetLink(kindId)}`;++ return layout({ title: `${name}: AI board`, description: `${name} AI benchmark ratings, quality and cost.`, body, active: '/ai', path: aiPath(kindId) });++}++++function aiModel({ model, summary }) {++ const data = summary && summary.data || null;++ const ratings = data && Array.isArray(data.ratings) ? data.ratings : [];++ const entries = data && Array.isArray(data.entries) ? data.entries : [];++ const kinds = [...new Set([...ratings.map((r) => r.kind_id), ...entries.map((e) => e.kind_id)])].filter(Boolean);++ const rows = kinds.map((kind) => {++ const r = ratings.find((item) => item.kind_id === kind);++ const count = entries.filter((item) => item.kind_id === kind).length;++ return `<tr><th scope="row"><a href="${aiPath(kind)}">${esc(kind)}</a></th><td>${r ? rating(r) : '—'}${r ? flag(r) : ''}</td><td>${r ? exact(r.games) : '—'}</td><td>${r ? share(r.win_rate) : '—'}</td><td>${count}</td></tr>`;++ });++ const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a> · Model</p><h1>${esc(model)}</h1><p class="rp-lede">Performance across every kind this model has played.</p></header>${benchState(summary)}${data ? `<p class="rp-meta">Total cost: ${usd(data.total_cost_usd)} · ${kinds.length} kinds</p>${rows.length ? aiTable(['Kind', 'Rating (95% interval)', 'Games', 'Win rate', 'Recent entries'], rows) : emptyBoard()}` : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;++ return layout({ title: `${model}: AI model`, description: `${model} across OpenVibe benchmark kinds.`, body, active: '/ai', path: modelPath(model) });++}++function notFound() {return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });}--module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, developers, notFound, layout, setRelease, configure };++module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, developers, aiIndex, aiBoard, aiModel, paretoRows, paretoChart, notFound, layout, setRelease, configure };nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++++const assert = require('assert');++const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot');++++(async () => {++ let time = Date.parse('2026-10-28T12:00:00Z');++ let down = false;++ const source = stubFetch();++ const calls = [];++ const fetchImpl = async (url, opts) => {++ const u = new URL(String(url));++ if (u.port !== '5230') return source(url, opts);++ calls.push(u.pathname + u.search);++ if (down) throw new Error('Bench offline');++ if (u.pathname === '/api/v1/kinds') return jsonResponse({ count: 1, kinds: [{ id: 'design.page', room: 'design', name: 'Page <design>', judge: 'human_pairwise' }] });++ if (u.pathname === '/api/v1/boards/design.page') {++ const type = u.searchParams.get('type');++ const rows = {++ elo: [++ { subject: 'openai/gpt-4o', elo: 1043, ci_lower: 1005, ci_upper: 1081, games: 8, win_rate: 0.75, provisional: true },++ { subject: 'model-b', elo: 980, ci_lower: 940, ci_upper: 1020, games: 8, win_rate: 0.25, provisional: true },++ ],++ pareto: [++ { subject: 'openai/gpt-4o', quality: 0.8 },++ { subject: 'model-b', quality: 0.6 },++ ],++ cost: [++ { subject: 'model-b', entries: 8, solved: 6, total_cost_usd: 0.3, cost_per_solved: 0.05 },++ { subject: 'openai/gpt-4o', entries: 8, solved: 8, total_cost_usd: 0.8, cost_per_solved: 0.1 },++ ],++ }[type];++ return rows ? jsonResponse({ board: { kind_id: 'design.page', type, rows } }) : jsonResponse({}, 404);++ }++ if (u.pathname === '/api/v1/models/openai%2Fgpt-4o') return jsonResponse({ model: 'openai/gpt-4o', ratings: [{ kind_id: 'design.page', elo: 1043, ci_lower: 1005, ci_upper: 1081, games: 8, win_rate: 0.75 }], entries: [{ kind_id: 'design.page' }], total_cost_usd: 0.8 });++ return jsonResponse({}, 404);++ };++ const t = await boot({ fetchImpl, now: () => time, env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230' } });++ try {++ await check('/ai groups kinds and links to all boards', async () => {++ const r = await t.get('/ai');++ assert.strictEqual(r.status, 200);++ for (const s of ['Design', 'Page <design>', 'openai/gpt-4o', '8 games', '/ai/design.page?tab=pareto', '/ai/design.page?tab=cost']) assert.ok(r.text.includes(s), s);++ assert.ok(!r.text.includes('Page <design>'));++ });++++ await check('Elo shows ranks, intervals, win rates and provisional state', async () => {++ const r = await t.get('/ai/design.page');++ assert.strictEqual(r.status, 200);++ for (const s of ['1043 ± 38', '75%', 'provisional', 'Rank', '95% interval', 'dataset JSONL']) assert.ok(r.text.includes(s), s);++ });++++ await check('Pareto has an inline SVG, frontier labels and an exact-value table', async () => {++ const r = await t.get('/ai/design.page?tab=pareto');++ assert.strictEqual(r.status, 200);++ for (const s of ['<svg', 'Quality against cost per solved task', 'Cost per solved task (USD', 'Frontier', 'model-b', 'dataset JSONL', '<table']) assert.ok(r.text.includes(s), s);++ });++++ await check('cost and model views quote Bench numbers', async () => {++ const cost = await t.get('/ai/design.page?tab=cost');++ assert.strictEqual(cost.status, 200);++ assert.ok(cost.text.includes('Cost per solved'));++ assert.ok(cost.text.includes('$0.05'));++ const model = await t.get('/ai/models/openai%2Fgpt-4o');++ assert.strictEqual(model.status, 200);++ assert.ok(model.text.includes('design.page'));++ assert.ok(model.text.includes('1043 ± 38'));++ });++++ await check('invalid kinds and models do not call Bench', async () => {++ const before = calls.length;++ assert.strictEqual((await t.get('/ai/bad!kind')).status, 404);++ assert.strictEqual((await t.get('/ai/models/bad!model')).status, 404);++ assert.strictEqual(calls.length, before);++ });++++ await check('sitemap includes AI index and every kind', async () => {++ const r = await t.get('/sitemap.xml');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('<loc>https://openvibe.report/ai</loc>'));++ assert.ok(r.text.includes('<loc>https://openvibe.report/ai/design.page</loc>'));++ });++++ await check('a Bench outage serves the last good pages with the read time', async () => {++ down = true;++ time += 60_001;++ for (const path of ['/ai', '/ai/design.page', '/ai/design.page?tab=pareto', '/ai/models/openai%2Fgpt-4o']) {++ const r = await t.get(path);++ assert.strictEqual(r.status, 200, path);++ assert.ok(r.text.includes('Showing the last good copy, read Oct 28, 12:00 UTC'), path);++ }++ });++ } finally { await t.close(); }++++ await check('an initial Bench outage still renders an AI page', async () => {++ const empty = await boot({ fetchImpl: async (url, opts) => String(url).includes(':5230') ? Promise.reject(new Error('offline')) : source(url, opts), env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230' } });++ try {++ const r = await empty.get('/ai');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('temporarily unavailable'));++ } finally { await empty.close(); }++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++++const assert = require('assert');++const { createBenchClient } = require('../server/data/bench');++const { check, done, jsonResponse } = require('./helpers/boot');++++(async () => {++ let time = Date.parse('2026-10-28T12:00:00Z');++ let down = false;++ let calls = 0;++ const fetchImpl = async () => {++ calls++;++ if (down) throw new Error('Bench offline');++ return jsonResponse({ count: 1, kinds: [{ id: 'design.page', room: 'design' }] });++ };++ const logs = [];++ const bench = createBenchClient({ config: { benchUrl: 'http://bench.test' }, fetchImpl, now: () => time, log: { warn: (line) => logs.push(line) } });++++ await check('Bench reads stay fresh for 60 seconds and keep their source time', async () => {++ const first = await bench.kinds();++ time += 59_999;++ const second = await bench.kinds();++ assert.deepStrictEqual(second, first);++ assert.strictEqual(calls, 1);++ assert.strictEqual(first.at, '2026-10-28T12:00:00.000Z');++ });++++ await check('a failed refresh serves the last good copy with its time', async () => {++ down = true;++ time++;++ const stale = await bench.kinds();++ assert.strictEqual(stale.stale, true);++ assert.strictEqual(stale.at, '2026-10-28T12:00:00.000Z');++ assert.strictEqual(stale.data.kinds[0].id, 'design.page');++ assert.strictEqual(calls, 2);++ assert.strictEqual(logs.length, 1);++ time += 30_000;++ assert.strictEqual((await bench.kinds()).stale, true);++ assert.strictEqual(calls, 2, 'the failed refresh also has a 60-second retry pause');++ });++++ await check('invalid parameters never reach Bench', async () => {++ assert.strictEqual(await bench.board('../bad'), null);++ assert.strictEqual(await bench.board('design.page', 'wrong'), null);++ assert.strictEqual(await bench.modelSummary('bad?name'), null);++ assert.strictEqual(calls, 2);++ assert.strictEqual(bench.isModelName('openai/gpt-4o'), true);++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -23,6 +23,9 @@ OV_NETWORK_URL=https://openvibe.network
@@ -23,6 +23,9 @@ OV_NETWORK_URL=https://openvibe.network
# OpenVibe.Live's Rundown and after-show reports (GET /api/rundown/*, /api/recap/*).
# OpenVibe.Live's Rundown and after-show reports (GET /api/rundown/*, /api/recap/*).
OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
+# Bench's public read API; set to an internal origin if Report and Bench share one.
+OV_BENCH_INTERNAL_URL=https://bench.openvibe.services
+
# 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=…).
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
@@ -33,6 +33,7 @@ OpenVibe.Live in both modes.
@@ -33,6 +33,7 @@ OpenVibe.Live in both modes.
| **Report's tables** | `report_metric_days`, `report_metric_windows`, `report_streams` | the same routes while `REPORT_SOURCE=report` |
| **Report's tables** | `report_metric_days`, `report_metric_windows`, `report_streams` | the same routes while `REPORT_SOURCE=report` |
| **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` |
| **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` |
| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |
| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |
+| **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/models/:model` | `/ai`, `/ai/:kind`, `/ai/models/:model` |
| **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts |
| **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts |
A source that does not answer is null, after one short log line; the page (or API route) becomes a 404. The service
A source that does not answer is null, after one short log line; the page (or API route) becomes a 404. The service
@@ -72,9 +73,14 @@ All server-rendered in `server/views/index.js`; every dynamic value goes through
@@ -72,9 +73,14 @@ All server-rendered in `server/views/index.js`; every dynamic value goes through
| `/rundown/s/:username` | one streamer's episodes and topics |
| `/rundown/s/:username` | one streamer's episodes and topics |
| `/after-show/:streamId` | one after-show report |
| `/after-show/:streamId` | one after-show report |
| `/c/:username` | one channel's latest after-show reports |
| `/c/:username` | one channel's latest after-show reports |
+| `/ai` | benchmark kinds grouped by room, with leaders and game counts |
+| `/ai/:kind` | Elo, Pareto quality against cost per solved, and cost per solved tables |
+| `/ai/models/:model` | a model's ratings and entries across kinds |
A bad path parameter (slug, stream id, username) or a source that answered nothing renders `views.notFound()` with
A bad path parameter (slug, stream id, username) or a source that answered nothing renders `views.notFound()` with
status 404. Pages are `Cache-Control: public, max-age=120`.
status 404. Pages are `Cache-Control: public, max-age=120`.
+AI pages use a 60-second Bench read cache and serve its last good copy with the read time during an outage. Their
+HTML is `no-store` so the outage notice stays current.
## API
## API
@@ -135,6 +141,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).
@@ -135,6 +141,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).
| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | Network's loopback origin (its manifest's `internalOrigin`) |
| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | Network's loopback origin (its manifest's `internalOrigin`) |
| `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_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 |
@@ -32,6 +32,25 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -32,6 +32,25 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.rp-tabs { display: inline-flex; gap: 4px; margin-top: 16px; padding: 4px; border-radius: 999px; background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); }
.rp-tabs { display: inline-flex; gap: 4px; margin-top: 16px; padding: 4px; border-radius: 999px; background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); }
.rp-tabs a { padding: 7px 14px; border-radius: 999px; color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: .86rem; font-weight: 700; }
.rp-tabs a { padding: 7px 14px; border-radius: 999px; color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: .86rem; font-weight: 700; }
.rp-tabs a[aria-current] { background: var(--room-fill); color: var(--room-on); }
.rp-tabs a[aria-current] { background: var(--room-fill); color: var(--room-on); }
+.rp-ai-room { margin-top: 28px; }
+.rp-ai-room h2 { margin-bottom: 12px; }
+.rp-ai-room .rp-topic h3 { margin: 0; font-size: 1.05rem; }
+.rp-ai-room .rp-topic p { margin: 0; }
+.rp-ai-room .rp-topic .rp-fine { margin: 0; }
+.rp-ai-scroll { overflow-x: auto; margin: 20px 0; border: 1px solid var(--border, #1f2d47); border-radius: 16px; }
+.rp-ai-table { width: 100%; border-collapse: collapse; min-width: 650px; font-variant-numeric: tabular-nums; }
+.rp-ai-table th, .rp-ai-table td { padding: 11px 14px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border, #1f2d47); }
+.rp-ai-table thead th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted, #7386a3); }
+.rp-ai-table tbody tr:last-child > * { border-bottom: 0; }
+.rp-ai-table a { overflow-wrap: anywhere; }
+.rp-flag { display: inline-block; font-size: .72rem; color: var(--room-strong); margin-left: 5px; }
+.rp-ai-chart { margin: 20px 0; padding: 12px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-card, #0f1729); }
+.rp-ai-chart svg { display: block; width: 100%; height: auto; }
+.rp-ai-chart line { stroke: var(--text-muted, #7386a3); stroke-width: 1; }
+.rp-ai-chart text { fill: var(--text-secondary, #96a7c2); font-size: 11px; }
+.rp-ai-chart .rp-ai-frontier { fill: var(--room-accent); stroke: var(--bg-card, #0f1729); stroke-width: 1; }
+.rp-ai-chart .rp-ai-point { fill: var(--text-muted, #7386a3); }
+.rp-ai-chart figcaption { margin-top: 6px; font-size: .8rem; color: var(--text-muted, #7386a3); }
.rp-counts { display: flex; flex-wrap: wrap; gap: 6px 20px; margin: 14px 0 0; color: var(--text-secondary, #96a7c2); }
.rp-counts { display: flex; flex-wrap: wrap; gap: 6px 20px; margin: 14px 0 0; color: var(--text-secondary, #96a7c2); }
.rp-counts b { color: var(--text-primary, #e6edf7); font-size: 1.15rem; font-variant-numeric: tabular-nums; }
.rp-counts b { color: var(--text-primary, #e6edf7); font-size: 1.15rem; font-variant-numeric: tabular-nums; }
@@ -131,3 +150,15 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -131,3 +150,15 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.rp-eps dt code { font: 600 .92rem/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--room-strong); overflow-wrap: anywhere; }
.rp-eps dt code { font: 600 .92rem/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--room-strong); overflow-wrap: anywhere; }
.rp-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
.rp-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
@media (prefers-reduced-motion: reduce) { .rp-topic { transition: none; } }
@media (prefers-reduced-motion: reduce) { .rp-topic { transition: none; } }
+
+/* AI boards: rooms side by side, tables that fit a phone, and a chart that scrolls rather than shrinking its labels */
+.rp-ai-rooms { display: grid; gap: 0 24px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); align-items: start; }
+.rp-ai-rooms .rp-topics { grid-template-columns: 1fr; }
+.rp-ai-table a { text-decoration: none; font-weight: 700; }
+.rp-ai-table a:hover, .rp-ai-table a:focus-visible { text-decoration: underline; }
+.rp-ai-chart { overflow-x: auto; }
+@media (max-width: 640px) {
+ .rp-ai-table { min-width: 0; font-size: .86rem; }
+ .rp-ai-table th, .rp-ai-table td { padding: 9px 7px; }
+ .rp-ai-chart svg { min-width: 720px; }
+}
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
*
*
* /, /stats, /rundown, /rundown/t/:slug, /rundown/e/:streamId, /rundown/s/:username,
* /, /stats, /rundown, /rundown/t/:slug, /rundown/e/:streamId, /rundown/s/:username,
* /after-show/:streamId, /c/:username the pages (http/pages.js)
* /after-show/:streamId, /c/:username the pages (http/pages.js)
+ * /ai, /ai/models/:model, /ai/:kind the AI boards, from OpenVibe.Bench (http/pages.js)
* /api/v1/* the JSON API (http/api.js)
* /api/v1/* the JSON API (http/api.js)
* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
* /api/health, /api/ready, /release.json, /release-metrics the plumbing (loopback scrape for /metrics)
* /api/health, /api/ready, /release.json, /release-metrics the plumbing (loopback scrape for /metrics)
@@ -26,6 +27,7 @@ const { openStore } = require('./db');
@@ -26,6 +27,7 @@ const { openStore } = require('./db');
const { createNetworkClient } = require('./data/network');
const { createNetworkClient } = require('./data/network');
const { createLiveClient } = require('./data/live');
const { createLiveClient } = require('./data/live');
const { createBillingClient } = require('./data/billing');
const { createBillingClient } = require('./data/billing');
+const { createBenchClient } = require('./data/bench');
const { createAnalytics } = require('./analytics');
const { createAnalytics } = require('./analytics');
const { createShadow } = require('./analytics/shadow');
const { createShadow } = require('./analytics/shadow');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
@@ -51,11 +53,12 @@ async function createApp(opts = {}) {
@@ -51,11 +53,12 @@ async function createApp(opts = {}) {
const network = opts.network || createNetworkClient({ config, fetchImpl, log, now });
const network = opts.network || createNetworkClient({ config, fetchImpl, log, now });
const live = opts.live || createLiveClient({ config, fetchImpl, log, now });
const live = opts.live || createLiveClient({ config, fetchImpl, log, now });
const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });
const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });
+ const bench = opts.bench || createBenchClient({ config, fetchImpl, log, now });
const analytics = opts.analytics || createAnalytics({ config, store, network, billing, now, log });
const analytics = opts.analytics || createAnalytics({ config, store, network, billing, now, log });
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
views.configure({ baseUrl: config.baseUrl });
views.configure({ baseUrl: config.baseUrl });
- const ctx = { config, log, fetchImpl, now, store, network, live, billing, analytics, shadow };
+ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
* Report reads Network's open stats and Live's Rundown and after-show reports over loopback. It also has its own
* Report reads Network's open stats and Live's Rundown and after-show reports over loopback. It also has its own
* PostgreSQL database (migrations/) for the analytics it ingests, and reads revenue from Billing. REPORT_SOURCE
* PostgreSQL database (migrations/) for the analytics it ingests, and reads revenue from Billing. REPORT_SOURCE
* chooses which side the pages and GET /api/v1/stats answer from: `network` (today's behaviour) or `report`.
* chooses which side the pages and GET /api/v1/stats answer from: `network` (today's behaviour) or `report`.
+ * The AI boards under /ai read OpenVibe.Bench's public boards the same way.
*/
*/
require('dotenv').config();
require('dotenv').config();
@@ -44,6 +45,10 @@ function load(env = process.env) {
@@ -44,6 +45,10 @@ function load(env = process.env) {
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
liveUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
liveUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
+ // 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'),
+
// 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.
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.REPORT_PGLITE_DIR || '' },
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.REPORT_PGLITE_DIR || '' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+
+/**
+ * OpenVibe.Bench's public reads (plan 2026-10-28 §4.12):
+ * GET /api/v1/kinds every benchmark kind
+ * GET /api/v1/boards/:kind?type=elo|pareto|cost
+ * GET /api/v1/models/:model one model across every kind
+ *
+ * Reads are public and held for sixty seconds in this process. A read that has answered once keeps its copy
+ * for as long as Bench is down: the client answers the last good one with the moment it was read (`at`) and
+ * `stale: true`, so a board page stays a page — never an error page. A read that has never answered is null
+ * after one short log line. A kind id, board type or model name that cannot exist is refused before any request
+ * is made.
+ */
+const { trim, requestJson, TIMEOUT_MS } = require('./source');
+
+const TTL_MS = 60_000;
+const KIND_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/;
+const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:+\/~\-]{0,119}$/;
+const BOARD_TYPES = Object.freeze(['elo', 'pareto', 'cost']);
+
+const isKindId = (value) => typeof value === 'string' && KIND_RE.test(value);
+
+/** A model as Bench pins it: one path segment (its own /models/:model route cannot serve more), 120 characters at most. */
+const isModelName = (value) => typeof value === 'string' && MODEL_RE.test(value);
+
+/** ?tab=elo|pareto|cost; anything else is the Elo board, exactly as the page tabs read it. */
+function normaliseTab(value) {
+ const raw = Array.isArray(value) ? value[0] : value;
+ return BOARD_TYPES.includes(raw) ? raw : 'elo';
+}
+
+/**
+ * opts: config ({ benchUrl }), fetchImpl, log, now, timeoutMs
+ */
+function createBenchClient({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs = TIMEOUT_MS } = {}) {
+ const baseUrl = trim(config.benchUrl || 'http://127.0.0.1:5230');
+ const clock = typeof now === 'function' ? now : () => Date.now();
+
+ // The last good copy of every read, kept beyond its minute so a Bench outage never empties a page. Kinds and
+ // boards are a fixed small set; models are capped, the oldest inserted out first.
+ const boards = new Map();
+ const models = new Map();
+
+ function keep(store, key, data, cap) {
+ store.set(key, data);
+ while (store.size > cap) store.delete(store.keys().next().value);
+ }
+
+ /** One read: the fresh copy, the last good one (stale) while Bench is down, or null when there is none yet. */
+ async function read(store, key, path, label, cap) {
+ const atMs = clock();
+ const hit = store.get(key);
+ if (hit && hit.atMs + TTL_MS > atMs) return { data: hit.data, at: hit.at, stale: false };
+ if (hit && hit.retryAtMs > atMs) return { data: hit.data, at: hit.at, stale: true };
+ const data = await requestJson(`${baseUrl}${path}`, { fetchImpl, log, timeoutMs, label });
+ if (data === null) {
+ if (hit) hit.retryAtMs = atMs + TTL_MS;
+ return hit ? { data: hit.data, at: hit.at, stale: true } : null;
+ }
+ const entry = { data, atMs, at: new Date(atMs).toISOString() };
+ keep(store, key, entry, cap);
+ return { data, at: entry.at, stale: false };
+ }
+
+ /** Every kind: { count, kinds: [{ id, room, entry, judge, name, metrics, created_at }] }. */
+ function kinds() {
+ return read(boards, 'kinds', '/api/v1/kinds', 'bench kinds', 500);
+ }
+
+ /** One kind's board: { board: { kind_id, category, period, type, rows } }. */
+ async function board(kindId, type = 'elo') {
+ if (!isKindId(kindId) || !BOARD_TYPES.includes(type)) return null;
+ return read(
+ boards,
+ `board:${kindId}:${type}`,
+ `/api/v1/boards/${encodeURIComponent(kindId)}?type=${type}`,
+ `bench board (${kindId}, ${type})`,
+ 500,
+ );
+ }
+
+ /** One model across every kind: { model, ratings, entries, total_cost_usd }. */
+ async function modelSummary(model) {
+ if (!isModelName(model)) return null;
+ return read(models, `model:${model}`, `/api/v1/models/${encodeURIComponent(model)}`, `bench model (${model})`, 200);
+ }
+
+ return { kinds, board, modelSummary, baseUrl, ttlMs: TTL_MS, isKindId, isModelName, normaliseTab };
+}
+
+module.exports = { createBenchClient, TTL_MS, BOARD_TYPES, isKindId, isModelName, normaliseTab };
@@ -32,7 +32,7 @@ function siteUpdated() {
@@ -32,7 +32,7 @@ function siteUpdated() {
}
}
}
}
-function createDiscoveryRoutes({ config, live }) {
+function createDiscoveryRoutes({ config, live, bench }) {
const r = express.Router();
const r = express.Router();
const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');
const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');
const abs = (p) => `${site}${p}`;
const abs = (p) => `${site}${p}`;
@@ -45,13 +45,18 @@ function createDiscoveryRoutes({ config, live }) {
@@ -45,13 +45,18 @@ function createDiscoveryRoutes({ config, live }) {
});
});
r.get('/sitemap.xml', async (_req, res) => {
r.get('/sitemap.xml', async (_req, res) => {
- const data = await live.topics();
+ const [data, ai] = await Promise.all([live.topics(), bench.kinds()]);
const topics = (data && Array.isArray(data.topics)) ? data.topics : [];
const topics = (data && Array.isArray(data.topics)) ? data.topics : [];
+ const kinds = (ai && Array.isArray(ai.data.kinds)) ? ai.data.kinds : [];
const lastmod = siteUpdated();
const lastmod = siteUpdated();
const pages = [
const pages = [
{ loc: abs('/'), changefreq: 'daily', priority: 1.0 },
{ loc: abs('/'), changefreq: 'daily', priority: 1.0 },
{ loc: abs('/stats'), changefreq: 'daily', priority: 0.9 },
{ loc: abs('/stats'), changefreq: 'daily', priority: 0.9 },
{ loc: abs('/rundown'), changefreq: 'daily', priority: 0.9 },
{ loc: abs('/rundown'), changefreq: 'daily', priority: 0.9 },
+ { loc: abs('/ai'), changefreq: 'daily', priority: 0.9 },
+ ...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({
+ loc: abs(`/ai/${encodeURIComponent(k.id)}`), changefreq: 'daily', priority: 0.7,
+ })),
...topics.filter((t) => t && typeof t.slug === 'string').map((t) => ({
...topics.filter((t) => t && typeof t.slug === 'string').map((t) => ({
loc: abs(`/rundown/t/${encodeURIComponent(t.slug)}`),
loc: abs(`/rundown/t/${encodeURIComponent(t.slug)}`),
changefreq: 'weekly',
changefreq: 'weekly',
@@ -71,6 +76,7 @@ function createDiscoveryRoutes({ config, live }) {
@@ -71,6 +76,7 @@ function createDiscoveryRoutes({ config, live }) {
{ title: 'OpenVibe.Report', url: abs('/'), note: 'the Report room' },
{ title: 'OpenVibe.Report', url: abs('/'), note: 'the Report room' },
{ title: 'Open numbers', url: abs('/stats'), note: 'page views, accounts and traffic for every OpenVibe site' },
{ title: 'Open numbers', url: abs('/stats'), note: 'page views, accounts and traffic for every OpenVibe site' },
{ title: 'The Rundown', url: abs('/rundown'), note: 'what streamers talked about, topic by topic' },
{ title: 'The Rundown', url: abs('/rundown'), note: 'what streamers talked about, topic by topic' },
+ { title: 'AI boards', url: abs('/ai'), note: 'Bench ratings, quality and cost across rooms' },
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Open stats (JSON)', url: abs('/api/v1/stats?days=7'), note: '?days=7, 30 or 90' },
{ title: 'Open stats (JSON)', url: abs('/api/v1/stats?days=7'), note: '?days=7, 30 or 90' },
@@ -21,11 +21,11 @@ const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('.
@@ -21,11 +21,11 @@ const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('.
const CACHE = 'public, max-age=120';
const CACHE = 'public, max-age=120';
-function createPageRoutes({ network, live, analytics = null }) {
+function createPageRoutes({ network, live, bench, analytics = null }) {
const r = express.Router();
const r = express.Router();
- function send(res, html, status = 200) {
- res.status(status).set('Cache-Control', CACHE).type('html').send(html);
+ function send(res, html, status = 200, cacheControl = CACHE) {
+ res.status(status).set('Cache-Control', cacheControl).type('html').send(html);
}
}
function notFound(res) {
function notFound(res) {
return send(res, views.notFound(), 404);
return send(res, views.notFound(), 404);
@@ -50,6 +50,30 @@ function createPageRoutes({ network, live, analytics = null }) {
@@ -50,6 +50,30 @@ function createPageRoutes({ network, live, analytics = null }) {
return send(res, views.stats({ stats, days }));
return send(res, views.stats({ stats, days }));
});
});
+ r.get('/ai', async (_req, res) => {
+ const kinds = await bench.kinds();
+ const list = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];
+ const boards = await Promise.all(list.map((kind) => bench.board(kind.id, 'elo')));
+ return send(res, views.aiIndex({ kinds, boards }), 200, 'no-store');
+ });
+
+ r.get('/ai/models/:model', async (req, res) => {
+ const model = req.params.model;
+ if (!bench.isModelName(model)) return notFound(res);
+ const summary = await bench.modelSummary(model);
+ return send(res, views.aiModel({ model, summary }), 200, 'no-store');
+ });
+
+ r.get('/ai/:kind', async (req, res) => {
+ const kindId = req.params.kind;
+ if (!bench.isKindId(kindId)) return notFound(res);
+ const tab = bench.normaliseTab(req.query.tab);
+ const [kinds, board, cost] = await Promise.all([bench.kinds(), bench.board(kindId, tab), tab === 'pareto' ? bench.board(kindId, 'cost') : Promise.resolve(null)]);
+ const kind = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds.find((k) => k.id === kindId) : null;
+ if (kinds && !kind) return notFound(res);
+ return send(res, views.aiBoard({ kindId, kind, tab, board, cost, kinds }), 200, 'no-store');
+ });
+
r.get('/rundown', async (_req, res) => {
r.get('/rundown', async (_req, res) => {
const [rundown, topics] = await Promise.all([live.rundown(), live.topics()]);
const [rundown, topics] = await Promise.all([live.rundown(), live.topics()]);
if (!rundown || !topics) return notFound(res);
if (!rundown || !topics) return notFound(res);
@@ -18,6 +18,7 @@ const { asset } = require('../assets');
@@ -18,6 +18,7 @@ const { asset } = require('../assets');
const NAME = 'OpenVibe.Report';
const NAME = 'OpenVibe.Report';
const NETWORK = 'https://openvibe.network';
const NETWORK = 'https://openvibe.network';
+const BENCH = 'https://bench.openvibe.services';
const ROOM = rooms.get('report');
const ROOM = rooms.get('report');
const esc = kit.esc;
const esc = kit.esc;
let RELEASE = 'dev';
let RELEASE = 'dev';
@@ -38,9 +39,40 @@ const pct = (v) => { const x = n(v); if (x == null) return ''; return `<span cla
@@ -38,9 +39,40 @@ const pct = (v) => { const x = n(v); if (x == null) return ''; return `<span cla
const who = (u) => (u ? u.display_name || u.username || '' : '');
const who = (u) => (u ? u.display_name || u.username || '' : '');
const AI = '<span class="rp-ai" title="Written by AI from the stream\'s own speech, chat and numbers">Written by AI</span>';
const AI = '<span class="rp-ai" title="Written by AI from the stream\'s own speech, chat and numbers">Written by AI</span>';
+// ── The AI boards (OpenVibe.Bench, plan 2026-10-28 §4.12): every number is Bench's; Report renders it ──
+/** Money as Bench quotes it (Report never derives a price): dollars, cents, or fractions of a cent. */
+const usd = (v) => {
+ const x = n(v);
+ if (x == null) return '—';
+ if (!x) return '$0';
+ const a = Math.abs(x);
+ if (a >= 1000) return `$${x.toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
+ if (a >= 1) return `$${x.toFixed(2)}`;
+ return `$${Number(x.toPrecision(2))}`;
+};
+/** A share of games won or tasks passed: 0.8333 → 83%. */
+const share = (v) => { const x = n(v); return x == null ? '—' : `${Math.round(x * 100)}%`; };
+/** The rating with its 95% interval as text — "1043 ± 38" — the exact bounds in the title. */
+function rating(r) {
+ const elo = n(r && r.elo);
+ if (elo == null) return '—';
+ const lo = n(r && r.ci_lower), hi = n(r && r.ci_upper);
+ if (lo == null || hi == null) return `${Math.round(elo)}`;
+ const half = Math.round(Math.max(Math.abs(hi - elo), Math.abs(elo - lo)));
+ return `<span title="95% interval ${Math.round(lo)}–${Math.round(hi)} Elo">${Math.round(elo)} ± ${half}</span>`;
+}
+/** Provisional: Bench marks it on the boards; a summary's games say the same thing (fewer than ten). */
+const provisional = (r) => Boolean(r && (r.provisional != null ? r.provisional : n(r.games) != null && Number(r.games) < 10));
+const flag = (r) => (provisional(r) ? ' <span class="rp-flag" title="Fewer than ten games: this rating can still move a lot">provisional</span>' : '');
+const roomName = (id) => { const s = String(id || ''); return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; };
+const judgeWord = (j) => ({ human_pairwise: 'voted on blind, two at a time', rubric: 'scored on a rubric', automatic_grader: 'graded automatically', test_suite: 'judged by a test suite', game_outcome: 'judged by the game outcome' }[j] || j || '—');
+/** A kind's game count: every model's games added and halved, because two models play each game. */
+const gamesOf = (rows) => Math.round((rows || []).reduce((sum, r) => sum + (n(r && r.games) || 0), 0) / 2);
+
const NAV = [
const NAV = [
{ label: 'Numbers', href: '/stats', icon: 'fa-chart-line' },
{ label: 'Numbers', href: '/stats', icon: 'fa-chart-line' },
{ label: 'The Rundown', href: '/rundown', icon: 'fa-newspaper' },
{ label: 'The Rundown', href: '/rundown', icon: 'fa-newspaper' },
+ { label: 'AI boards', href: '/ai', icon: 'fa-robot' },
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
];
];
@@ -50,7 +82,7 @@ function layout({ title, description, canonical = null, path = null, robots = 'i
@@ -50,7 +82,7 @@ function layout({ title, description, canonical = null, path = null, robots = 'i
service: 'report',
service: 'report',
apiBase: NETWORK,
apiBase: NETWORK,
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && active.startsWith(l.href)) })),
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && active.startsWith(l.href)) })),
- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }],
+ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }, { label: 'AI boards', href: '/ai', icon: 'fa-robot' }],
history: { type: 'page', title: title || NAME },
history: { type: 'page', title: title || NAME },
};
};
const footer = { service: 'report', variant: 'full', mount: '#ov-footer', brandName: NAME };
const footer = { service: 'report', variant: 'full', mount: '#ov-footer', brandName: NAME };
@@ -176,6 +208,7 @@ function home({ stats, rundown, topics } = {}) {
@@ -176,6 +208,7 @@ function home({ stats, rundown, topics } = {}) {
explore: { title: 'More on Report', items: [
explore: { title: 'More on Report', items: [
{ title: 'Every site\'s numbers', text: 'Page views, people, streams and how fast each service answers', href: '/stats', icon: 'ov:gauge' },
{ title: 'Every site\'s numbers', text: 'Page views, people, streams and how fast each service answers', href: '/stats', icon: 'ov:gauge' },
{ title: 'The Rundown', text: 'What streamers talked about, topic by topic', href: '/rundown', icon: 'ov:news' },
{ title: 'The Rundown', text: 'What streamers talked about, topic by topic', href: '/rundown', icon: 'ov:news' },
+ { title: 'AI boards', text: 'Every benchmark kind, its leader, quality against cost and cost per solved task', href: '/ai', icon: 'ov:ai' },
{ title: 'Status', text: 'Whether every OpenVibe service is up right now', href: 'https://openvibe.network/status', icon: 'ov:ping' },
{ title: 'Status', text: 'Whether every OpenVibe service is up right now', href: 'https://openvibe.network/status', icon: 'ov:ping' },
] },
] },
dev: { title: 'For developers', lede: 'Every number here is open JSON, readable from any site.', samples: [
dev: { title: 'For developers', lede: 'Every number here is open JSON, readable from any site.', samples: [
@@ -310,8 +343,92 @@ function developers() {
@@ -310,8 +343,92 @@ function developers() {
return layout({ title: 'The Report API', description: 'Open JSON for the network\'s numbers, The Rundown and after-show reports.', body, active: '/developers', path: '/developers' });
return layout({ title: 'The Report API', description: 'Open JSON for the network\'s numbers, The Rundown and after-show reports.', body, active: '/developers', path: '/developers' });
}
}
+function benchState(...reads) {
+ const stale = reads.filter((r) => r && r.stale);
+ if (!stale.length) return '';
+ const oldest = stale.map((r) => r.at).sort()[0];
+ return `<p class="rp-fine" role="status">Bench is unavailable. Showing the last good copy, read ${esc(dayTime(oldest))}.</p>`;
+}
+
+const aiPath = (kind) => `/ai/${encodeURIComponent(kind)}`;
+const modelPath = (model) => `/ai/models/${encodeURIComponent(model)}`;
+const modelLink = (subject) => `<a href="${modelPath(subject)}">${esc(subject)}</a>`;
+const datasetLink = (kind) => `<p class="rp-fine">Download the <a href="${BENCH}/api/v1/datasets/${encodeURIComponent(kind)}.jsonl">dataset JSONL</a> from Bench.</p>`;
+const emptyBoard = () => '<p class="rp-fine">No results yet for this board.</p>';
+
+function aiIndex({ kinds, boards = [] }) {
+ const list = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];
+ const groups = new Map();
+ list.forEach((kind, i) => {
+ const room = String(kind.room || 'Other');
+ if (!groups.has(room)) groups.set(room, []);
+ const rows = boards[i] && boards[i].data.board && boards[i].data.board.rows || [];
+ groups.get(room).push({ kind, rows, available: Boolean(boards[i]) });
+ });
+ const sections = [...groups].sort(([a], [b]) => a.localeCompare(b)).map(([room, items]) => `<section class="rp-ai-room" aria-label="${esc(room)}"><h2>${esc(roomName(room))}</h2><ul class="rp-topics">${items.map(({ kind, rows, available }) => {
+ const leader = rows[0];
+ const href = aiPath(kind.id);
+ return `<li class="rp-topic"><h3><a href="${href}">${esc(kind.name || kind.id)}</a></h3><p class="rp-fine">${esc(kind.id)} · ${esc(judgeWord(kind.judge))}</p><p>${available ? `Leader: ${leader ? modelLink(leader.subject) : 'No leader yet'} · ${exact(gamesOf(rows))} games` : 'Board temporarily unavailable'}</p><p class="rp-meta"><a href="${href}">Elo</a> · <a href="${href}?tab=pareto">Pareto</a> · <a href="${href}?tab=cost">Cost per solved</a></p></li>`;
+ }).join('')}</ul></section>`).join('');
+ const body = `<header class="rp-page-h"><p class="rp-kicker">Benchmarks</p><h1>AI boards</h1><p class="rp-lede">Every benchmark kind across OpenVibe rooms, with ratings, quality and cost.</p></header>${benchState(kinds, ...boards)}${sections ? `<div class="rp-ai-rooms">${sections}</div>` : '' }${sections ? '' : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;
+ return layout({ title: 'AI boards', description: 'AI benchmark leaders and boards across OpenVibe rooms.', body, active: '/ai', path: '/ai' });
+}
+
+function aiTable(headers, rows) {
+ return `<div class="rp-ai-scroll"><table class="rp-ai-table"><thead><tr>${headers.map((h) => `<th scope="col">${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table></div>`;
+}
+
+function paretoRows(pareto, cost) {
+ const costs = new Map((cost && cost.data.board && cost.data.board.rows || []).map((r) => [r.subject, n(r.cost_per_solved)]));
+ const rows = (pareto && pareto.data.board && pareto.data.board.rows || []).map((r) => ({ ...r, quality: n(r.quality), costPerSolved: costs.get(r.subject) }));
+ return rows.map((r) => ({ ...r, frontier: r.quality != null && r.costPerSolved != null && !rows.some((other) => other !== r && other.quality != null && other.costPerSolved != null && other.quality >= r.quality && other.costPerSolved <= r.costPerSolved && (other.quality > r.quality || other.costPerSolved < r.costPerSolved)) }));
+}
+
+function paretoChart(rows) {
+ const points = rows.filter((r) => r.quality != null && r.costPerSolved != null && r.costPerSolved >= 0);
+ if (!points.length) return '<p class="rp-fine">No quality and cost per solved pairs yet.</p>';
+ const maxCost = Math.max(0.001, ...points.map((r) => r.costPerSolved));
+ const x = (v) => 70 + 650 * v / maxCost;
+ const y = (v) => 285 - 235 * Math.max(0, Math.min(1, v));
+ const marks = points.map((r) => {
+ const px = x(r.costPerSolved).toFixed(1), py = y(r.quality).toFixed(1);
+ return `<g><circle cx="${px}" cy="${py}" r="${r.frontier ? 5 : 3}" class="${r.frontier ? 'rp-ai-frontier' : 'rp-ai-point'}"><title>${esc(r.subject)}: ${share(r.quality)} quality, ${usd(r.costPerSolved)} per solved task${r.frontier ? ', frontier' : ''}</title></circle>${r.frontier ? `<text x="${px}" y="${(Number(py) - 9).toFixed(1)}" text-anchor="middle">${esc(r.subject)}</text>` : ''}</g>`;
+ }).join('');
+ return `<figure class="rp-ai-chart"><svg viewBox="0 0 800 340" role="img" aria-labelledby="rp-ai-chart-title rp-ai-chart-desc"><title id="rp-ai-chart-title">Quality against cost per solved task</title><desc id="rp-ai-chart-desc">Each point is a model. Labelled points form the best quality and cost frontier. The table below gives exact values.</desc><line x1="70" y1="285" x2="720" y2="285"/><line x1="70" y1="50" x2="70" y2="285"/><text x="70" y="307" text-anchor="middle">$0</text><text x="720" y="307" text-anchor="end">${esc(usd(maxCost))}</text><text x="45" y="285" text-anchor="end">0%</text><text x="45" y="55" text-anchor="end">100%</text><text x="395" y="332" text-anchor="middle">Cost per solved task (USD, lower is better)</text><text x="16" y="170" transform="rotate(-90 16 170)" text-anchor="middle">Quality (higher is better)</text>${marks}</svg><figcaption>Labelled points are on the quality and cost frontier.</figcaption></figure>`;
+}
+
+function aiBoard({ kindId, kind, tab, board, cost, kinds }) {
+ const name = kind && kind.name || kindId;
+ const rows = board && board.data.board && board.data.board.rows || [];
+ const tabs = [['elo', 'Elo'], ['pareto', 'Pareto'], ['cost', 'Cost per solved']].map(([id, label]) => `<a href="${aiPath(kindId)}${id === 'elo' ? '' : `?tab=${id}`}"${tab === id ? ' aria-current="page"' : ''}>${label}</a>`).join('');
+ let content;
+ if (tab === 'elo') content = rows.length ? aiTable(['Rank', 'Model', 'Rating (95% interval)', 'Games', 'Win rate'], rows.map((r, i) => `<tr><td>${i + 1}</td><th scope="row">${modelLink(r.subject)}${flag(r)}</th><td>${rating(r)}</td><td>${exact(r.games)}</td><td>${share(r.win_rate)}</td></tr>`)) : emptyBoard();
+ if (tab === 'pareto') {
+ const points = paretoRows(board, cost);
+ content = `${paretoChart(points)}${points.length ? aiTable(['Model', 'Quality', 'Cost per solved', 'Frontier'], points.map((r) => `<tr><th scope="row">${modelLink(r.subject)}</th><td>${share(r.quality)}</td><td>${usd(r.costPerSolved)}</td><td>${r.frontier ? 'Frontier' : '—'}</td></tr>`)) : ''}`;
+ }
+ if (tab === 'cost') content = rows.length ? aiTable(['Rank', 'Model', 'Solved', 'Entries', 'Total cost', 'Cost per solved'], rows.map((r, i) => `<tr><td>${i + 1}</td><th scope="row">${modelLink(r.subject)}</th><td>${exact(r.solved)}</td><td>${exact(r.entries)}</td><td>${usd(r.total_cost_usd)}</td><td>${usd(r.cost_per_solved)}</td></tr>`)) : emptyBoard();
+ if (!board) content = '<p class="rp-fine">Bench results are temporarily unavailable.</p>';
+ const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a>${kind ? ` · ${esc(roomName(kind.room))}` : ''}</p><h1>${esc(name)}</h1><p class="rp-lede">${kind ? esc(judgeWord(kind.judge).replace(/^./, (c) => c.toUpperCase())) : 'Bench results'}.</p><nav class="rp-tabs" aria-label="Board view">${tabs}</nav></header>${benchState(kinds, board, cost)}<section aria-label="${esc(tab)} board">${content}</section>${datasetLink(kindId)}`;
+ return layout({ title: `${name}: AI board`, description: `${name} AI benchmark ratings, quality and cost.`, body, active: '/ai', path: aiPath(kindId) });
+}
+
+function aiModel({ model, summary }) {
+ const data = summary && summary.data || null;
+ const ratings = data && Array.isArray(data.ratings) ? data.ratings : [];
+ const entries = data && Array.isArray(data.entries) ? data.entries : [];
+ const kinds = [...new Set([...ratings.map((r) => r.kind_id), ...entries.map((e) => e.kind_id)])].filter(Boolean);
+ const rows = kinds.map((kind) => {
+ const r = ratings.find((item) => item.kind_id === kind);
+ const count = entries.filter((item) => item.kind_id === kind).length;
+ return `<tr><th scope="row"><a href="${aiPath(kind)}">${esc(kind)}</a></th><td>${r ? rating(r) : '—'}${r ? flag(r) : ''}</td><td>${r ? exact(r.games) : '—'}</td><td>${r ? share(r.win_rate) : '—'}</td><td>${count}</td></tr>`;
+ });
+ const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a> · Model</p><h1>${esc(model)}</h1><p class="rp-lede">Performance across every kind this model has played.</p></header>${benchState(summary)}${data ? `<p class="rp-meta">Total cost: ${usd(data.total_cost_usd)} · ${kinds.length} kinds</p>${rows.length ? aiTable(['Kind', 'Rating (95% interval)', 'Games', 'Win rate', 'Recent entries'], rows) : emptyBoard()}` : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;
+ return layout({ title: `${model}: AI model`, description: `${model} across OpenVibe benchmark kinds.`, body, active: '/ai', path: modelPath(model) });
+}
+
function notFound() {
function notFound() {
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });
}
}
-module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, developers, notFound, layout, setRelease, configure };
+module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, developers, aiIndex, aiBoard, aiModel, paretoRows, paretoChart, notFound, layout, setRelease, configure };
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+
+const assert = require('assert');
+const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot');
+
+(async () => {
+ let time = Date.parse('2026-10-28T12:00:00Z');
+ let down = false;
+ const source = stubFetch();
+ const calls = [];
+ const fetchImpl = async (url, opts) => {
+ const u = new URL(String(url));
+ if (u.port !== '5230') return source(url, opts);
+ calls.push(u.pathname + u.search);
+ if (down) throw new Error('Bench offline');
+ if (u.pathname === '/api/v1/kinds') return jsonResponse({ count: 1, kinds: [{ id: 'design.page', room: 'design', name: 'Page <design>', judge: 'human_pairwise' }] });
+ if (u.pathname === '/api/v1/boards/design.page') {
+ const type = u.searchParams.get('type');
+ const rows = {
+ elo: [
+ { subject: 'openai/gpt-4o', elo: 1043, ci_lower: 1005, ci_upper: 1081, games: 8, win_rate: 0.75, provisional: true },
+ { subject: 'model-b', elo: 980, ci_lower: 940, ci_upper: 1020, games: 8, win_rate: 0.25, provisional: true },
+ ],
+ pareto: [
+ { subject: 'openai/gpt-4o', quality: 0.8 },
+ { subject: 'model-b', quality: 0.6 },
+ ],
+ cost: [
+ { subject: 'model-b', entries: 8, solved: 6, total_cost_usd: 0.3, cost_per_solved: 0.05 },
+ { subject: 'openai/gpt-4o', entries: 8, solved: 8, total_cost_usd: 0.8, cost_per_solved: 0.1 },
+ ],
+ }[type];
+ return rows ? jsonResponse({ board: { kind_id: 'design.page', type, rows } }) : jsonResponse({}, 404);
+ }
+ if (u.pathname === '/api/v1/models/openai%2Fgpt-4o') return jsonResponse({ model: 'openai/gpt-4o', ratings: [{ kind_id: 'design.page', elo: 1043, ci_lower: 1005, ci_upper: 1081, games: 8, win_rate: 0.75 }], entries: [{ kind_id: 'design.page' }], total_cost_usd: 0.8 });
+ return jsonResponse({}, 404);
+ };
+ const t = await boot({ fetchImpl, now: () => time, env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230' } });
+ try {
+ await check('/ai groups kinds and links to all boards', async () => {
+ const r = await t.get('/ai');
+ assert.strictEqual(r.status, 200);
+ for (const s of ['Design', 'Page <design>', 'openai/gpt-4o', '8 games', '/ai/design.page?tab=pareto', '/ai/design.page?tab=cost']) assert.ok(r.text.includes(s), s);
+ assert.ok(!r.text.includes('Page <design>'));
+ });
+
+ await check('Elo shows ranks, intervals, win rates and provisional state', async () => {
+ const r = await t.get('/ai/design.page');
+ assert.strictEqual(r.status, 200);
+ for (const s of ['1043 ± 38', '75%', 'provisional', 'Rank', '95% interval', 'dataset JSONL']) assert.ok(r.text.includes(s), s);
+ });
+
+ await check('Pareto has an inline SVG, frontier labels and an exact-value table', async () => {
+ const r = await t.get('/ai/design.page?tab=pareto');
+ assert.strictEqual(r.status, 200);
+ for (const s of ['<svg', 'Quality against cost per solved task', 'Cost per solved task (USD', 'Frontier', 'model-b', 'dataset JSONL', '<table']) assert.ok(r.text.includes(s), s);
+ });
+
+ await check('cost and model views quote Bench numbers', async () => {
+ const cost = await t.get('/ai/design.page?tab=cost');
+ assert.strictEqual(cost.status, 200);
+ assert.ok(cost.text.includes('Cost per solved'));
+ assert.ok(cost.text.includes('$0.05'));
+ const model = await t.get('/ai/models/openai%2Fgpt-4o');
+ assert.strictEqual(model.status, 200);
+ assert.ok(model.text.includes('design.page'));
+ assert.ok(model.text.includes('1043 ± 38'));
+ });
+
+ await check('invalid kinds and models do not call Bench', async () => {
+ const before = calls.length;
+ assert.strictEqual((await t.get('/ai/bad!kind')).status, 404);
+ assert.strictEqual((await t.get('/ai/models/bad!model')).status, 404);
+ assert.strictEqual(calls.length, before);
+ });
+
+ await check('sitemap includes AI index and every kind', async () => {
+ const r = await t.get('/sitemap.xml');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('<loc>https://openvibe.report/ai</loc>'));
+ assert.ok(r.text.includes('<loc>https://openvibe.report/ai/design.page</loc>'));
+ });
+
+ await check('a Bench outage serves the last good pages with the read time', async () => {
+ down = true;
+ time += 60_001;
+ for (const path of ['/ai', '/ai/design.page', '/ai/design.page?tab=pareto', '/ai/models/openai%2Fgpt-4o']) {
+ const r = await t.get(path);
+ assert.strictEqual(r.status, 200, path);
+ assert.ok(r.text.includes('Showing the last good copy, read Oct 28, 12:00 UTC'), path);
+ }
+ });
+ } finally { await t.close(); }
+
+ await check('an initial Bench outage still renders an AI page', async () => {
+ const empty = await boot({ fetchImpl: async (url, opts) => String(url).includes(':5230') ? Promise.reject(new Error('offline')) : source(url, opts), env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230' } });
+ try {
+ const r = await empty.get('/ai');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('temporarily unavailable'));
+ } finally { await empty.close(); }
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+
+const assert = require('assert');
+const { createBenchClient } = require('../server/data/bench');
+const { check, done, jsonResponse } = require('./helpers/boot');
+
+(async () => {
+ let time = Date.parse('2026-10-28T12:00:00Z');
+ let down = false;
+ let calls = 0;
+ const fetchImpl = async () => {
+ calls++;
+ if (down) throw new Error('Bench offline');
+ return jsonResponse({ count: 1, kinds: [{ id: 'design.page', room: 'design' }] });
+ };
+ const logs = [];
+ const bench = createBenchClient({ config: { benchUrl: 'http://bench.test' }, fetchImpl, now: () => time, log: { warn: (line) => logs.push(line) } });
+
+ await check('Bench reads stay fresh for 60 seconds and keep their source time', async () => {
+ const first = await bench.kinds();
+ time += 59_999;
+ const second = await bench.kinds();
+ assert.deepStrictEqual(second, first);
+ assert.strictEqual(calls, 1);
+ assert.strictEqual(first.at, '2026-10-28T12:00:00.000Z');
+ });
+
+ await check('a failed refresh serves the last good copy with its time', async () => {
+ down = true;
+ time++;
+ const stale = await bench.kinds();
+ assert.strictEqual(stale.stale, true);
+ assert.strictEqual(stale.at, '2026-10-28T12:00:00.000Z');
+ assert.strictEqual(stale.data.kinds[0].id, 'design.page');
+ assert.strictEqual(calls, 2);
+ assert.strictEqual(logs.length, 1);
+ time += 30_000;
+ assert.strictEqual((await bench.kinds()).stale, true);
+ assert.strictEqual(calls, 2, 'the failed refresh also has a 60-second retry pause');
+ });
+
+ await check('invalid parameters never reach Bench', async () => {
+ assert.strictEqual(await bench.board('../bad'), null);
+ assert.strictEqual(await bench.board('design.page', 'wrong'), null);
+ assert.strictEqual(await bench.modelSummary('bad?name'), null);
+ assert.strictEqual(calls, 2);
+ assert.strictEqual(bench.isModelName('openai/gpt-4o'), true);
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });