openvibers/openvibe.exchange Commits
The Arena, rankings by task, and the model changelog (#91)
Changes
@@@ -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 neutraltitle until it is opened, the model still receives the turn it answers, and the page says so plainly. Accountexport 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 drawit as small swatches. Only `#rrggbb` values are stored and painted; a second refresh leaves the row untouched.nnew file mode 100644@@@ -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);@@@ -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); }nnew file mode 100644@@@ -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);++})();@@@ -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;}@@@ -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-varsnnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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'),nnew file mode 100644@@@ -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 };@@@ -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') },@@@ -33,7 +33,7 @@ const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sandconst 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 })),});});@@@ -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.@@@ -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' },nnew file mode 100644@@@ -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 };@@@ -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>`;nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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 };@@@ -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}`;nnew file mode 100644@@@ -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();++})();
@@ -12,6 +12,19 @@ What changed in OpenVibe.Exchange, newest first.
@@ -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
(`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
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.
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,
- **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
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.
it as small swatches. Only `#rrggbb` values are stored and painted; a second refresh leaves the row untouched.
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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);
@@ -516,6 +516,62 @@ body.ex-companion-shell > main.ex { flex: 1; min-height: 0; display: flex; }
@@ -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; }
.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 ─────────────────────────────────── */
/* ── Companions: memory and lorebooks ─────────────────────────────────── */
.ex-chat-links { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 12px; }
.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); }
.ex-locked { margin: 0 0 8px; font-size: .88rem; color: var(--ovk-muted); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -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);
+})();
@@ -3,6 +3,9 @@
@@ -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
// 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.
// 18+ confirmation never appear in a twin.
const views = require('./views/exchange');
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 { raw } = require('./render/html');
const { renderPage } = require('./render/layout');
const { renderPage } = require('./render/layout');
const { homeJsonLd } = require('./http/discovery');
const { homeJsonLd } = require('./http/discovery');
@@ -10,7 +13,7 @@ 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.';
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) {
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 });
const page = (path, o) => ({ html: renderPage({ viewer: { kind: 'anonymous' }, config, path, agentLayer: true, ...o }), title: o.title });
async function home(path) {
async function home(path) {
@@ -52,9 +55,22 @@ function createAgentPages(ctx) {
@@ -52,9 +55,22 @@ function createAgentPages(ctx) {
const [catalog, usage] = await Promise.all([ai.catalog(), ai.usage(30)]);
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 })) });
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') {
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;
return null;
}
}
@@ -28,7 +28,10 @@ const { createKeyStore } = require('./auth/keys');
@@ -28,7 +28,10 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createAi } = require('./ai');
const { createAi } = require('./ai');
+const { createBench } = require('./bench');
const { createOpenApi } = require('./openapi');
const { createOpenApi } = require('./openapi');
+const { createArenaStore } = require('./arena/store');
+const { createCatalogHistory } = require('./catalog-history');
const { createChatStore, createAdultStore } = require('./chat/store');
const { createChatStore, createAdultStore } = require('./chat/store');
const { createCharacterStore, createCompanionChatStore } = require('./companions/store');
const { createCharacterStore, createCompanionChatStore } = require('./companions/store');
const { createSharing } = require('./companions/sharing');
const { createSharing } = require('./companions/sharing');
@@ -38,6 +41,7 @@ const { createLorebooks } = require('./companions/lorebook');
@@ -38,6 +41,7 @@ const { createLorebooks } = require('./companions/lorebook');
const { createLocker } = require('./companions/locker');
const { createLocker } = require('./companions/locker');
const { createNoteTaker } = require('./companions/taking-notes');
const { createNoteTaker } = require('./companions/taking-notes');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
+const { createArenaRoutes } = require('./http/arena');
const { createCompanionRoutes } = require('./http/companions');
const { createCompanionRoutes } = require('./http/companions');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
@@ -68,6 +72,9 @@ async function createApp(opts = {}) {
@@ -68,6 +72,9 @@ async function createApp(opts = {}) {
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
const ai = opts.ai || createAi({ config, fetchImpl: opts.aiFetchImpl || globalThis.fetch, log, now: s.now });
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 chats = createChatStore(s);
const adult = createAdultStore(s);
const adult = createAdultStore(s);
// Companions (2026-11-06): the cards, their chats, and the one sharing system they use.
// Companions (2026-11-06): the cards, their chats, and the one sharing system they use.
@@ -93,7 +100,7 @@ async function createApp(opts = {}) {
@@ -93,7 +100,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
: 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 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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -202,6 +209,8 @@ async function createApp(opts = {}) {
@@ -202,6 +209,8 @@ async function createApp(opts = {}) {
}
}
// The companions routes come before the pages router: /companions, /c/... and /talk/...
// The companions routes come before the pages router: /companions, /c/... and /talk/...
app.use(createCompanionRoutes(ctx));
app.use(createCompanionRoutes(ctx));
+ // The Arena and the rankings: /arena, /arena/:id and /rankings.
+ app.use(createArenaRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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 };
@@ -72,6 +72,25 @@ function load(env = process.env) {
@@ -72,6 +72,25 @@ function load(env = process.env) {
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
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: {
api: {
publicUrl: trim(env.OV_EXCHANGE_API_URL || 'https://api.openvibe.exchange'),
publicUrl: trim(env.OV_EXCHANGE_API_URL || 'https://api.openvibe.exchange'),
openapiUrl: trim(env.OV_EXCHANGE_OPENAPI_URL || 'https://api.openvibe.exchange/openapi.json'),
openapiUrl: trim(env.OV_EXCHANGE_OPENAPI_URL || 'https://api.openvibe.exchange/openapi.json'),
new file mode 100644
new file mode 100644
@@ -0,0 +1,250 @@
@@ -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 };
@@ -21,6 +21,7 @@ const PUBLIC_PAGES = [
@@ -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: '/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: '/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: '/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: '/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.' },
{ 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) {
@@ -102,6 +103,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Exchange', url: abs('/'), note: 'every model, one account' },
{ title: 'OpenVibe.Exchange', url: abs('/'), note: 'every model, one account' },
{ title: 'Models', url: abs('/models') },
{ 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: 'The API', url: abs('/api') },
{ title: 'Sell your compute', url: abs('/sell') },
{ title: 'Sell your compute', url: abs('/sell') },
{ title: 'Uncensored (18+)', url: abs('/uncensored') },
{ title: 'Uncensored (18+)', url: abs('/uncensored') },
@@ -33,7 +33,7 @@ const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sand
@@ -33,7 +33,7 @@ const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sand
const textOf = (s) => esc(String(s || '')).replace(/\n/g, '<br>');
const textOf = (s) => esc(String(s || '')).replace(/\n/g, '<br>');
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, ai, openapi, chats, adult, log } = ctx;
+ const { config, ai, history, openapi, chats, adult, log } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
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 page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
@@ -44,7 +44,13 @@ function createPageRoutes(ctx) {
@@ -44,7 +44,13 @@ function createPageRoutes(ctx) {
title, index: false,
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>`),
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);
}, 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 ──────────────────────────────────
// ── Exchange v1, retired ──────────────────────────────────
function retiredHandler(req, res) {
function retiredHandler(req, res) {
@@ -160,6 +166,8 @@ function createPageRoutes(ctx) {
@@ -160,6 +166,8 @@ function createPageRoutes(ctx) {
nav: [
nav: [
{ href: '/chat/new?sealed=1', label: 'A sealed chat', icon: 'shield' },
{ href: '/chat/new?sealed=1', label: 'A sealed chat', icon: 'shield' },
{ href: '/models', label: 'Models', icon: 'spark' },
{ 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: '/api', label: 'API', icon: 'code' },
{ href: '/ai', label: 'AI in OpenVibe', icon: 'gear' },
{ 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>` : ''
@@ -575,11 +583,15 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
// ── Status ───────────────────────────────────────────────
// ── Status ───────────────────────────────────────────────
r.get('/status', async (req, res) => {
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, {
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'Status', description: 'Uptime per offer, the model changelog and incidents on OpenVibe.Exchange.',
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 })),
});
});
});
});
@@ -25,6 +25,10 @@ const TABLES = [
@@ -25,6 +25,10 @@ const TABLES = [
// erased with the chat it belongs to. Its plaintext was never here.
// 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_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' },
{ 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,
// 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
// 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.
// may be deleted while somebody else's chat still points at it, and that chat keeps its messages.
@@ -22,6 +22,8 @@ const NAV = [
@@ -22,6 +22,8 @@ const NAV = [
{ label: 'Home', href: '/' },
{ label: 'Home', href: '/' },
{ label: 'Models', href: '/models' },
{ label: 'Models', href: '/models' },
{ label: 'Chat', href: '/chat' },
{ label: 'Chat', href: '/chat' },
+ { label: 'Arena', href: '/arena' },
+ { label: 'Rankings', href: '/rankings' },
{ label: 'API', href: '/api' },
{ label: 'API', href: '/api' },
{ label: 'Sell compute', href: '/sell' },
{ label: 'Sell compute', href: '/sell' },
{ label: 'Uncensored', href: '/uncensored' },
{ label: 'Uncensored', href: '/uncensored' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,225 @@
@@ -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 };
@@ -168,7 +168,7 @@ function home({ catalog, usage, viewer, q = '', config = null }) {
@@ -168,7 +168,7 @@ function home({ catalog, usage, viewer, q = '', config = null }) {
<h1>Every AI model, one account.</h1>
<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>
<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' })}
${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>
</div>
${preview.length === 2 ? previewCard(preview) : ''}
${preview.length === 2 ? previewCard(preview) : ''}
</section>
</section>
@@ -180,6 +180,8 @@ ${panel({
@@ -180,6 +180,8 @@ ${panel({
${models.length ? `<p class="ex-more"><a href="/models">All ${models.length} models →</a></p>` : ''}`,
${models.length ? `<p class="ex-more"><a href="/models">All ${models.length} models →</a></p>` : ''}`,
})}
})}
<div class="ex-lines">
<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: '/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: '/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') })}
${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">
@@ -611,7 +613,29 @@ ${panel({ title: 'The cost, per day', body: usage.totals ? `<p class="ex-prose">
}
}
// ── Status ─────────────────────────────────────────────────
// ── 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 })));
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));
offers.sort((a, b) => (b.o.requests_7d || 0) - (a.o.requests_7d || 0));
const rows = offers.slice(0, 40).map(({ m, o }) => ({
const rows = offers.slice(0, 40).map(({ m, o }) => ({
@@ -622,8 +646,6 @@ function statusPage({ catalog, usage }) {
@@ -622,8 +646,6 @@ function statusPage({ catalog, usage }) {
speed: o.speed.p95_ms == null ? '–' : ms(o.speed.p95_ms),
speed: o.speed.p95_ms == null ? '–' : ms(o.speed.p95_ms),
updated: o.updated_at ? String(o.updated_at).slice(0, 10) : '–',
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">
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' })] })}
${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')}
${unavailable(catalog, 'The status board')}
@@ -635,7 +657,7 @@ ${panel({ title: 'Uptime per offer', note: 'Measured from real requests in the l
@@ -635,7 +657,7 @@ ${panel({ title: 'Uptime per offer', note: 'Measured from real requests in the l
{ key: 'speed', label: 'p95' },
{ key: 'speed', label: 'p95' },
{ key: 'updated', label: 'Changed', hide: 'sm' },
{ 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.' }) })}
], 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: '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>` })}
${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>`;
</div>`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -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 };
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
@@ -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
// 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).
// 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 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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -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();
+})();