openvibers/openvibe.exchange Commits

The Arena, rankings by task, and the model changelog (#91)

OP OpenVibers committed 7 days ago · 31ed916c31 · 22 files, +1434 −14

* The Arena, rankings by task, and the model changelog

- /arena: one prompt, two models answering blind side by side, a vote (left, right, tie, both bad),
  then both names and their receipts. The pair comes from the live catalogue (free tier first, different
  families where possible); each answer is a run on the person's allowance. The vote is a Bench
  human_pairwise judgement for that person under the prompt's task category, rate-limited per person and
  counted once per round (409 on a second).
- /rankings: Bench's board by task category joined to AI's catalogue: Elo, record, price per million out
  and in, measured p50 and uptime, and Elo per $/M out. Products and public apps from AI's aggregate usage.
- /status: a daily snapshot of the catalogue (exchange_model_snapshots) names models that arrived, left
  or changed price between the last two recorded days, with the dates.
- New: server/bench.js (service-token client), server/arena/{category,store}.js, server/catalog-history.js,
  server/views/arena.js, migration 0008. Account export/erasure covers arena rounds and votes. Nav,
  sitemap, llms.txt and Markdown twins updated.
- Config: OV_BENCH_URL / OV_BENCH_AUDIENCE / OV_BENCH_SCOPE / EXCHANGE_BENCH_TIMEOUT_MS, and
  EXCHANGE_ARENA_PER_DAY / _VOTES_PER_HOUR / _MAX_CHARS / _MAX_TOKENS.

Checked: OV_ALLOW_SUITE=1 npm test — 10/10 files, including test/arena.test.js (ask, blind pair, vote,
reveal, double vote, rate limit, other account, cross-site, Bench down) and test/rankings.test.js (join,
value column, apps, changelog new/gone/repriced, unavailable board). Screenshots at 390 and 1280, dark
and light, taken locally with scripts/shoot-arena.js: no overflow, no console errors, tap targets ok.

* The changelog separates a model's family from its name

"Llama 3 8B abliterated Llama" read as one string; the family now has its
own separator, so the new-model line is unambiguous.

Checked: node test/rankings.test.js and scripts/shoot-arena.js (clean).

* The Arena's migration is 0010 (0008 is companion memory, 0009 private chats)

* Merge remote-tracking branch 'origin/main' into review-91

# Conflicts:
#	public/css/exchange.css
#	server/app.js
#	server/views/exchange.js
#	test/helpers/boot.js

* Arena: "It's a tie", a whole number of votes, and the rankings' long uppercase captions folded into How to read it

* Rankings: developer apps are one row, Developer apps (N), never a named app

* Merge remote-tracking branch 'origin/main' into review-91

# Conflicts:
#	CHANGELOG.md
#	public/css/exchange.css

* rankings test: developer apps are one unnamed row

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 70a3b58..3dab5a3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -12,6 +12,19 @@ What changed in OpenVibe.Exchange, newest first.
(`ov-locker.js`, people-locked to the person) and Exchange stores only the envelope. The chat list shows a neutral
title until it is opened, the model still receives the turn it answers, and the page says so plainly. Account
export carries the envelope and deletion erases it.
++- **The Arena (`/arena`).** One prompt, two models answering side by side with no names, one vote (left, right, a
++ tie, or both bad), then both names and their receipts. The pair is chosen from the live catalogue, free-tier
++ capacity first and different families where possible, and each answer is a normal run on the person's own
++ allowance. A vote is a Bench `human_pairwise` judgement for that person, filed under the prompt's task category
++ (code, writing, chat, reasoning, translation, analysis), rate-limited per person and counted once per round.
++- **Rankings (`/rankings`).** Bench's board by task category beside the cheapest live price per million in and
++ out, the measured p50 and uptime, and a value column (rating points above 1000 per dollar of output). A
++ products-and-apps panel shows which models OpenVibe's products run, and developers' apps as one unnamed row, from AI's aggregate usage
++ and never a person.
++- **Model changelog (`/status`).** A daily snapshot of the catalogue in `exchange_model_snapshots` names the
++ models that arrived, left or changed their price between the last two recorded days, with the dates.
++- **Account data.** A person's arena rounds and votes are exported and erased with their account.
++
- **Theme palettes.** A Design theme's listing keeps the preview palette from Design's public API (background,
surface, text, accent and up to two more), in a new `palette` array on the API; the card and the listing page draw
it as small swatches. Only `#rrggbb` values are stored and painted; a second refresh leaves the row untouched.
ddiff --git a/migrations/0010_arena.sql b/migrations/0010_arena.sql
nnew file mode 100644
iindex 0000000..7399c18
---- /dev/null
++++ b/migrations/0010_arena.sql
@@@ -0,0 +1,50 @@
++-- phase: expand
++-- The Arena (2026-11-06): one prompt, two models answering blind side by side, one vote, then both names and
++-- their receipts. Each vote is also a Bench human_pairwise judgement (server/bench.js); these rows keep what the
++-- person saw and which vote was theirs, so a refresh never asks the models again and a round is never counted twice.
++-- Also the daily catalogue snapshot behind the model changelog on /status.
++
++CREATE TABLE IF NOT EXISTS exchange_arena_rounds (
++ id text COLLATE "C" PRIMARY KEY, -- exa_ + 26 Crockford base32 characters (ULID)
++ owner text COLLATE "C" NOT NULL, -- usr_…
++ prompt text NOT NULL,
++ category text COLLATE "C" NOT NULL, -- code | writing | chat | reasoning | translation | analysis
++ models text COLLATE "C" NOT NULL DEFAULT '[]', -- JSON: the two answers with their receipts
++ bench_round_id text COLLATE "C", -- brd_… in OpenVibe.Bench
++ bench_left text COLLATE "C", -- bte_… the left answer's Bench entry
++ bench_right text COLLATE "C", -- bte_… the right answer's Bench entry
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_arena_rounds_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT exchange_arena_rounds_prompt CHECK (length(prompt) BETWEEN 1 AND 8000),
++ CONSTRAINT exchange_arena_rounds_category CHECK (category ~ '^[a-z0-9-]{1,40}$')
++);
++CREATE INDEX IF NOT EXISTS exchange_arena_rounds_owner ON exchange_arena_rounds (owner, created_at DESC);
++
++CREATE TABLE IF NOT EXISTS exchange_arena_votes (
++ round_id text COLLATE "C" NOT NULL REFERENCES exchange_arena_rounds (id) ON DELETE CASCADE,
++ owner text COLLATE "C" NOT NULL, -- usr_… the voter
++ outcome text COLLATE "C" NOT NULL, -- left | right | tie | both_bad
++ bench_judgement_id text COLLATE "C", -- bju_… in OpenVibe.Bench
++ created_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (round_id, owner),
++ CONSTRAINT exchange_arena_votes_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT exchange_arena_votes_outcome CHECK (outcome IN ('left', 'right', 'tie', 'both_bad'))
++);
++CREATE INDEX IF NOT EXISTS exchange_arena_votes_owner ON exchange_arena_votes (owner, created_at DESC);
++
++-- One row per model per UTC day: what it cost and where it was served, so /status can say new, gone or repriced
++-- without keeping a second catalogue. Rows are only ever added.
++CREATE TABLE IF NOT EXISTS exchange_model_snapshots (
++ day date NOT NULL,
++ slug text COLLATE "C" NOT NULL, -- deepseek/deepseek-chat
++ name text COLLATE "C" NOT NULL,
++ family text COLLATE "C",
++ price_in double precision,
++ price_out double precision,
++ context integer,
++ provider text COLLATE "C",
++ community boolean NOT NULL DEFAULT false,
++ seen_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (day, slug)
++);
++CREATE INDEX IF NOT EXISTS exchange_model_snapshots_slug ON exchange_model_snapshots (slug, day DESC);
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex 51f26b4..87899a8 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -516,6 +516,62 @@ body.ex-companion-shell > main.ex { flex: 1; min-height: 0; display: flex; }
.ex-check { min-height: 44px; align-items: center; }
}
++/* ── The Arena and the rankings ─────────────────────────── */
++.ex-arena-form textarea { width: 100%; }
++.ex-arena-form-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-top: 10px; }
++.ex-arena-form-row .ex-muted { font-size: .82rem; }
++.ex-arena-steps { display: grid; gap: 10px; margin: 0 0 12px; padding: 0; list-style: none; counter-reset: arena; }
++.ex-arena-steps li { position: relative; padding-left: 34px; line-height: 1.5; }
++.ex-arena-steps li::before {
++ counter-increment: arena; content: counter(arena);
++ position: absolute; left: 0; top: 0; width: 24px; height: 24px; border-radius: 999px;
++ display: grid; place-items: center; font-size: .78rem; font-weight: 800;
++ background: var(--ovk-panel-2); color: var(--ovk-accent);
++}
++.ex-arena-recent { margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--ovk-line); }
++.ex-arena-recent li { border-bottom: 1px solid var(--ovk-line); }
++.ex-arena-recent a { display: flex; flex-wrap: wrap; gap: 4px 12px; justify-content: space-between; align-items: baseline; padding: 12px 14px; text-decoration: none; color: inherit; }
++.ex-arena-recent a:hover, .ex-arena-recent a:focus-visible { background: var(--ovk-panel-2); }
++.ex-arena-recent small { color: var(--ovk-muted); font-size: .78rem; }
++.ex-arena-count { margin: 0 0 8px; font-size: .82rem; }
++.ex-arena-prompt { margin: 0 0 8px; font-size: 1.02rem; line-height: 1.55; }
++
++.ex-arena-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
++@media (min-width: 860px) { .ex-arena-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
++.ex-arena-answer { min-width: 0; border: 1px solid var(--ovk-line); border-radius: 16px; background: var(--ovk-panel); padding: 12px 14px; }
++.ex-arena-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding-bottom: 8px; margin-bottom: 8px; border-bottom: 1px solid var(--ovk-line); }
++.ex-arena-head b { font-size: .95rem; }
++.ex-arena-head .ex-muted { font-size: .74rem; overflow-wrap: anywhere; }
++.ex-arena-letter {
++ display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 999px;
++ background: var(--ovk-panel-2); color: var(--ovk-accent); font-weight: 800; font-size: .82rem;
++}
++.ex-arena-text { font-size: .95rem; line-height: 1.55; overflow-wrap: anywhere; }
++.ex-arena-receipt { margin-top: 12px; }
++
++.ex-arena-vote { margin-top: 16px; }
++.ex-arena-vote fieldset { margin: 0; padding: 0; border: 0; }
++.ex-arena-vote legend { font-weight: 700; margin-bottom: 8px; }
++.ex-arena-vote-row { display: flex; flex-wrap: wrap; gap: 8px; }
++.ex-arena-vote-row .ovk-btn { flex: 1 1 140px; min-height: 44px; }
++.ex-arena-vote .ex-muted { margin: 8px 0 0; font-size: .8rem; }
++.ex-arena-voted { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; margin: 16px 0 0; }
++
++@media (min-width: 860px) {
++ .ex-rankings-table td:first-child, .ex-rankings-table th:first-child { white-space: nowrap; }
++}
++
++/* A table's model link is a finger target on a phone, not only a word in a cell. */
++@media (max-width: 700px) {
++ .ex-page .ovk-table th a, .ex-page .ovk-table td a { display: inline-flex; align-items: center; min-height: 44px; }
++ .ex-arena-voted .ovk-btn { min-height: 44px; }
++}
++
++/* The status changelog: one line per change, so a long model name can wrap without a flex split. */
++.ex-changes { list-style: none; margin: 0 0 12px; padding: 0; }
++.ex-changes li { padding: 8px 0; border-top: 1px solid var(--ovk-line); font-size: .9rem; line-height: 1.5; }
++.ex-changes li:first-child { border-top: 0; }
++
/* ── Companions: memory and lorebooks ─────────────────────────────────── */
.ex-chat-links { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 12px; }
.ex-locked { margin: 0 0 8px; font-size: .88rem; color: var(--ovk-muted); }
ddiff --git a/scripts/shoot-arena.js b/scripts/shoot-arena.js
nnew file mode 100644
iindex 0000000..8df40c4
---- /dev/null
++++ b/scripts/shoot-arena.js
@@@ -0,0 +1,128 @@
++'use strict';
++/**
++ * Design-review screenshots for the Arena, the rankings and the status changelog: 390 and 1280, dark and light.
++ *
++ * node scripts/shoot-arena.js [outDir]
++ *
++ * The shots go outside the repository (a temporary folder by default): pictures of the site, never committed.
++ * It boots the real app on a temp database with a mock Network, a stand-in AI and a stand-in Bench, seeds a
++ * blind round, a board and two days of catalogue snapshots, and drives headless Chrome.
++ */
++const path = require('path');
++const os = require('os');
++const { boot } = require('../test/helpers/boot');
++const { createFakeAi } = require('../test/helpers/ai');
++const { createFakeBench } = require('../test/helpers/bench');
++const { chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');
++
++const OUT = path.resolve(process.argv[2] || path.join(os.tmpdir(), 'exchange-arena-shots'));
++const CHROME = '/usr/bin/google-chrome';
++
++const BOARD = {
++ unavailable: false, kind_id: 'chat.arena', category: 'all',
++ rows: [
++ { subject: 'deepseek/deepseek-chat', elo: 1128, games: 42, wins: 27, losses: 13, ties: 2, win_rate: 0.6667, provisional: false },
++ { subject: 'qwen/qwen3-32b', elo: 1044, games: 31, wins: 17, losses: 13, ties: 1, win_rate: 0.5645, provisional: false },
++ { subject: 'meta-llama/llama-3-8b-abliterated', elo: 962, games: 12, wins: 4, losses: 8, ties: 0, win_rate: 0.3333, provisional: false },
++ ],
++};
++
++(async () => {
++ const bench = createFakeBench({ board: async (args) => ({ ...BOARD, category: args.category }) });
++ const t = await boot({
++ ai: createFakeAi({
++ chat: (args) => ({
++ content: args.model.startsWith('deepseek')
++ ? 'Use csv.DictReader, skip blank rows, and keep the sum in a Decimal so a float never rounds a currency column. Raise a clear error when the column is missing or holds a value that is not a number.'
++ : 'Read the file with open(..., newline=""), parse each row, check that the column exists, and add only values that parse as float. Report the line number of the first bad value instead of failing silently.',
++ model: args.model, provider: 'deepseek', run_id: 'run_shot', cost_usd: 0.0002,
++ usage: { tokens_in: 12, tokens_out: 30 }, tokens_in: 90, tokens_out: 240, latency_ms: 640,
++ why: ['the cheapest healthy offer inside the free ceiling'],
++ }),
++ }),
++ bench,
++ });
++ const alice = t.network.addUser('alice');
++
++ const asked = await t.get('/arena/ask', {
++ as: alice,
++ form: { prompt: 'Write a Python function that reads a CSV and sums a numeric column. Explain the edge cases.' },
++ });
++ if (asked.status !== 303) throw new Error('the round was not made: ' + asked.status + ' ' + asked.text.slice(0, 300));
++ const roundUrl = asked.headers.get('location');
++
++ const db = t.ctx.s.db;
++ for (const [slug, name, family, priceIn, priceOut] of [
++ ['deepseek/deepseek-chat', 'DeepSeek Chat', 'DeepSeek', 0.10, 0.20],
++ ['qwen/qwen3-32b', 'Qwen3 32B', 'Qwen', 0.40, 0.90],
++ ['old/model', 'Old Model', 'Other', 1, 2],
++ ]) {
++ await db.prepare(`INSERT INTO exchange_model_snapshots (day, slug, name, family, price_in, price_out, provider)
++ VALUES ('2026-11-05', ?, ?, ?, ?, ?, 'deepseek')`).run(slug, name, family, priceIn, priceOut);
++ }
++
++ const cookie = t.signIn(alice);
++ const base = t.base;
++ const shots = [
++ { name: 'arena-home', path: '/arena' },
++ { name: 'arena-blind', path: roundUrl },
++ ];
++
++ const browser = await chromium.launch({ executablePath: CHROME });
++ const problems = [];
++ async function shoot(shot, w, scheme) {
++ const ctx = await browser.newContext({
++ viewport: { width: w, height: 900 }, colorScheme: scheme, deviceScaleFactor: 2,
++ isMobile: w < 700, hasTouch: w < 700,
++ });
++ const url = new URL(base);
++ await ctx.addCookies([
++ { name: 'exchange_at', value: cookie.split('=')[1], domain: url.hostname, path: '/' },
++ { name: 'ov_token', value: cookie.split('=')[1], domain: url.hostname, path: '/' },
++ { name: 'ov_theme_id', value: scheme === 'light' ? 'daylight' : 'vibe', domain: url.hostname, path: '/' },
++ ]);
++ const pg = await ctx.newPage();
++ const errs = [];
++ const ours = (text) => !/openvibe\.network|CORS|Failed to load resource/.test(String(text));
++ pg.on('pageerror', (e) => { if (ours(e.message)) errs.push(e.message); });
++ pg.on('console', (m) => { if (m.type() === 'error' && ours(m.text())) errs.push(m.text()); });
++ await pg.goto(base + shot.path, { waitUntil: 'load' });
++ await pg.waitForTimeout(600);
++ const docW = await pg.evaluate(() => document.documentElement.scrollWidth);
++ if (docW !== w) problems.push(shot.name + ' ' + w + ' ' + scheme + ': scrollWidth ' + docW + ' > ' + w);
++ if (errs.length) problems.push(shot.name + ' ' + w + ' ' + scheme + ': console: ' + errs.join(' | '));
++ if (w === 390) {
++ const small = await pg.evaluate(() => {
++ const out = [];
++ for (const el of document.querySelectorAll('a, button, input, select, textarea')) {
++ if (el.closest('nav, .ovnav, .openvibe-navbar, .skip, .ovf-legal-inline, .ovf-col--brand, [data-network-navbar]')) continue;
++ const r = el.getBoundingClientRect();
++ if (!r.width || !r.height) continue;
++ const st = getComputedStyle(el);
++ if (st.opacity === '0' || st.visibility === 'hidden' || el.closest('.ovk-sr, .sr-only, .visually-hidden')) continue;
++ if (r.height < 44) out.push(el.tagName + '.' + (el.className || '') + ' [' + String(el.textContent || '').trim().slice(0, 20) + '] h=' + Math.round(r.height));
++ }
++ return out.slice(0, 8);
++ });
++ if (small.length) problems.push(shot.name + ' 390 ' + scheme + ': small tap targets: ' + small.join(', '));
++ }
++ await pg.screenshot({ path: path.join(OUT, shot.name + '-' + w + '-' + scheme + '.png'), fullPage: true });
++ await pg.close();
++ await ctx.close();
++ }
++
++ for (const shot of shots) for (const [w, scheme] of [[390, 'dark'], [390, 'light'], [1280, 'dark'], [1280, 'light']]) await shoot(shot, w, scheme);
++
++ // The vote, then the reveal, then the board and the changelog.
++ await t.get(roundUrl + '/vote', { as: alice, form: { outcome: 'left' } });
++ for (const shot of [
++ { name: 'arena-revealed', path: roundUrl },
++ { name: 'rankings', path: '/rankings?category=code' },
++ { name: 'status', path: '/status' },
++ ]) for (const [w, scheme] of [[390, 'dark'], [390, 'light'], [1280, 'dark'], [1280, 'light']]) await shoot(shot, w, scheme);
++
++ await browser.close();
++ await t.close();
++ process.stdout.write((problems.length ? 'PROBLEMS:\n' + problems.join('\n') : `clean: ${OUT}`) + '\n');
++ process.exit(problems.length ? 1 : 0);
++})();
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex f128dac..20c8a3d 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -3,6 +3,9 @@
// own render through the same views and layout the HTML routes use, always signed out: a chat, a try box and the
// 18+ confirmation never appear in a twin.
const views = require('./views/exchange');
++const arenaViews = require('./views/arena');
++const { CATEGORIES } = require('./arena/category');
++const { ARENA_KIND } = require('./bench');
const { raw } = require('./render/html');
const { renderPage } = require('./render/layout');
const { homeJsonLd } = require('./http/discovery');
@@@ -10,7 +13,7 @@ const { homeJsonLd } = require('./http/discovery');
const HOME_DESCRIPTION = 'OpenVibe.Exchange: every AI model, one account. Live prices, speed and uptime of every offer, from the families on sale today and more as their providers join; chat with one model or three side by side; one OpenAI- and Anthropic-compatible endpoint.';
function createAgentPages(ctx) {
-- const { config, ai } = ctx;
++ const { config, ai, bench, history } = ctx;
const page = (path, o) => ({ html: renderPage({ viewer: { kind: 'anonymous' }, config, path, agentLayer: true, ...o }), title: o.title });
async function home(path) {
@@@ -52,9 +55,22 @@ function createAgentPages(ctx) {
const [catalog, usage] = await Promise.all([ai.catalog(), ai.usage(30)]);
return page(p, { index: true, title: 'AI in OpenVibe', description: 'Which OpenVibe products use which models, and what each costs a day.', body: raw(views.aiPage({ catalog, usage })) });
}
++ if (p === '/rankings') {
++ const [catalog, usage, board] = await Promise.all([
++ ai.catalog(), ai.usage(30), bench.board({ kind: ARENA_KIND, category: 'all' }),
++ ]);
++ return page(p, {
++ index: true, title: 'Rankings',
++ description: 'Model rankings by task category from blind Arena votes, beside price, speed and uptime.',
++ body: raw(arenaViews.rankingsPage({ catalog, usage, board, category: 'all', sort: '', categories: ['all', ...CATEGORIES] })),
++ });
++ }
if (p === '/status') {
-- const [catalog, usage] = await Promise.all([ai.catalog(), ai.usage(30)]);
-- return page(p, { index: true, title: 'Status', description: 'Uptime per offer, the model changelog, and incidents.', body: raw(views.statusPage({ catalog, usage })) });
++ const [catalog, usage, changes] = await Promise.all([
++ ai.catalog(), ai.usage(30),
++ history ? history.changes().catch(() => null) : Promise.resolve(null),
++ ]);
++ return page(p, { index: true, title: 'Status', description: 'Uptime per offer, the model changelog, and incidents.', body: raw(views.statusPage({ catalog, usage, changes })) });
}
return null;
}
ddiff --git a/server/app.js b/server/app.js
iindex e2920db..eebf926 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -28,7 +28,10 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createAi } = require('./ai');
++const { createBench } = require('./bench');
const { createOpenApi } = require('./openapi');
++const { createArenaStore } = require('./arena/store');
++const { createCatalogHistory } = require('./catalog-history');
const { createChatStore, createAdultStore } = require('./chat/store');
const { createCharacterStore, createCompanionChatStore } = require('./companions/store');
const { createSharing } = require('./companions/sharing');
@@@ -38,6 +41,7 @@ const { createLorebooks } = require('./companions/lorebook');
const { createLocker } = require('./companions/locker');
const { createNoteTaker } = require('./companions/taking-notes');
const { createPageRoutes } = require('./http/pages');
++const { createArenaRoutes } = require('./http/arena');
const { createCompanionRoutes } = require('./http/companions');
const { createServiceReadiness } = require('./observability');
const { assetVersion, send } = require('./render/layout');
@@@ -68,6 +72,9 @@ async function createApp(opts = {}) {
const principal = createPrincipal({ config, keys });
const ai = opts.ai || createAi({ config, fetchImpl: opts.aiFetchImpl || globalThis.fetch, log, now: s.now });
++ const bench = opts.bench || createBench({ config, fetchImpl: opts.benchFetchImpl || globalThis.fetch, log, now: s.now });
++ const arenaStore = createArenaStore(s);
++ const history = createCatalogHistory({ s, log });
const chats = createChatStore(s);
const adult = createAdultStore(s);
// Companions (2026-11-06): the cards, their chats, and the one sharing system they use.
@@@ -93,7 +100,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
const openapi = createOpenApi({ config, fetchImpl: opts.openapiFetch || globalThis.fetch, log });
-- const ctx = { config, s, keys, sso, principal, ai, openapi, chats, adult, adultAccount, characters, companionChats, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, log };
++ const ctx = { config, s, keys, sso, principal, ai, bench, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, log };
const app = express();
app.disable('x-powered-by');
@@@ -202,6 +209,8 @@ async function createApp(opts = {}) {
}
// The companions routes come before the pages router: /companions, /c/... and /talk/...
app.use(createCompanionRoutes(ctx));
++ // The Arena and the rankings: /arena, /arena/:id and /rankings.
++ app.use(createArenaRoutes(ctx));
app.use(createPageRoutes(ctx));
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/arena/category.js b/server/arena/category.js
nnew file mode 100644
iindex 0000000..e12f92f
---- /dev/null
++++ b/server/arena/category.js
@@@ -0,0 +1,64 @@
++'use strict';
++
++/**
++ * Which kind of work a person's arena prompt is: code, writing, chat, reasoning, translation or analysis. The
++ * category is recorded as the Bench task's class, so the leaderboard can say which models are worth it for the
++ * kind of thing the person actually asked. No model call: a few strong words and the shape of the text are
++ * enough, and a wrong guess only moves one vote between two honest boards.
++ */
++const CATEGORIES = Object.freeze(['code', 'writing', 'chat', 'reasoning', 'translation', 'analysis']);
++const DEFAULT = 'chat';
++
++const SIGNALS = {
++ translation: [
++ /\btranslat(e|ion|ed|ing)\b/, /\binto (french|spanish|german|italian|portuguese|japanese|korean|chinese|russian|arabic|dutch|polish|turkish|hindi)\b/,
++ /\bin (french|spanish|german|italian|portuguese|japanese|korean|chinese|russian|arabic|dutch|polish|turkish|hindi)\b/,
++ /\blocali[sz](e|ation)\b/, /\bsubtitles?\b/,
++ ],
++ code: [
++ /```/, /\bcode\b/, /\bfunction\b/, /\bclass\b/, /\bmethod\b/, /\bregex\b/, /\bapi\b/, /\bbug\b/,
++ /\bdebug\b/, /\bstack trace\b/, /\bcompile[rs]?\b/, /\b(refactor|unit test|sql query|query|typescript|javascript|python|rust|golang|java|c\+\+|react|node\.js|docker|kubernetes|git)\b/,
++ /\b(write|fix|review|explain) (a |the |this )?(code|script|function|query|program)\b/,
++ /(^|\n)\s*(const|let|var|def|class|function|import|from|select|create table|#include|public static)\b/i,
++ /[{};]\s*$/m,
++ ],
++ reasoning: [
++ /\bstep by step\b/, /\bprove\b/, /\bproof\b/, /\btheorem\b/, /\bcalculate\b/, /\bcompute\b/, /\bhow many\b/,
++ /\bprobability\b/, /\blogic(al)?\b/, /\bpuzzle\b/, /\bmath(s|ematics)?\b/, /\bequation\b/, /\bintegral\b/,
++ /\bwhy (does|do|is|are|would)\b/, /\bwhat happens if\b/, /\bdeduce\b/, /\binfer\b/,
++ ],
++ writing: [
++ /\bwrite (a|an|the|me)\b/, /\bessay\b/, /\bpoem\b/, /\bstory\b/, /\bemail\b/, /\bblog\b/, /\barticle\b/,
++ /\bcopy\b/, /\bslogan\b/, /\brewrite\b/, /\bedit\b/, /\bsummar(y|ise|ize)\b/, /\bheadline\b/, /\bcaption\b/,
++ /\bscript for (a|the)\b/, /\bcover letter\b/, /\bproduct description\b/,
++ ],
++ analysis: [
++ /\banaly[sz]e\b/, /\banalysis\b/, /\bcompare\b/, /\bcomparison\b/, /\bpros and cons\b/, /\btrade-?offs?\b/,
++ /\bstrateg(y|ies)\b/, /\bdecision\b/, /\bdata\b/, /\bevaluate\b/, /\bassess\b/, /\bbusiness\b/,
++ /\bmarket\b/, /\brisks?\b/, /\bchart\b/,
++ ],
++ chat: [
++ /\bhello\b/, /\bhi\b/, /\bthanks\b/, /\bwhat do you think\b/, /\btell me about\b/, /\bchat\b/,
++ /\bhow are you\b/, /\bopinion\b/,
++ ],
++};
++
++function classify(text) {
++ const source = String(text || '');
++ if (!source.trim()) return DEFAULT;
++ const lower = source.toLowerCase();
++ const scores = {};
++ for (const [name, patterns] of Object.entries(SIGNALS)) {
++ scores[name] = patterns.reduce((n, re) => n + (re.test(lower) ? 1 : 0), 0);
++ }
++ // A prompt that carries code is code, unless it is plainly a translation request.
++ if (scores.translation >= 2) return 'translation';
++ if (scores.code >= 2) return 'code';
++ if (scores.translation >= 1 && scores.translation >= scores.code) return 'translation';
++ const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
++ const [best, score] = ranked[0];
++ if (score >= 2 || (best !== 'chat' && score >= 1)) return best;
++ return DEFAULT;
++}
++
++module.exports = { classify, CATEGORIES, DEFAULT };
ddiff --git a/server/arena/store.js b/server/arena/store.js
nnew file mode 100644
iindex 0000000..e2f9941
---- /dev/null
++++ b/server/arena/store.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * A person's arena rounds and votes. Every read and write is scoped by the owner in the WHERE clause; a round
++ * id from another account finds nothing. The pair and its receipts live here so a refresh never runs the models
++ * again, and the vote row is the local half of the Bench judgement (one per person per round).
++ */
++function createArenaStore(s) {
++ const db = s.db;
++
++ async function create({ owner, prompt, category, models, benchRoundId = null, benchLeft = null, benchRight = null }) {
++ const id = s.newId('exa');
++ await db.prepare(`INSERT INTO exchange_arena_rounds
++ (id, owner, prompt, category, models, bench_round_id, bench_left, bench_right)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(id, owner, String(prompt).slice(0, 8000), String(category).slice(0, 40), JSON.stringify(models || []),
++ benchRoundId, benchLeft, benchRight);
++ return get(owner, id);
++ }
++
++ function parse(row) {
++ if (!row) return null;
++ let models = [];
++ try { models = JSON.parse(row.models || '[]'); } catch { models = []; }
++ return { ...row, models: Array.isArray(models) ? models : [] };
++ }
++
++ async function get(owner, id) {
++ return parse(await db.prepare('SELECT * FROM exchange_arena_rounds WHERE id = ? AND owner = ?').get(String(id || ''), owner));
++ }
++
++ async function recent(owner, limit = 8) {
++ const rows = await db.prepare('SELECT id, prompt, category, created_at FROM exchange_arena_rounds WHERE owner = ? ORDER BY created_at DESC LIMIT ?')
++ .all(owner, Math.max(1, Math.min(30, limit)));
++ return rows.map((r) => ({ ...r }));
++ }
++
++ async function voteOf(owner, roundId) {
++ return await db.prepare('SELECT * FROM exchange_arena_votes WHERE round_id = ? AND owner = ?').get(String(roundId || ''), owner) || null;
++ }
++
++ /** Record the vote after Bench accepted it. A second row for the same person and round is never written. */
++ async function castVote({ owner, roundId, outcome, benchJudgementId = null }) {
++ const row = await db.prepare(`INSERT INTO exchange_arena_votes (round_id, owner, outcome, bench_judgement_id)
++ VALUES (?, ?, ?, ?) ON CONFLICT (round_id, owner) DO NOTHING RETURNING *`)
++ .get(String(roundId), owner, String(outcome), benchJudgementId);
++ return row ? { ...row } : voteOf(owner, roundId);
++ }
++
++ /** Rounds this person asked for today: the arena's own daily allowance, apart from the chat's. */
++ async function askedToday(owner) {
++ const row = await db.prepare("SELECT COUNT(*) AS n FROM exchange_arena_rounds WHERE owner = ? AND created_at >= date_trunc('day', now())").get(owner);
++ return Number(row && row.n) || 0;
++ }
++
++ /** Votes this person cast in the last hour: the rate limit on the vote, per person. */
++ async function votesLastHour(owner) {
++ const row = await db.prepare("SELECT COUNT(*) AS n FROM exchange_arena_votes WHERE owner = ? AND created_at >= now() - interval '1 hour'").get(owner);
++ return Number(row && row.n) || 0;
++ }
++
++ return { create, get, recent, voteOf, castVote, askedToday, votesLastHour };
++}
++
++module.exports = { createArenaStore };
ddiff --git a/server/bench.js b/server/bench.js
nnew file mode 100644
iindex 0000000..02ff38e
---- /dev/null
++++ b/server/bench.js
@@@ -0,0 +1,100 @@
++'use strict';
++/**
++ * OpenVibe.Bench, from OpenVibe.Exchange.
++ *
++ * createRound({prompt, category, entries}) POST /api/v1/arena/rounds
++ * vote({roundId, subject, outcome, pair}) POST /api/v1/arena/rounds/:id/votes
++ * board({category}) GET /api/v1/boards/chat.arena?category=…
++ *
++ * The round is built by this service and the vote is cast for the person named in x-ov-subject, so the
++ * judgement is theirs (one per person per round) and this service can never vote on what it built. The board
++ * is a public read and is cached here for a minute, so a rankings page view does not cost Bench a request.
++ */
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
++const BOARD_MS = 60_000;
++const ARENA_KIND = 'chat.arena';
++
++async function jsonOf(res) {
++ const text = await res.text();
++ try { return text ? JSON.parse(text) : null; } catch (e) { return null; }
++}
++
++function createBench({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
++ const tokens = config.oauth.clientSecret ? createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: config.bench.audience, scope: config.bench.scope, fetch: fetchImpl,
++ }) : null;
++
++ let boardHit = new Map();
++ let warnedAt = 0;
++ const warned = (what, err) => {
++ if (now() - warnedAt < 60_000) return;
++ warnedAt = now();
++ log.warn(`[exchange] Bench ${what} unavailable: ${String((err && err.message) || err).slice(0, 200)}`);
++ };
++
++ async function call(method, path, { body = null, subject = null } = {}) {
++ if (!tokens) {
++ const err = new Error('Bench is not configured for this service');
++ err.code = 'bench.not_configured';
++ err.status = 503;
++ throw err;
++ }
++ const token = await tokens.getToken();
++ const res = await fetchImpl(`${config.bench.url}${path}`, {
++ method,
++ headers: {
++ 'Content-Type': 'application/json',
++ Accept: 'application/json',
++ Authorization: `Bearer ${token}`,
++ ...(subject ? { 'x-ov-subject': String(subject) } : {}),
++ },
++ ...(body ? { body: JSON.stringify(body) } : {}),
++ signal: AbortSignal.timeout(config.bench.timeoutMs),
++ });
++ const out = await jsonOf(res);
++ if (!res.ok) {
++ const err = new Error((out && (out.detail || out.title)) || `Bench answered ${res.status}`);
++ err.code = (out && out.code) || 'bench.failed';
++ err.status = res.status;
++ throw err;
++ }
++ return out;
++ }
++
++ async function createRound({ prompt, category, entries }) {
++ return await call('POST', '/api/v1/arena/rounds', {
++ body: { prompt: String(prompt).slice(0, 4000), category: String(category || 'chat').slice(0, 40), entries },
++ });
++ }
++
++ async function vote({ roundId, subject, outcome, pair }) {
++ return await call('POST', `/api/v1/arena/rounds/${encodeURIComponent(roundId)}/votes`, {
++ body: { outcome, pair }, subject,
++ });
++ }
++
++ /** The board for one task category ('all' is every vote). A read failure is said, never invented. */
++ async function board({ kind = ARENA_KIND, category = 'all' } = {}) {
++ const key = `${kind}|${category}`;
++ const hit = boardHit.get(key);
++ if (hit && now() - hit.at < BOARD_MS) return hit.value;
++ try {
++ const body = await call('GET', `/api/v1/boards/${encodeURIComponent(kind)}?category=${encodeURIComponent(category)}`);
++ const value = body && body.board ? body.board : { unavailable: true, kind_id: kind, category, rows: [] };
++ boardHit.set(key, { at: now(), value });
++ if (boardHit.size > 16) boardHit.delete(boardHit.keys().next().value);
++ return value;
++ } catch (err) {
++ warned('board', err);
++ if (hit && now() - hit.at < 5 * 60_000) return { ...hit.value, stale: true };
++ return { unavailable: true, kind_id: kind, category, rows: [] };
++ }
++ }
++
++ return { createRound, vote, board, configured: Boolean(tokens), kind: ARENA_KIND };
++}
++
++module.exports = { createBench, ARENA_KIND };
ddiff --git a/server/catalog-history.js b/server/catalog-history.js
nnew file mode 100644
iindex 0000000..835511d
---- /dev/null
++++ b/server/catalog-history.js
@@@ -0,0 +1,89 @@
++'use strict';
++
++/**
++ * The model changelog behind /status: one row per model per UTC day, taken from the live catalogue. The next
++ * day's rows are compared with the last recorded day, so the page can say what arrived, what went away and what
++ * changed its price, with the dates it happened. Nothing is ever deleted or rewritten in place.
++ */
++const NOT_CHAT = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);
++const blended = (price) => (Number(price.in) + Number(price.out)) / 2;
++
++function cheapestOffer(model) {
++ const offers = (model.offers || []).filter((o) => o.price && Number.isFinite(Number(o.price.in)) && Number.isFinite(Number(o.price.out)));
++ return offers.sort((a, b) => blended(a.price) - blended(b.price))[0] || (model.offers || [])[0] || null;
++}
++
++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
++const samePrice = (a, b) => (a == null ? null : Number(a)) === (b == null ? null : Number(b));
++
++function createCatalogHistory({ s, log = console }) {
++ const db = s.db;
++ let capturedFor = null;
++
++ /** Record today's catalogue once; a page view and a restart both cost at most one insert batch a day. */
++ async function capture(catalog) {
++ if (!catalog || catalog.unavailable || !Array.isArray(catalog.models) || !catalog.models.length) return;
++ const day = dayOf(s.now());
++ if (capturedFor === day) return;
++ const existing = await db.prepare('SELECT COUNT(*) AS n FROM exchange_model_snapshots WHERE day = ?').get(day);
++ if (Number(existing && existing.n) > 0) { capturedFor = day; return; }
++
++ const statement = db.prepare(`INSERT INTO exchange_model_snapshots
++ (day, slug, name, family, price_in, price_out, context, provider, community)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (day, slug) DO NOTHING`);
++ let written = 0;
++ for (const model of catalog.models) {
++ if (!model || !model.slug) continue;
++ if (NOT_CHAT.has(String(model.kind || model.type || 'chat').toLowerCase())) continue;
++ const offer = cheapestOffer(model);
++ const price = offer && offer.price ? offer.price : null;
++ await statement.run(day, String(model.slug).slice(0, 120), String(model.name || model.slug).slice(0, 200),
++ model.family ? String(model.family).slice(0, 80) : null,
++ price ? Number(price.in) : null, price ? Number(price.out) : null,
++ Number.isFinite(Number(model.context)) ? Number(model.context) : null,
++ offer ? String(offer.provider_name || offer.provider || '').slice(0, 80) : null,
++ Boolean(offer && offer.trust === 'community'));
++ written++;
++ }
++ capturedFor = day;
++ if (written) log.info(`[OpenVibe.Exchange] catalogue snapshot for ${day}: ${written} models`);
++ }
++
++ /** What changed between the last two recorded days. `available` is false when there is only one day yet. */
++ async function changes() {
++ const days = (await db.prepare('SELECT DISTINCT day FROM exchange_model_snapshots ORDER BY day DESC LIMIT 2').all())
++ .map((r) => String(r.day).slice(0, 10));
++ if (!days.length) return { available: false, from: null, to: null, added: [], gone: [], repriced: [] };
++ if (days.length === 1) return { available: false, from: days[0], to: days[0], added: [], gone: [], repriced: [] };
++ const [to, from] = days;
++ const current = await db.prepare('SELECT * FROM exchange_model_snapshots WHERE day = ?').all(to);
++ const before = await db.prepare('SELECT * FROM exchange_model_snapshots WHERE day = ?').all(from);
++ const nowBy = new Map(current.map((r) => [r.slug, r]));
++ const oldBy = new Map(before.map((r) => [r.slug, r]));
++
++ const added = current.filter((r) => !oldBy.has(r.slug)).map((r) => ({
++ slug: r.slug, name: r.name, family: r.family, provider: r.provider, community: Boolean(r.community),
++ price_in: r.price_in == null ? null : Number(r.price_in), price_out: r.price_out == null ? null : Number(r.price_out),
++ }));
++ const gone = before.filter((r) => !nowBy.has(r.slug)).map((r) => ({ slug: r.slug, name: r.name, family: r.family, provider: r.provider, community: Boolean(r.community) }));
++ const repriced = [];
++ for (const row of current) {
++ const old = oldBy.get(row.slug);
++ if (!old) continue;
++ if (samePrice(row.price_in, old.price_in) && samePrice(row.price_out, old.price_out)) continue;
++ repriced.push({
++ slug: row.slug, name: row.name, family: row.family,
++ price_in: row.price_in == null ? null : Number(row.price_in),
++ price_out: row.price_out == null ? null : Number(row.price_out),
++ was_in: old.price_in == null ? null : Number(old.price_in),
++ was_out: old.price_out == null ? null : Number(old.price_out),
++ });
++ }
++ repriced.sort((a, b) => Math.abs((b.price_out || 0) - (b.was_out || 0)) - Math.abs((a.price_out || 0) - (a.was_out || 0)));
++ return { available: true, from, to, added, gone, repriced };
++ }
++
++ return { capture, changes };
++}
++
++module.exports = { createCatalogHistory };
ddiff --git a/server/config.js b/server/config.js
iindex 75ee6be..fdd0102 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -72,6 +72,25 @@ function load(env = process.env) {
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
},
++ // OpenVibe.Bench (server/bench.js): the Arena's rounds and each person's vote are a Bench
++ // human_pairwise judgement, and the rankings read the board by task category. The token is this
++ // service's own client-credentials grant (bench.round.create, bench.judgement.cast, bench.board.read).
++ bench: {
++ url: trim(env.OV_BENCH_URL || 'https://bench.openvibe.services'),
++ audience: env.OV_BENCH_AUDIENCE || 'openvibe.bench',
++ scope: env.OV_BENCH_SCOPE || 'bench.round.create bench.judgement.cast bench.board.read',
++ timeoutMs: Math.max(2000, int(env.EXCHANGE_BENCH_TIMEOUT_MS, 15_000)),
++ },
++
++ // The Arena (/arena): one prompt, two models, one vote. A round is two runs on the person's own
++ // allowance, and a vote is rate-limited per person so a ballot cannot be stuffed from one account.
++ arena: {
++ perDay: Math.max(1, int(env.EXCHANGE_ARENA_PER_DAY, 10)),
++ votesPerHour: Math.max(1, int(env.EXCHANGE_ARENA_VOTES_PER_HOUR, 30)),
++ maxChars: Math.max(200, int(env.EXCHANGE_ARENA_MAX_CHARS, 2000)),
++ maxTokens: Math.max(100, Math.min(4000, int(env.EXCHANGE_ARENA_MAX_TOKENS, 900))),
++ },
++
api: {
publicUrl: trim(env.OV_EXCHANGE_API_URL || 'https://api.openvibe.exchange'),
openapiUrl: trim(env.OV_EXCHANGE_OPENAPI_URL || 'https://api.openvibe.exchange/openapi.json'),
ddiff --git a/server/http/arena.js b/server/http/arena.js
nnew file mode 100644
iindex 0000000..2849afd
---- /dev/null
++++ b/server/http/arena.js
@@@ -0,0 +1,250 @@
++'use strict';
++
++/**
++ * The Arena and the rankings.
++ *
++ * GET /arena ask one prompt, two models answer blind; the person's last rounds
++ * POST /arena/ask run the pair on the person's own allowance, build the Bench round, redirect to it
++ * GET /arena/:id the pair, the vote form, and after the vote both names and their receipts
++ * POST /arena/:id/vote one vote per person per round; a Bench human_pairwise judgement, then the reveal
++ * GET /rankings Bench's board by task category beside AI's price, speed and uptime
++ *
++ * The models are chosen from the live catalogue, free-tier capacity first, by different families where it can
++ * be done. Each answer is a normal run on the person's allowance; each vote is theirs in Bench. A vote is
++ * rate-limited per person and a second vote on a round is refused, locally and again by Bench.
++ */
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++const kit = require('openvibe-shared/kit');
++const { asyncRouter } = require('./router');
++const { sameOrigin } = require('./principal');
++const { raw } = require('../render/html');
++const { send } = require('../render/layout');
++const views = require('../views/arena');
++const { classify, CATEGORIES } = require('../arena/category');
++const { ARENA_KIND } = require('../bench');
++
++const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
++const NOT_CHAT = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);
++const OUTCOMES = new Set(['left', 'right', 'tie', 'both_bad']);
++const blended = (price) => (Number(price.in) + Number(price.out)) / 2;
++const isChat = (m) => !NOT_CHAT.has(String(m.kind || m.type || 'chat').toLowerCase());
++
++/** Every model that may play, best first: free-tier capacity, then health, then price. */
++function candidatesOf(catalog) {
++ const out = [];
++ for (const model of catalog.models || []) {
++ if (!isChat(model) || model.uncensored) continue;
++ const offers = model.offers || [];
++ const priced = offers.filter((o) => o.price).sort((a, b) => blended(a.price) - blended(b.price));
++ const offer = priced[0] || offers[0];
++ if (!offer) continue;
++ const uptimes = offers.map((o) => Number(o.uptime)).filter(Number.isFinite);
++ const speeds = offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite);
++ out.push({
++ model, offer,
++ modelId: offer.id || model.slug,
++ free: offers.some((o) => o.free_tier === true || o.free === true),
++ uptime: uptimes.length ? Math.max(...uptimes) : 0,
++ p50: speeds.length ? Math.min(...speeds) : Number.POSITIVE_INFINITY,
++ price: offer.price ? blended(offer.price) : Number.POSITIVE_INFINITY,
++ });
++ }
++ out.sort((a, b) => (Number(b.free) - Number(a.free)) || (b.uptime - a.uptime) || (a.price - b.price) || a.model.slug.localeCompare(b.model.slug));
++ return out;
++}
++
++/** The first two, from different families when the catalogue allows it; the rest are the retry list. */
++function pickPair(candidates) {
++ if (candidates.length < 2) return { pair: [], rest: [] };
++ const first = candidates[0];
++ const second = candidates.find((c) => c.model.slug !== first.model.slug && (c.model.family || '') !== (first.model.family || ''))
++ || candidates.find((c) => c.model.slug !== first.model.slug);
++ const pair = second ? [first, second] : [];
++ const rest = candidates.filter((c) => !pair.includes(c));
++ return { pair, rest };
++}
++
++function createArenaRoutes(ctx) {
++ const { config, ai, bench, arenaStore, log } = ctx;
++ const r = asyncRouter();
++ r.use(express.urlencoded({ extended: false, limit: '64kb' }));
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
++ const owner = (req) => req.viewer.subject;
++ const signInPage = (req, res, { title, text, next, status = 401 }) => page(req, res, {
++ title, index: false,
++ body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'person', title, text, action: { href: `/auth/login?next=${encodeURIComponent(next || req.originalUrl)}`, label: 'Sign in with OpenVibe' } })}</div>`),
++ }, status);
++ const notFromHere = (req, res) => page(req, res, {
++ title: 'Not from this site', index: false,
++ body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'shield', title: 'That came from somewhere else', text: 'Use the form on OpenVibe.Exchange.' })}</div>`),
++ }, 403);
++
++ async function renderHome(req, res, { problem = null, status = 200 } = {}) {
++ const [catalog, rounds] = await Promise.all([
++ ai.catalog(),
++ signedIn(req) ? arenaStore.recent(owner(req), 6) : Promise.resolve([]),
++ ]);
++ return page(req, res, {
++ title: 'The Arena', index: !signedIn(req), cache: signedIn(req) ? null : PUBLIC_CACHE,
++ description: 'One prompt, two models, side by side and unnamed. Vote, then see both names, their prices and their receipts.',
++ body: raw(views.arenaHome({ viewer: req.viewer, catalog, rounds, problem, config })),
++ }, status);
++ }
++
++ async function renderRound(req, res, round, { problem = null, status = 200, notice = null } = {}) {
++ const vote = await arenaStore.voteOf(owner(req), round.id);
++ return page(req, res, {
++ title: 'The Arena', index: false,
++ body: raw(views.arenaRound({ round, vote, revealed: Boolean(vote), problem: problem || notice, config })),
++ }, status);
++ }
++
++ r.get('/arena', async (req, res) => renderHome(req, res));
++
++ r.post('/arena/ask', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to enter the Arena', text: 'Two models answer your prompt, blind.', next: '/arena' });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const prompt = String((req.body && req.body.prompt) || '').trim().slice(0, config.arena.maxChars);
++ if (!prompt) return renderHome(req, res, { problem: 'Write a prompt first.', status: 422 });
++
++ const asked = await arenaStore.askedToday(owner(req));
++ if (asked >= config.arena.perDay) {
++ return renderHome(req, res, { problem: `That is today's ${config.arena.perDay} arena prompts. The allowance resets at midnight UTC.`, status: 429 });
++ }
++
++ const catalog = await ai.catalog();
++ if (catalog.unavailable) return renderHome(req, res, { problem: 'The live catalogue is not reachable right now. Reload in a moment.', status: 503 });
++ const { pair, rest } = pickPair(candidatesOf(catalog));
++ if (pair.length < 2) return renderHome(req, res, { problem: 'Fewer than two models are live right now, so the Arena has nothing to compare.', status: 503 });
++
++ const answer = async (candidate) => {
++ const out = await ai.chat({
++ model: candidate.modelId, messages: [{ role: 'user', content: prompt }],
++ subject: owner(req), maxTokens: config.arena.maxTokens,
++ });
++ return {
++ slug: candidate.model.slug, name: candidate.model.name, family: candidate.model.family,
++ answer: out.content || '(no text)', model_id: candidate.modelId, run_id: out.run_id || null,
++ provider: out.provider || candidate.offer.provider,
++ provider_name: candidate.offer.provider_name || null,
++ answered_model: out.model || null,
++ cost_usd: out.cost_usd, tokens_in: out.tokens_in, tokens_out: out.tokens_out,
++ latency_ms: out.latency_ms, why: out.why, price: candidate.offer.price || null,
++ };
++ };
++
++ const answers = await Promise.all(pair.map((c) => answer(c).then((value) => ({ value })).catch((err) => ({ err }))));
++ const retries = rest.slice();
++ for (let i = 0; i < answers.length; i++) {
++ if (!answers[i].err) continue;
++ while (retries.length && !answers[i].value) {
++ const next = retries.shift();
++ try { answers[i] = { value: await answer(next) }; } catch (err) { answers[i] = { err }; }
++ }
++ }
++ if (answers.some((a) => a.err || !a.value)) {
++ const first = answers.find((a) => a.err);
++ log.warn('[OpenVibe.Exchange] arena answer failed:', (first && first.err && first.err.message) || 'no answer');
++ const reason = first && first.err && first.err.status === 429
++ ? 'Your free allowance for today is spent.'
++ : 'One of the two models did not answer. Try the prompt again.';
++ return renderHome(req, res, { problem: reason, status: first && first.err && first.err.status === 429 ? 429 : 502 });
++ }
++
++ const category = classify(prompt);
++ const models = answers.map((a) => a.value);
++ let built;
++ try {
++ built = await bench.createRound({
++ prompt, category,
++ entries: models.map((m) => ({
++ provider: m.provider || 'openvibe', model: m.slug, answer: m.answer,
++ cost_usd: m.cost_usd, ms: m.latency_ms, tokens: (m.tokens_in || 0) + (m.tokens_out || 0),
++ run_id: m.run_id,
++ })),
++ });
++ } catch (err) {
++ log.warn('[OpenVibe.Exchange] arena round was not recorded:', err.message);
++ return renderHome(req, res, { problem: 'The Arena could not record that round in OpenVibe.Bench, so no vote would count. Try again in a moment.', status: 503 });
++ }
++
++ const entries = (built && Array.isArray(built.entries)) ? built.entries : [];
++ const left = entries.find((e) => e.side === 'left') || entries[0] || {};
++ const right = entries.find((e) => e.side === 'right') || entries[1] || {};
++ if (!left.id || !right.id) {
++ return renderHome(req, res, { problem: 'The Arena could not record both answers. Try again.', status: 502 });
++ }
++ const round = await arenaStore.create({
++ owner: owner(req), prompt, category, models,
++ benchRoundId: built.round && built.round.id,
++ benchLeft: left.id, benchRight: right.id,
++ });
++ return res.redirect(303, `/arena/${round.id}`);
++ });
++
++ r.get('/arena/:id', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to see this round', text: 'Your rounds are yours.', next: `/arena/${encodeURIComponent(req.params.id)}` });
++ const round = await arenaStore.get(owner(req), req.params.id);
++ if (!round) return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such round', text: 'It may belong to another account, or the link is old.', action: { href: '/arena', label: 'The Arena' } })}</div>`) }, 404);
++ return renderRound(req, res, round);
++ });
++
++ r.post('/arena/:id/vote', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to vote', text: 'Your vote is a Bench judgement for your account.', next: `/arena/${encodeURIComponent(req.params.id)}` });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const round = await arenaStore.get(owner(req), req.params.id);
++ if (!round) return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such round', text: 'It may belong to another account.', action: { href: '/arena', label: 'The Arena' } })}</div>`) }, 404);
++
++ const existing = await arenaStore.voteOf(owner(req), round.id);
++ if (existing) return renderRound(req, res, round, { notice: 'You already voted on this round; it is counted once.', status: 409 });
++
++ const recent = await arenaStore.votesLastHour(owner(req));
++ if (recent >= config.arena.votesPerHour) {
++ return renderRound(req, res, round, { problem: `That is ${config.arena.votesPerHour} votes in an hour, the rate limit. Try again later.`, status: 429 });
++ }
++ const outcome = String((req.body && req.body.outcome) || '');
++ if (!OUTCOMES.has(outcome)) return renderRound(req, res, round, { problem: 'Pick left, right, a tie, or both bad.', status: 422 });
++
++ try {
++ const out = await bench.vote({
++ roundId: round.bench_round_id, subject: owner(req), outcome,
++ pair: { left: round.bench_left, right: round.bench_right },
++ });
++ await arenaStore.castVote({
++ owner: owner(req), roundId: round.id, outcome,
++ benchJudgementId: out && out.judgement ? out.judgement.id : null,
++ });
++ return res.redirect(303, `/arena/${round.id}`);
++ } catch (err) {
++ if (err.code === 'bench.already_judged') {
++ return renderRound(req, res, round, { notice: 'This vote is already counted in OpenVibe.Bench.', status: 409 });
++ }
++ log.warn('[OpenVibe.Exchange] arena vote failed:', err.message);
++ const reason = err.code === 'bench.not_configured' || err.status === 503
++ ? 'The Arena is not connected to OpenVibe.Bench right now, so the vote cannot count yet. Nothing was recorded; try again in a moment.'
++ : 'OpenVibe.Bench did not accept the vote. Nothing was recorded; try again in a moment.';
++ return renderRound(req, res, round, { problem: reason, status: 503 });
++ }
++ });
++
++ r.get('/rankings', async (req, res) => {
++ const wanted = typeof req.query.category === 'string' ? req.query.category : 'all';
++ const categories = ['all', ...CATEGORIES];
++ const category = categories.includes(wanted) ? wanted : 'all';
++ const sort = req.query.sort === 'value' ? 'value' : '';
++ const [catalog, usage, board] = await Promise.all([
++ ai.catalog(), ai.usage(30), bench.board({ kind: ARENA_KIND, category }),
++ ]);
++ return page(req, res, {
++ title: 'Rankings', index: true, cache: signedIn(req) ? null : PUBLIC_CACHE, agentLayer: true,
++ description: 'Model rankings by task category from blind Arena votes, beside each model’s price per million, measured speed and uptime.',
++ body: raw(views.rankingsPage({ catalog, usage, board, category, sort, categories })),
++ });
++ });
++
++ return r;
++}
++
++module.exports = { createArenaRoutes, candidatesOf, pickPair };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex f6d85e2..c3c9e08 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -21,6 +21,7 @@ const PUBLIC_PAGES = [
{ path: '/api', changefreq: 'weekly', priority: 0.7, title: 'The API', text: 'One OpenAI- and Anthropic-compatible endpoint, model ids, keys from Network and aggregate usage.' },
{ path: '/sell', changefreq: 'weekly', priority: 0.6, title: 'Sell your compute', text: 'OpenVibe Node offers a GPU or a spare machine to the network for credits; what is not sold here, said plainly.' },
{ path: '/uncensored', changefreq: 'weekly', priority: 0.6, title: 'Uncensored', text: 'Abliterated models for adults, with the floor: no minors, no non-consensual content, no mass harm.' },
++ { path: '/rankings', changefreq: 'daily', priority: 0.8, title: 'Rankings', text: 'Model rankings by task category from blind Arena votes, beside each model’s price per million, measured speed and uptime.' },
{ path: '/ai', changefreq: 'daily', priority: 0.6, title: 'AI in OpenVibe', text: 'Which OpenVibe products run which models through the same router, and what each costs a day.' },
{ path: '/status', changefreq: 'hourly', priority: 0.5, title: 'Status', text: 'Uptime per offer, the model changelog, and incidents.' },
];
@@@ -102,6 +103,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Start here', links: [
{ title: 'OpenVibe.Exchange', url: abs('/'), note: 'every model, one account' },
{ title: 'Models', url: abs('/models') },
++ { title: 'Rankings', url: abs('/rankings'), note: 'blind Arena votes by task, beside price and speed' },
{ title: 'The API', url: abs('/api') },
{ title: 'Sell your compute', url: abs('/sell') },
{ title: 'Uncensored (18+)', url: abs('/uncensored') },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 7db77fb..88f579e 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -33,7 +33,7 @@ const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sand
const textOf = (s) => esc(String(s || '')).replace(/\n/g, '<br>');
function createPageRoutes(ctx) {
-- const { config, ai, openapi, chats, adult, log } = ctx;
++ const { config, ai, history, openapi, chats, adult, log } = ctx;
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
@@@ -44,7 +44,13 @@ function createPageRoutes(ctx) {
title, index: false,
body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'person', title, text, action: { href: `/auth/login?next=${encodeURIComponent(next || req.originalUrl)}`, label: 'Sign in with OpenVibe' } })}</div>`),
}, status);
-- const catalogOf = () => ai.catalog();
++ // The catalogue is also the day's changelog row: the first read of a UTC day records what every model
++ // costs, so /status can say what is new, gone or repriced without keeping a second catalogue.
++ const catalogOf = async () => {
++ const catalog = await ai.catalog();
++ await history.capture(catalog).catch((err) => log.warn('[OpenVibe.Exchange] catalogue snapshot failed:', err.message));
++ return catalog;
++ };
// ── Exchange v1, retired ──────────────────────────────────
function retiredHandler(req, res) {
@@@ -160,6 +166,8 @@ function createPageRoutes(ctx) {
nav: [
{ href: '/chat/new?sealed=1', label: 'A sealed chat', icon: 'shield' },
{ href: '/models', label: 'Models', icon: 'spark' },
++ { href: '/arena', label: 'The Arena', icon: 'spark' },
++ { href: '/rankings', label: 'Rankings', icon: 'trophy' },
{ href: '/api', label: 'API', icon: 'code' },
{ href: '/ai', label: 'AI in OpenVibe', icon: 'gear' },
],
@@@ -575,11 +583,15 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
// ── Status ───────────────────────────────────────────────
r.get('/status', async (req, res) => {
-- const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);
++ const catalog = await catalogOf();
++ const [usage, changes] = await Promise.all([
++ ai.usage(30),
++ history.changes().catch((err) => { log.warn('[OpenVibe.Exchange] changelog failed:', err.message); return null; }),
++ ]);
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'Status', description: 'Uptime per offer, the model changelog and incidents on OpenVibe.Exchange.',
-- body: raw(views.statusPage({ catalog, usage })),
++ body: raw(views.statusPage({ catalog, usage, changes })),
});
});
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 3e5e277..5cf1a16 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -25,6 +25,10 @@ const TABLES = [
// erased with the chat it belongs to. Its plaintext was never here.
{ table: 'exchange_chat_seals', subject: 'owner', value: (usr) => usr, file: 'chat-seals.json', erase: 'delete' },
{ table: 'exchange_adult_confirmations', subject: 'subject', value: (usr) => usr, file: 'adult-confirmation.json', erase: 'delete' },
++ // The Arena (2026-11-06): the rounds this person asked for and the votes they cast are theirs. A vote
++ // goes first, then its round (the vote also cascades from the round).
++ { table: 'exchange_arena_votes', subject: 'owner', value: (usr) => usr, file: 'arena-votes.json', erase: 'delete' },
++ { table: 'exchange_arena_rounds', subject: 'owner', value: (usr) => usr, file: 'arena-rounds.json', erase: 'delete' },
// The companions (2026-11-06): a person's characters, their chats and the messages in them are theirs,
// exactly like the chats above. The messages go first, then the chats, then the characters: a character
// may be deleted while somebody else's chat still points at it, and that chat keeps its messages.
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 28ec28c..fb9e200 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -22,6 +22,8 @@ const NAV = [
{ label: 'Home', href: '/' },
{ label: 'Models', href: '/models' },
{ label: 'Chat', href: '/chat' },
++ { label: 'Arena', href: '/arena' },
++ { label: 'Rankings', href: '/rankings' },
{ label: 'API', href: '/api' },
{ label: 'Sell compute', href: '/sell' },
{ label: 'Uncensored', href: '/uncensored' },
ddiff --git a/server/views/arena.js b/server/views/arena.js
nnew file mode 100644
iindex 0000000..c52f26f
---- /dev/null
++++ b/server/views/arena.js
@@@ -0,0 +1,225 @@
++'use strict';
++
++/**
++ * The Arena and the rankings, built from openvibe-shared/kit in the site's one container.
++ *
++ * /arena one prompt, two models, blind: ask, vote, then both names and their receipts
++ * /arena/:id the pair the person asked, their vote, and what it was counted as
++ * /rankings Bench's board by task category beside AI's price, speed and uptime, and the value column
++ *
++ * The same rule as the rest of Exchange: a number is shown only when a read gave it. A missing price stays a
++ * dash; an unavailable board says so, and no figure is invented.
++ */
++const kit = require('openvibe-shared/kit');
++const { esc } = require('../render/html');
++const { mark, product, productMark } = require('./marks');
++const { providerLabel, familyLabel } = require('./exchange');
++
++const money = (n) => (n == null ? '–' : Number(n) === 0 ? 'free' : `$${Number(n) < 0.1 ? Number(n).toPrecision(2) : Number(n).toFixed(2)}`);
++const ms = (n) => (n == null ? '–' : n < 1000 ? `${Math.round(n)} ms` : `${(n / 1000).toFixed(1)} s`);
++const pct = (n) => (n == null ? '–' : `${(n * 100).toFixed(1)}%`);
++const count = (n) => (n == null ? '–' : Number(n).toLocaleString('en-US'));
++const blended = (price) => (price ? (Number(price.in) + Number(price.out)) / 2 : Number.POSITIVE_INFINITY);
++const cheapest = (m) => (m.offers || []).filter((o) => o.price).sort((a, b) => blended(a.price) - blended(b.price))[0] || null;
++
++const CATEGORY_LABELS = {
++ all: 'All', code: 'Code', writing: 'Writing', chat: 'Chat', reasoning: 'Reasoning',
++ translation: 'Translation', analysis: 'Analysis',
++};
++const labelOf = (c) => CATEGORY_LABELS[c] || (c ? c.charAt(0).toUpperCase() + c.slice(1) : 'Chat');
++
++function unavailable(what) {
++ return kit.notice({ kind: 'warn', text: `${what} is not reachable right now. Reload in a moment.` });
++}
++
++function panel({ title, note = '', id, body, cls = '' }) {
++ return kit.section({ title, note, id, cls: `ex-card${cls ? ` ${cls}` : ''}`, body: `<div class="ex-sec-body">${body}</div>` });
++}
++
++// ── The Arena ──────────────────────────────────────────────
++function arenaHome({ viewer, catalog, rounds = [], problem = null, config }) {
++ const signedIn = viewer && viewer.kind === 'user' && viewer.subject;
++ const models = (catalog.models || []).filter((m) => !m.uncensored);
++ const ask = signedIn
++ ? `<form class="ovk-form ex-arena-form" method="post" action="/arena/ask">
++${problem ? kit.notice({ kind: 'error', text: problem }) : ''}
++<label class="ex-label" for="arena-prompt">Your prompt</label>
++<textarea id="arena-prompt" name="prompt" rows="4" maxlength="${config.arena.maxChars}" required placeholder="Ask anything: explain a concept, write something, debug a snippet, compare two options…"></textarea>
++<div class="ex-arena-form-row">${kit.button({ label: 'Send to two models', type: 'submit' })}<span class="ex-muted">Two runs on your own free allowance. ${config.arena.perDay} arena prompts a day.</span></div>
++</form>`
++ : `<div class="ex-signin">${kit.button({ href: `/auth/login?next=${encodeURIComponent('/arena')}`, label: 'Sign in to ask' })}<span class="ex-muted">Two models answer blind; your vote is yours.</span></div>`;
++
++ const recent = rounds.length
++ ? `<ul class="ex-arena-recent">${rounds.map((r) => `<li><a href="/arena/${esc(r.id)}"><span>${esc(r.prompt.length > 90 ? `${r.prompt.slice(0, 90)}…` : r.prompt)}</span><small>${esc(labelOf(r.category))} · ${esc(String(r.created_at).slice(0, 10))}</small></a></li>`).join('')}</ul>`
++ : '';
++
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: 'The Arena', sub: 'One prompt, two models, side by side and unnamed. You pick. Then both names and their receipts.', actions: [kit.button({ href: '/rankings', label: 'Rankings', kind: 'quiet', icon: 'trophy' })] })}
++${catalog.unavailable ? unavailable('The live model catalogue') : ''}
++${panel({ title: 'Ask one question', note: 'The router picks two models from the free tier or the live catalogue. You never see which is which until you vote.', id: 'ask', body: ask })}
++${panel({ title: 'How it works', id: 'how', body: `<ol class="ex-list ex-arena-steps">
++<li><strong>Two answers, no names.</strong> The same prompt runs on two models and you see only the words, side by side.</li>
++<li><strong>You vote once.</strong> Left, right, a tie, or both bad. One vote per person per round, and a second is refused.</li>
++<li><strong>Then the reveal.</strong> Both names, the offer that answered, the price of the run, its speed and the router's reason.</li>
++<li><strong>It counts.</strong> Your vote is an OpenVibe.Bench human_pairwise judgement, filed under the kind of work your prompt asked for: code, writing, chat, reasoning, translation or analysis.</li>
++</ol><p class="ex-prose">The rankings put those ratings beside the price per million tokens, the measured speed and the uptime, so “worth it” is visible. One vote is one sample: the board marks a model provisional until it has enough games.</p>
++<p class="ex-prose"><strong>It is public.</strong> An arena prompt and both answers are recorded in Bench's open arena dataset, without your name, so anyone can check a vote. Do not paste anything private.</p>` })}
++${recent ? panel({ title: 'Your last rounds', id: 'recent', body: recent }) : ''}
++${models.length ? `<p class="ex-muted ex-arena-count">${models.length} models in the catalogue; two are chosen for each prompt.</p>` : ''}
++</div>`;
++}
++
++function answerColumn({ side, answer, model, revealed }) {
++ const letter = side === 'left' ? 'A' : 'B';
++ const head = revealed && model
++ ? `<header class="ex-arena-head">${mark(familyLabel(model), { size: 22, label: false })}<b>${esc(model.name)}</b>${model.answered_model && model.answered_model !== model.model_id ? `<span class="ex-muted">${esc(model.answered_model)}</span>` : ''}</header>`
++ : `<header class="ex-arena-head"><span class="ex-arena-letter">${letter}</span><b>Model ${letter}</b></header>`;
++ const receipt = revealed && model
++ ? `<details class="ex-receipt ex-arena-receipt"><summary>Receipt</summary><dl>
++<dt>Offer</dt><dd>${esc(providerLabel({ provider: model.provider, provider_name: model.provider_name }))}</dd>
++<dt>Price</dt><dd>${model.cost_usd == null ? '–' : Number(model.cost_usd) === 0 ? 'free' : `$${Number(model.cost_usd).toFixed(6)}`}${model.price ? ` · ${money(model.price.in)} in / ${money(model.price.out)} out per M` : ''}</dd>
++<dt>Tokens</dt><dd>${count(model.tokens_in)} in · ${count(model.tokens_out)} out</dd>
++<dt>Latency</dt><dd>${ms(model.latency_ms)}</dd>
++<dt>Why this offer</dt><dd>${Array.isArray(model.why) && model.why.length ? esc(model.why.join(' · ')) : 'The router picked the healthiest offer for the person’s floor and ceiling.'}</dd>
++</dl></details>`
++ : '';
++ return `<article class="ex-arena-answer" data-side="${esc(side)}"${revealed ? ` data-model="${esc(model ? model.slug : '')}"` : ''}>
++${head}
++<div class="ex-arena-text">${esc(answer || '(no text)').replace(/\n/g, '<br>')}</div>
++${receipt}
++</article>`;
++}
++
++function arenaRound({ round, vote = null, revealed = false, problem = null, config }) {
++ const [left, right] = round.models || [];
++ const voted = Boolean(vote);
++ const outcome = vote ? vote.outcome : null;
++ const voteForm = voted
++ ? `<p class="ex-arena-voted">${kit.badge(outcome === 'left' ? 'You chose A' : outcome === 'right' ? 'You chose B' : outcome === 'tie' ? 'You called it a tie' : 'You said both were bad', 'ok')} It is counted. ${kit.button({ href: '/arena', label: 'Ask another', kind: 'quiet' })}</p>`
++ : `<form class="ex-arena-vote" method="post" action="/arena/${esc(round.id)}/vote">
++${problem ? kit.notice({ kind: 'error', text: problem }) : ''}
++<fieldset><legend>Which answer is better?</legend>
++<div class="ex-arena-vote-row">
++<button type="submit" name="outcome" value="left" class="ovk-btn ovk-btn--primary">A is better</button>
++<button type="submit" name="outcome" value="right" class="ovk-btn ovk-btn--primary">B is better</button>
++<button type="submit" name="outcome" value="tie" class="ovk-btn ovk-btn--quiet">It’s a tie</button>
++<button type="submit" name="outcome" value="both_bad" class="ovk-btn ovk-btn--ghost">Both bad</button>
++</div></fieldset>
++<p class="ex-muted">One vote per person. It becomes a Bench judgement under “${esc(labelOf(round.category))}”, and the names are revealed right after.</p>
++</form>`;
++
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: voted ? 'Your vote, revealed' : 'Which one is better?', sub: `Filed under ${labelOf(round.category)}. One prompt, two answers, no names until you vote.`, back: { href: '/arena', label: 'Arena' } })}
++${panel({ title: 'Your prompt', id: 'prompt', body: `<p class="ex-arena-prompt">${esc(round.prompt).replace(/\n/g, '<br>')}</p><p class="ex-muted">${kit.badge(labelOf(round.category))} ${voted ? '' : 'The models are unnamed until you vote.'}</p>` })}
++${panel({ title: 'Two answers', id: 'pair', body: `<div class="ex-arena-grid" data-count="2">
++${answerColumn({ side: 'left', answer: left && left.answer, model: left, revealed, index: 0 })}
++${answerColumn({ side: 'right', answer: right && right.answer, model: right, revealed, index: 1 })}
++</div>${voteForm}` })}
++${voted ? panel({ title: 'What it counted as', id: 'counted', body: `<p class="ex-prose">This vote is a <strong>Bench human_pairwise judgement</strong> for your account, in the <strong>${esc(labelOf(round.category))}</strong> category. It moves both models' Elo on <a href="/rankings?category=${encodeURIComponent(round.category)}">the rankings</a>, beside their price per million and their measured speed and uptime.</p>` }) : ''}
++</div>`;
++}
++
++// ── Rankings ───────────────────────────────────────────────
++function indexModels(models) {
++ const map = new Map();
++ const add = (key, model) => { if (key && !map.has(key)) map.set(key, model); };
++ for (const m of models) {
++ add(m.slug, m);
++ add(m.canonical, m);
++ for (const o of m.offers || []) add(o.id, m);
++ }
++ return map;
++}
++
++function rankingsPage({ catalog, usage, board, category, sort, categories }) {
++ const models = (catalog.models || []).filter((m) => !m.uncensored);
++ const byKey = indexModels(models);
++ const rowsFor = (board.rows || []).filter((r) => r.games > 0).map((r) => {
++ const model = byKey.get(r.subject) || null;
++ const offer = model ? cheapest(model) : null;
++ const priceOut = offer && offer.price ? Number(offer.price.out) : null;
++ const priceIn = offer && offer.price ? Number(offer.price.in) : null;
++ const value = priceOut && priceOut > 0 ? Math.max(0, Number(r.elo) - 1000) / priceOut : null;
++ return {
++ subject: r.subject, model, elo: Number(r.elo), games: Number(r.games), winRate: Number(r.win_rate),
++ provisional: Boolean(r.provisional), priceIn, priceOut,
++ speed: offer && offer.speed ? offer.speed.p50_ms : null,
++ uptime: offer ? offer.uptime : null,
++ value,
++ name: model ? model.name : r.subject,
++ family: model ? familyLabel(model) : 'Other',
++ href: model ? `/m/${model.slug.split('/').map(encodeURIComponent).join('/')}` : null,
++ };
++ }).sort((a, b) => (sort === 'value'
++ ? (b.value == null ? -1 : b.value) - (a.value == null ? -1 : a.value)
++ : b.elo - a.elo || a.name.localeCompare(b.name)));
++
++ const tabs = categories.map((c) => `<a class="ex-chip${c === category ? ' is-on' : ''}" href="/rankings?category=${encodeURIComponent(c)}">${esc(labelOf(c))}</a>`).join('');
++ const sortLinks = [['Best rating', ''], ['Best value', 'value']].map(([label, s]) => `<a class="ex-chip${(sort || '') === s ? ' is-on' : ''}" href="/rankings?category=${encodeURIComponent(category)}${s ? `&sort=${s}` : ''}">${label}</a>`).join('');
++
++ const tableRows = rowsFor.map((r) => ({
++ model: r.href ? `<a href="${r.href}">${mark(r.family)}<b>${esc(r.name)}</b></a>` : `<b>${esc(r.name)}</b>`,
++ elo: `<b>${Math.round(r.elo)}</b>${r.provisional ? ' <small class="ex-muted">provisional</small>' : ''}`,
++ games: `${count(r.games)} game${r.games === 1 ? '' : 's'} · ${pct(r.winRate)} won`,
++ price: r.priceOut == null ? '<span class="ex-muted">free capacity</span>' : `<b>${money(r.priceOut)}</b> out · ${money(r.priceIn)} in`,
++ speed: ms(r.speed),
++ uptime: pct(r.uptime),
++ value: r.value == null ? '–' : `${Math.round(r.value).toLocaleString('en-US')}`,
++ }));
++
++ const boardNote = board.unavailable
++ ? 'The Bench board is not reachable right now.'
++ : board.rows && board.rows.length
++ ? `Elo from ${count(Math.round((board.rows || []).reduce((n, r) => n + (Number(r.games) || 0), 0) / 2))} votes.`
++ : 'No votes in this category yet.';
++
++ const products = (usage.by_product || []).slice(0, 12);
++ const productRows = products.map((p) => {
++ // AI folds every developer app into one row ("apps", with how many); a single app is never named publicly.
++ const isApp = p.type === 'apps' || p.type === 'app';
++ const prod = isApp ? { name: p.type === 'apps' ? `Developer apps (${count(p.apps || 0)})` : 'Developer apps' } : product(p.product);
++ const modelsUsed = (p.models || []).slice(0, 3).map((m) => m.model || m.provider).filter(Boolean).join(', ');
++ const who = isApp
++ ? `${kit.badge('App', 'accent')}<b>${esc(prod.name)}</b>`
++ : `${productMark(p.product, { size: 28 })}<b>${esc(prod.name)}</b>`;
++ return {
++ product: `<span class="ex-product-cell">${who}</span>`,
++ models: modelsUsed ? `<span class="ex-muted">${esc(modelsUsed)}</span>` : '–',
++ requests: count(p.requests),
++ tokens: count((p.tokens_in || 0) + (p.tokens_out || 0)),
++ };
++ });
++
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: 'Rankings', sub: 'What people prefer, by the kind of work asked, beside what each model costs and how fast it answers.', actions: [kit.button({ href: '/arena', label: 'Enter the Arena', kind: 'quiet', icon: 'spark' })] })}
++${catalog.unavailable ? unavailable('The live catalogue') : ''}
++${board.unavailable ? unavailable('The Bench board') : ''}
++<div class="ex-chips" role="group" aria-label="Task category">${tabs}</div>
++<div class="ex-chips" role="group" aria-label="Sort">${sortLinks}</div>
++${panel({ title: labelOf(category), note: boardNote, id: 'board', body: tableRows.length ? kit.table({
++ columns: [
++ { key: 'model', label: 'Model', primary: true, html: true },
++ { key: 'elo', label: 'Rating', html: true },
++ { key: 'games', label: 'Record' },
++ { key: 'price', label: 'Per million, out / in', html: true },
++ { key: 'speed', label: 'p50', hide: 'sm' },
++ { key: 'uptime', label: 'Uptime', hide: 'sm' },
++ { key: 'value', label: 'Elo per $/M out', num: true },
++ ],
++ rows: tableRows, label: `Rankings for ${labelOf(category)}`,
++ }) : kit.empty({ icon: 'spark', title: 'No votes here yet', text: category === 'all' ? 'The first Arena vote will open this board.' : `No one has voted on a ${labelOf(category).toLowerCase()} prompt yet. <a href="/arena">Ask one</a>.` }) })}
++${panel({ title: 'Products and apps', note: 'Which models OpenVibe’s products and developers’ apps run (apps as one row, never named), from AI’s own aggregate usage. No prompt and no person.', id: 'products', body: usage.unavailable ? '<p class="ex-prose">Usage is not reachable right now.</p>' : (productRows.length ? kit.table({
++ columns: [{ key: 'product', label: 'Requester', primary: true, html: true }, { key: 'models', label: 'Models', html: true }, { key: 'requests', label: 'Requests' }, { key: 'tokens', label: 'Tokens', hide: 'sm' }],
++ rows: productRows, label: 'Models by requester', caption: `The last ${usage.days || 30} days`,
++ }) : kit.empty({ icon: 'code', title: 'No product has called the router yet', text: 'When one does, its models and requests show here.' })) })}
++${panel({ title: 'How to read it', id: 'how', body: `<ul class="ex-list">
++<li><strong>Rating</strong> is Bench Elo from blind Arena votes. 1000 is where a model starts; a model with fewer than 10 games is provisional.</li>
++<li><strong>Price</strong> is the cheapest live offer’s price per million tokens, in and out, from OpenVibe.AI’s catalogue.</li>
++<li><strong>Speed and uptime</strong> are measured from real requests over the last seven days; an offer with no traffic shows a dash.</li>
++<li><strong>Value</strong> is rating points above 1000 per dollar of output. It rewards a model that wins and costs little; it never turns a dear model into a bargain. A model whose price is not published shows a dash.</li>
++<li><strong>Categories</strong> come from the prompt’s classification in the Arena: code, writing, chat, reasoning, translation and analysis.</li>
++</ul>` })}
++</div>`;
++}
++
++module.exports = { arenaHome, arenaRound, rankingsPage, labelOf, CATEGORY_LABELS };
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex d86963c..895e0b8 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -168,7 +168,7 @@ function home({ catalog, usage, viewer, q = '', config = null }) {
<h1>Every AI model, one account.</h1>
<p class="ex-lead">${families.length ? `${esc(listSaid(families.slice(0, 6)))} now, more as their providers join: the` : 'The'} live price, speed and uptime of every offer, one OpenAI-compatible endpoint, and a chat that puts two or three models side by side.</p>
${kit.find({ action: '/models', name: 'q', placeholder: families.length ? `Find a model: ${families.slice(0, 3).join(', ')}…` : 'Find a model or a provider', value: q, icon: 'search' })}
--<p class="ex-hero-actions">${kit.button({ href: '/models', label: 'All models' })} ${kit.button({ href: '/chat', label: 'Open chat', kind: 'quiet' })} ${kit.button({ href: '/api', label: 'The API', kind: 'ghost' })}</p>
++<p class="ex-hero-actions">${kit.button({ href: '/models', label: 'All models' })} ${kit.button({ href: '/chat', label: 'Open chat', kind: 'quiet' })} ${kit.button({ href: '/arena', label: 'Enter the Arena', kind: 'ghost' })}</p>
</div>
${preview.length === 2 ? previewCard(preview) : ''}
</section>
@@@ -180,6 +180,8 @@ ${panel({
${models.length ? `<p class="ex-more"><a href="/models">All ${models.length} models →</a></p>` : ''}`,
})}
<div class="ex-lines">
++${lineCard({ href: '/arena', name: 'The Arena', text: 'One prompt, two unnamed models, one vote: then both names and their receipts.', mark: mark('DeepSeek') })}
++${lineCard({ href: '/rankings', name: 'Rankings by task', text: 'What people prefer for code, writing, chat and reasoning, beside the price, speed and uptime. Worth it, measured.', mark: mark('Qwen') })}
${lineCard({ href: '/uncensored', name: 'Uncensored (18+)', text: 'Abliterated models for adults, with the floor that holds on every request.', mark: mark('Llama') })}
${lineCard({ href: '/sell', name: 'Run on people’s machines', text: 'Community GPUs through OpenVibe Node, labelled on every answer.', mark: mark('Qwen') })}
${lineCard({ href: '/api', name: 'Bring your own key, 0% fee', text: 'Your provider key pays the provider directly; the router never adds a fee on it.', mark: mark('GLM') })}
@@@ -611,7 +613,29 @@ ${panel({ title: 'The cost, per day', body: usage.totals ? `<p class="ex-prose">
}
// ── Status ─────────────────────────────────────────────────
--function statusPage({ catalog, usage }) {
++/**
++ * The model changelog: what the last two daily snapshots of the catalogue say. Three honest lists — arrived,
++ * gone, repriced — each with the dates they were recorded. A single day recorded so far says so rather than
++ * inventing a change.
++ */
++function changelogBody(changes) {
++ if (!changes) return '<p class="ex-prose">The changelog could not be read right now.</p>';
++ if (!changes.available) {
++ return `<p class="ex-prose">${changes.from ? `The catalogue was first recorded on ${esc(changes.from)}.` : 'The catalogue has not been recorded yet.'} A model is listed as new, gone or repriced from the second day of snapshots.</p>`;
++ }
++ const block = (title, items, render) => (items.length
++ ? `<h3 class="ex-h3">${esc(title)} <span class="ex-muted">${items.length}</span></h3><ul class="ex-changes">${items.map(render).join('')}</ul>`
++ : '');
++ const priceChange = (x) => `${x.was_in == null && x.was_out == null ? 'no price' : `${money(x.was_in)} in / ${money(x.was_out)} out`} → ${x.price_in == null && x.price_out == null ? 'free capacity' : `${money(x.price_in)} in / ${money(x.price_out)} out`}`;
++ const where = (x) => (x.community ? 'a community machine' : (x.provider ? providerLabel({ provider: x.provider, provider_name: x.provider }) : ''));
++ const added = block('New', changes.added, (x) => `<li><b>${esc(x.name)}</b>${x.family ? ` · <span class="ex-muted">${esc(x.family)}</span>` : ''}${where(x) ? ` on ${esc(where(x))}` : ''}${x.price_out == null ? '' : ` · ${money(x.price_out)} out per M`}</li>`);
++ const repriced = block('Price changed', changes.repriced, (x) => `<li><b>${esc(x.name)}</b> · ${esc(priceChange(x))}</li>`);
++ const gone = block('Gone', changes.gone, (x) => `<li><b>${esc(x.name)}</b>${where(x) ? ` <span class="ex-muted">on ${esc(where(x))}</span>` : ''}</li>`);
++ const body = `${added}${repriced}${gone}` || '<p class="ex-prose">No model arrived, left or changed its price between the two recorded days.</p>';
++ return `<p class="ex-muted">Between ${esc(changes.from)} and ${esc(changes.to)}, from the daily catalogue snapshot.</p>${body}`;
++}
++
++function statusPage({ catalog, usage, changes = null }) {
const offers = chatModels(catalog).flatMap((m) => m.offers.map((o) => ({ m, o })));
offers.sort((a, b) => (b.o.requests_7d || 0) - (a.o.requests_7d || 0));
const rows = offers.slice(0, 40).map(({ m, o }) => ({
@@@ -622,8 +646,6 @@ function statusPage({ catalog, usage }) {
speed: o.speed.p95_ms == null ? '–' : ms(o.speed.p95_ms),
updated: o.updated_at ? String(o.updated_at).slice(0, 10) : '–',
}));
-- const recent = [...offers].filter(({ o }) => o.updated_at).sort((a, b) => String(b.o.updated_at).localeCompare(String(a.o.updated_at))).slice(0, 10)
-- .map(({ m, o }) => `<li><time>${esc(String(o.updated_at).slice(0, 10))}</time> ${esc(m.name)} on ${esc(providerLabel(o))}${o.price ? ` · ${inOut(o.price)}` : ''}</li>`).join('');
return `<div class="ex-wrap ex-page">
${kit.head({ title: 'Status', sub: 'Uptime per offer, the model changelog, and incidents.', actions: [kit.button({ href: '/models', label: 'Models', kind: 'quiet' })] })}
${unavailable(catalog, 'The status board')}
@@@ -635,7 +657,7 @@ ${panel({ title: 'Uptime per offer', note: 'Measured from real requests in the l
{ key: 'speed', label: 'p95' },
{ key: 'updated', label: 'Changed', hide: 'sm' },
], rows, label: 'Uptime per offer' }) : kit.empty({ icon: 'spark', title: 'No offers yet', text: 'Offers appear here when a provider key or a Node machine is on.' }) })}
--${panel({ title: 'Model changelog', note: 'What changed in the catalogue, newest first. Prices move when a provider moves them.', id: 'changelog', body: recent ? `<ul class="ex-changelog">${recent}</ul>` : '<p class="ex-prose">No catalogue change recorded yet.</p>' })}
++${panel({ title: 'Model changelog', note: 'Models that arrived, left or changed their price, from a daily snapshot of the catalogue. Never a guessed date.', id: 'changelog', body: changelogBody(changes) })}
${panel({ title: 'Incidents', id: 'incidents', body: catalog.unavailable || catalog.stale ? kit.notice({ kind: 'error', text: 'OpenVibe.AI is not answering right now, so these numbers are not live. Live prices and the catalog on the other pages say so too.' }) : '<p class="ex-prose">No incident recorded. When an offer goes down, a failover is used and the answer says so; here is where an outage is written down.</p>' })}
${panel({ title: 'Requests today', body: usage.unavailable ? '<p class="ex-prose">Usage is not reachable right now.</p>' : `<p class="ex-prose">${count(usage.today ? usage.today.requests : 0)} requests today, ${count(usage.today ? (usage.today.tokens_in || 0) + (usage.today.tokens_out || 0) : 0)} tokens.</p>` })}
</div>`;
ddiff --git a/test/arena.test.js b/test/arena.test.js
nnew file mode 100644
iindex 0000000..15bb562
---- /dev/null
++++ b/test/arena.test.js
@@@ -0,0 +1,158 @@
++'use strict';
++/**
++ * The Arena: a prompt runs two models, the pair is blind, a vote is the person's own Bench judgement once and
++ * only once, and the reveal names both models with their receipts. The category classifier is checked on its own.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++const { createFakeBench } = require('./helpers/bench');
++const { classify } = require('../server/arena/category');
++
++(async () => {
++ const ai = createFakeAi({
++ chat: (args) => ({
++ content: `An answer from ${args.model} about the question.`,
++ model: args.model, provider: args.model.startsWith('deepseek') ? 'deepseek' : 'novita',
++ run_id: `run_${args.model}`, cost_usd: 0.0002, usage: { tokens_in: 12, tokens_out: 30 },
++ tokens_in: 12, tokens_out: 30, latency_ms: 500, why: ['cheapest healthy offer'],
++ }),
++ });
++ const bench = createFakeBench();
++ const t = await boot({ ai, bench });
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++
++ try {
++ await check('the classifier puts a prompt on the right task board', async () => {
++ assert.strictEqual(classify('Write a function that reads a CSV and sums a column'), 'code');
++ assert.strictEqual(classify('Debug this stack trace: TypeError at line 12'), 'code');
++ assert.strictEqual(classify('Translate “good morning” into Japanese'), 'translation');
++ assert.strictEqual(classify('Write a poem about the sea at night'), 'writing');
++ assert.strictEqual(classify('Prove that the square root of 2 is irrational, step by step'), 'reasoning');
++ assert.strictEqual(classify('Compare the pros and cons of two pricing strategies'), 'analysis');
++ assert.strictEqual(classify('Hello! How are you?'), 'chat');
++ });
++
++ await check('signed out, the Arena offers a sign-in and refuses to ask', async () => {
++ const home = await t.get('/arena');
++ assert.strictEqual(home.status, 200, home.text);
++ assert.ok(home.text.includes('Sign in to ask'));
++ const ask = await t.get('/arena/ask', { form: { prompt: 'Hello' } });
++ assert.strictEqual(ask.status, 401);
++ });
++
++ let roundUrl;
++ await check('one prompt runs two models on the person’s own allowance and records the pair', async () => {
++ const r = await t.get('/arena/ask', { as: alice, form: { prompt: 'Write a function that reads a CSV and sums a column' } });
++ assert.strictEqual(r.status, 303, r.text);
++ roundUrl = r.headers.get('location');
++ assert.match(roundUrl, /^\/arena\/exa_/);
++ assert.strictEqual(ai.calls.length, 2, 'two runs, one per model');
++ assert.ok(ai.calls.every((c) => c.subject === alice.subject), 'both runs are on the person’s behalf');
++ assert.deepStrictEqual(ai.calls.map((c) => c.model).sort(), ['deepseek/deepseek-chat@v1', 'qwen/qwen3-32b@v1']);
++
++ assert.strictEqual(bench.calls.rounds.length, 1);
++ const made = bench.calls.rounds[0];
++ assert.strictEqual(made.category, 'code');
++ assert.strictEqual(made.entries.length, 2);
++ assert.deepStrictEqual(made.entries.map((e) => e.model), ['deepseek/deepseek-chat', 'qwen/qwen3-32b']);
++ assert.ok(made.entries.every((e) => e.answer.includes('An answer from')));
++ });
++
++ await check('the pair is blind: the words, and no model name or provider', async () => {
++ const r = await t.get(roundUrl, { as: alice });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('Model A') && r.text.includes('Model B'));
++ assert.ok(r.text.includes('An answer from deepseek/deepseek-chat@v1'));
++ assert.ok(!r.text.includes('DeepSeek Chat'), 'no model name before the vote');
++ assert.ok(!r.text.includes('Qwen3 32B'), 'no model name before the vote');
++ assert.ok(!r.text.includes('Receipt'), 'no receipt before the vote');
++ assert.ok(r.text.includes('A is better') && r.text.includes('Both bad'));
++ assert.ok(r.text.includes('code'), 'the task category is said');
++ });
++
++ await check('a vote is the person’s own Bench judgement, once', async () => {
++ const r = await t.get(`${roundUrl}/vote`, { as: alice, form: { outcome: 'left' } });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.strictEqual(bench.calls.votes.length, 1);
++ const vote = bench.calls.votes[0];
++ assert.strictEqual(vote.subject, alice.subject);
++ assert.strictEqual(vote.outcome, 'left');
++ assert.strictEqual(vote.roundId, 'brd_TEST1');
++ assert.deepStrictEqual(vote.pair, { left: 'bte_TEST10', right: 'bte_TEST11' });
++
++ const page = await t.get(roundUrl, { as: alice });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('DeepSeek Chat') && page.text.includes('Qwen3 32B'), 'both names revealed');
++ assert.ok(page.text.includes('Receipt') && page.text.includes('Why this offer'), 'the receipts are open');
++ assert.ok(page.text.includes('You chose A'), 'the person’s vote is shown');
++ assert.ok(page.text.includes('$0.000200'), 'the run’s cost');
++ });
++
++ await check('a second vote on the same round is refused and never counted again', async () => {
++ const again = await t.get(`${roundUrl}/vote`, { as: alice, form: { outcome: 'right' } });
++ assert.strictEqual(again.status, 409, again.text);
++ assert.strictEqual(bench.calls.votes.length, 1, 'Bench was asked once');
++ const row = await t.ctx.s.db.maybe('SELECT COUNT(*)::int AS n FROM exchange_arena_votes WHERE owner = $1', [alice.subject]);
++ assert.strictEqual(Number(row.n), 1, 'one local vote');
++ });
++
++ await check('another account cannot open or vote on this round', async () => {
++ assert.strictEqual((await t.get(roundUrl, { as: bob })).status, 404);
++ assert.strictEqual((await t.get(`${roundUrl}/vote`, { as: bob, form: { outcome: 'left' } })).status, 404);
++ assert.strictEqual(bench.calls.votes.length, 1);
++ });
++
++ await check('a cross-site vote is refused', async () => {
++ const r = await t.get(`${roundUrl}/vote`, { as: alice, form: { outcome: 'left' }, origin: 'https://evil.test' });
++ assert.strictEqual(r.status, 403);
++ });
++ } finally { await t.close(); }
++
++ const capped = await boot({ ai: createFakeAi(), bench: createFakeBench(), env: { EXCHANGE_ARENA_VOTES_PER_HOUR: '1' } });
++ const carol = capped.network.addUser('carol');
++ try {
++ await check('votes are rate-limited per person', async () => {
++ const first = await capped.get('/arena/ask', { as: carol, form: { prompt: 'Explain a mutex' } });
++ const second = await capped.get('/arena/ask', { as: carol, form: { prompt: 'Explain a semaphore' } });
++ assert.strictEqual(first.status, 303, first.text);
++ assert.strictEqual(second.status, 303, second.text);
++ const one = await capped.get(`${first.headers.get('location')}/vote`, { as: carol, form: { outcome: 'tie' } });
++ assert.strictEqual(one.status, 303, one.text);
++ const two = await capped.get(`${second.headers.get('location')}/vote`, { as: carol, form: { outcome: 'left' } });
++ assert.strictEqual(two.status, 429, two.text);
++ assert.ok(two.text.includes('votes in an hour'));
++ });
++
++ await check('the daily arena allowance stops at its number', async () => {
++ const cappedTwo = await boot({ ai: createFakeAi(), bench: createFakeBench(), env: { EXCHANGE_ARENA_PER_DAY: '1' } });
++ const dave = cappedTwo.network.addUser('dave');
++ try {
++ assert.strictEqual((await cappedTwo.get('/arena/ask', { as: dave, form: { prompt: 'one' } })).status, 303);
++ const second = await cappedTwo.get('/arena/ask', { as: dave, form: { prompt: 'two' } });
++ assert.strictEqual(second.status, 429, second.text);
++ assert.ok(second.text.includes('arena prompts'));
++ } finally { await cappedTwo.close(); }
++ });
++ } finally { await capped.close(); }
++
++ const broken = createFakeBench({
++ vote: async () => { const err = new Error('Bench is down'); err.status = 503; err.code = 'bench.failed'; throw err; },
++ });
++ const t2 = await boot({ ai: createFakeAi(), bench: broken });
++ const erin = t2.network.addUser('erin');
++ try {
++ await check('when Bench is down the vote is refused and nothing is recorded locally', async () => {
++ const ask = await t2.get('/arena/ask', { as: erin, form: { prompt: 'Hello there' } });
++ assert.strictEqual(ask.status, 303, ask.text);
++ const vote = await t2.get(`${ask.headers.get('location')}/vote`, { as: erin, form: { outcome: 'left' } });
++ assert.strictEqual(vote.status, 503, vote.text);
++ assert.ok(vote.text.includes('cannot count yet'));
++ const row = await t2.ctx.s.db.maybe('SELECT COUNT(*)::int AS n FROM exchange_arena_votes WHERE owner = $1', [erin.subject]);
++ assert.strictEqual(Number(row.n), 0, 'no local vote without a Bench judgement');
++ });
++ } finally { await t2.close(); }
++
++ done();
++})();
ddiff --git a/test/helpers/bench.js b/test/helpers/bench.js
nnew file mode 100644
iindex 0000000..4c29586
---- /dev/null
++++ b/test/helpers/bench.js
@@@ -0,0 +1,38 @@
++'use strict';
++/**
++ * A stand-in OpenVibe.Bench client for the Arena tests: it records every round, vote and board read, and answers
++ * in the shape of server/bench.js. A test may pass its own `createRound`, `vote` or `board` to make one fail.
++ */
++function createFakeBench({ createRound = null, vote = null, board = null } = {}) {
++ const calls = { rounds: [], votes: [], boards: [] };
++ let n = 0;
++ return {
++ configured: true,
++ kind: 'chat.arena',
++ calls,
++ async createRound(args) {
++ calls.rounds.push(args);
++ if (createRound) return createRound(args);
++ n += 1;
++ return {
++ round: { id: `brd_TEST${n}`, status: 'finished', kind_id: 'chat.arena' },
++ category: args.category,
++ entries: args.entries.map((e, i) => ({
++ id: `bte_TEST${n}${i}`, side: i === 0 ? 'left' : 'right', provider: e.provider, model: e.model,
++ })),
++ };
++ },
++ async vote(args) {
++ calls.votes.push(args);
++ if (vote) return vote(args);
++ return { judgement: { id: `bju_TEST${n}`, outcome: args.outcome, method: 'human_pairwise' }, revealed: {} };
++ },
++ async board(args) {
++ calls.boards.push(args);
++ if (board) return board(args);
++ return { unavailable: false, kind_id: 'chat.arena', category: args.category, rows: [] };
++ },
++ };
++}
++
++module.exports = { createFakeBench };
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 67249d4..d1ed455 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -44,7 +44,7 @@ async function boot(opts = {}) {
// The M2 memory client goes out with this fetch. A test that does not stand one up gets an empty store, so
// no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).
const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, openapiFetch: opts.openapiFetch });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl, openapiFetch: opts.openapiFetch });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/rankings.test.js b/test/rankings.test.js
nnew file mode 100644
iindex 0000000..4228976
---- /dev/null
++++ b/test/rankings.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++/**
++ * The rankings join Bench's board with AI's catalogue: a rating, the price per million out and in, the measured
++ * speed and uptime, and the value column. The status changelog reads the daily catalogue snapshots: new, gone
++ * and repriced, each with the dates they were recorded.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++const { createFakeBench } = require('./helpers/bench');
++
++const BOARD = {
++ unavailable: false, kind_id: 'chat.arena', category: 'all',
++ rows: [
++ { subject: 'deepseek/deepseek-chat', elo: 1120, games: 24, wins: 15, losses: 7, ties: 2, win_rate: 0.6667, provisional: false },
++ { subject: 'qwen/qwen3-32b', elo: 980, games: 4, wins: 1, losses: 3, ties: 0, win_rate: 0.25, provisional: true },
++ { subject: 'no-catalog/model', elo: 1200, games: 12, wins: 9, losses: 3, ties: 0, win_rate: 0.75, provisional: false },
++ ],
++};
++
++const boardFor = (category) => ({ ...BOARD, category });
++
++(async () => {
++ const bench = createFakeBench({ board: async (args) => boardFor(args.category) });
++ const ai = createFakeAi();
++ ai.usageData.by_product.push({
++ product: 'apps', type: 'apps', apps: 2, requests: 5, tokens_in: 10, tokens_out: 20,
++ models: [{ model: 'deepseek-chat', requests: 5 }],
++ });
++ const t = await boot({ ai, bench });
++
++ try {
++ await check('the rankings join the board with price, speed and uptime', async () => {
++ const r = await t.get('/rankings?category=code');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(bench.calls.boards[0].category, 'code');
++ assert.ok(r.text.includes('DeepSeek Chat'), 'the catalog model by name');
++ assert.ok(r.text.includes('1120'), 'the Elo');
++ assert.ok(r.text.includes('$0.30'), 'the cheapest output price per million');
++ assert.ok(r.text.includes('$0.20'), 'the cheapest input price');
++ assert.ok(r.text.includes('520 ms'), 'the measured p50');
++ assert.ok(r.text.includes('97.0%'), 'the uptime');
++ assert.ok(r.text.includes('Elo per $/M out'), 'the value column');
++ assert.ok(r.text.includes('400'), 'rating above 1000 per dollar of output');
++ assert.ok(r.text.includes('provisional'), 'a model with four games is provisional');
++ assert.ok(r.text.includes('no-catalog/model'), 'a subject with no catalogue match is still listed');
++ assert.ok(r.text.includes('Code'), 'the category tab');
++ assert.match(r.headers.get('cache-control'), /^public, max-age=60/);
++ });
++
++ await check('best value sorts by the value column', async () => {
++ const r = await t.get('/rankings?category=code&sort=value');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('Best value'));
++ });
++
++ await check('the products and apps panel is aggregate only', async () => {
++ const r = await t.get('/rankings');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Products and apps'));
++ assert.ok(/OpenVibe\.Live|Live/.test(r.text), 'the product name');
++ assert.ok(r.text.includes('Developer apps (2)'), 'developer apps are one unnamed row, with how many');
++ assert.ok(!/app_[a-z0-9]/i.test(r.text), 'no developer app is named');
++ assert.ok(r.text.includes('No prompt and no person'), 'the aggregate promise');
++ assert.strictEqual(bench.calls.boards[bench.calls.boards.length - 1].category, 'all');
++ });
++
++ await check('the changelog reads the last two daily snapshots: new, gone, repriced', async () => {
++ const db = t.ctx.s.db;
++ const old = [['deepseek/deepseek-chat', 'DeepSeek Chat', 'DeepSeek', 0.10, 0.20], ['qwen/qwen3-32b', 'Qwen3 32B', 'Qwen', 0.40, 0.90], ['old/model', 'Old Model', 'Other', 1, 2]];
++ for (const [slug, name, family, priceIn, priceOut] of old) {
++ await db.prepare(`INSERT INTO exchange_model_snapshots (day, slug, name, family, price_in, price_out, provider)
++ VALUES ('2026-11-05', ?, ?, ?, ?, ?, 'test')`).run(slug, name, family, priceIn, priceOut);
++ }
++ const status = await t.get('/status');
++ assert.strictEqual(status.status, 200, status.text);
++ assert.ok(status.text.includes('2026-11-05'), 'the earlier day is named');
++ assert.ok(status.text.includes('New') && status.text.includes('Llama 3 8B abliterated'), 'the model new today');
++ assert.ok(status.text.includes('Gone') && status.text.includes('Old Model'), 'the model that left');
++ assert.ok(status.text.includes('Price changed') && status.text.includes('DeepSeek Chat'), 'the repriced model');
++ assert.ok(status.text.includes('$0.20 in / $0.30 out'), 'the new price');
++ assert.ok(status.text.includes('$0.10 in / $0.20 out'), 'the old price');
++ const today = await db.prepare("SELECT COUNT(*) AS n FROM exchange_model_snapshots WHERE day = CURRENT_DATE").get();
++ assert.strictEqual(Number(today.n), 3, 'today’s three chat models were recorded by the status read');
++ });
++ } finally { await t.close(); }
++
++ const down = await boot({ ai: createFakeAi(), bench: createFakeBench({ board: async () => ({ unavailable: true, category: 'all', rows: [] }) }) });
++ try {
++ await check('an unavailable board says so and invents no rating', async () => {
++ const r = await down.get('/rankings');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('The Bench board is not reachable right now'));
++ assert.ok(!r.text.includes('1120'));
++ });
++ } finally { await down.close(); }
++
++ done();
++})();