openvibers/openvibe.exchange Commits
Compare: openvibe.compare serves the comparison pages behind its own name (#191)
Changes
nnew file mode 100644@@@ -0,0 +1,104 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.compare — reference vhost for ov-vhost-install.++##++## The comparison site is served by OpenVibe.Exchange's process on 127.0.0.1:5150 (the same app, behind its own++## name: server/http/compare-site.js). This vhost passes the name through in Host, which is what makes the request++## the compare site's; every other name reaches Exchange's own pages. No proxy_cache: the app decides cacheability.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=compare_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=compare_api:10m rate=5r/s;++limit_req_zone $binary_remote_addr zone=compare_forms:10m rate=1r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.compare www.openvibe.compare;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.compare$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.compare;++++ ssl_certificate /etc/letsencrypt/live/openvibe.compare/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.compare/privkey.pem;++++ return 301 https://openvibe.compare$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.compare;++ include snippets/security-txt.conf;++++ ssl_certificate /etc/letsencrypt/live/openvibe.compare/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.compare/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.compare.access.log;++ error_log /var/log/nginx/openvibe.compare.error.log;++++ # The remark API's JSON body cap is 32 KB; leave room for headers.++ client_max_body_size 64k;++++ add_header X-Content-Type-Options nosniff always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ location = /auth/me {++ limit_req zone=compare_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5150;++ }++++ location /auth/ {++ limit_req zone=compare_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5150;++ }++++ location /api/ {++ limit_req zone=compare_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5150;++ }++++ # A reader's remark is the only form on the site.++ location = /remarks {++ limit_req zone=compare_forms burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5150;++ }++++ # Host-local scrapers only; OpenVibe.Events' account delivery is loopback-only.++ location ~* ^/metrics(/|$) { return 404; }++ location ~* ^/internal(/|$) { return 404; }++++ location / {++ proxy_pass http://127.0.0.1:5150;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}@@@ -48,6 +48,7 @@ const { createPageRoutes } = require('./http/pages');const { createConsoleRoutes } = require('./http/console');const { createArenaRoutes } = require('./http/arena');const { createCompareApi } = require('./http/compare');++const { createCompareSite } = require('./http/compare-site');const { createCompanionRoutes } = require('./http/companions');const { createServiceReadiness } = require('./observability');const { assetVersion, send } = require('./render/layout');@@@ -263,6 +264,11 @@ async function createApp(opts = {}) {// Mounted before Exchange v1's catch-all, which answers 410 to everything else under /api/v1.app.use('/api/v1/compare', createCompareApi(ctx));++ // ── openvibe.compare, behind its own name ───────────────++ // The vhost for openvibe.compare proxies here with Host: openvibe.compare; every other Host passes through to++ // Exchange's own pages, so one process serves both names without either site's routes leaking into the other.++ app.use(createCompareSite(ctx));++// ── Exchange v1's JSON API: gone ────────────────────────app.use('/api/v1', (req, res) => {res.set('Cache-Control', 'public, max-age=86400');@@@ -27,7 +27,7 @@ const QUALITY_KINDS = Object.freeze([const NOT_CHAT = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);const KIND = 'ai.models';--const finite = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);++const finite = (v) => (v === null || v === undefined || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null));const iso = (v, fallback = null) => {if (!v) return fallback;const d = new Date(v);nnew file mode 100644@@@ -0,0 +1,558 @@++'use strict';++++/**++ * openvibe.compare — the comparison site itself (plan 297), served by Exchange behind its own name.++ *++ * The pages are plain kit markup and complete without JavaScript. They are a read of the API in++ * server/http/compare.js; the only write is a reader's remark, which becomes a pending benchmark case for review++ * and is never run or published before a person accepts it.++ */++const express = require('express');++const rateLimit = require('express-rate-limit');++const kit = require('openvibe-shared/kit');++const seo = require('openvibe-shared/seo');++const { asyncRouter } = require('./router');++const { send } = require('../render/layout');++const { esc, raw } = require('../render/html');++++const SITE = Object.freeze({++ name: 'OpenVibe.Compare',++ tagline: 'Compare anything, starting with AI models.',++ service: 'exchange',++ home: '/',++ icon: 'exchange',++ nav: [++ { label: 'Compare', href: '/', icon: 'fa-scale-balanced' },++ { label: 'Side by side', href: '/compare', icon: 'fa-table-columns' },++ { label: 'Versions', href: '/versions', icon: 'fa-code-branch' },++ { label: 'How it measures', href: '/method', icon: 'fa-ruler' },++ { label: 'Exchange', href: 'https://openvibe.exchange', icon: 'fa-arrow-up-right-from-square' },++ ],++});++++const money = (n) => (n == null ? '–' : Number(n) === 0 ? '$0' : `$${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 day = (v) => {++ if (!v) return null;++ const d = new Date(v);++ return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);++};++const monthYear = (v) => {++ const d = v ? new Date(v) : new Date();++ return Number.isNaN(d.getTime()) ? '' : d.toLocaleString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });++};++const modelHref = (slug) => `/models/${String(slug).split('/').map(encodeURIComponent).join('/')}`;++const modelOf = (href) => String(href || '').split('/models/')[1] || '';++++function qualityText(q) {++ if (!q || !q.available) return 'not measured';++ return `${q.score}${q.games ? ` · ${count(q.games)} games` : ''}`;++}++++function privacyText(p) {++ if (!p || !p.label) return '<span class="ovk-muted">not stated</span>';++ return esc(p.label);++}++++function policyText(p) {++ if (!p) return '<span class="ovk-muted">not stated</span>';++ const bits = [];++ bits.push(p.trains === false ? 'no training' : p.trains === true ? 'may train' : 'training not stated');++ if (p.retention_days != null) bits.push(p.retention_days === 0 ? 'no retention' : `kept ${p.retention_days} d`);++ return esc(bits.join(' · '));++}++++function badgeFor(text, tone = '') {++ return kit.badge(text, tone);++}++++function changeLine(c) {++ if (!c || !c.available) return '<span class="ovk-muted">not measured</span>';++ const arrow = c.delta === 0 ? 'same' : `${c.delta > 0 ? '+' : ''}${c.delta}${c.unit === 'ms' ? ' ms' : c.unit === 'USD' ? ` $${Math.abs(c.delta).toFixed(4)}` : c.unit === 'Elo' ? ' Elo' : ''}`;++ return `${esc(c.label)}: <b>${esc(arrow)}</b>${c.pct == null ? '' : ` (${c.pct > 0 ? '+' : ''}${c.pct}%)`}${c.direction === 'better' ? ' — better' : c.direction === 'worse' ? ' — worse' : ''}`;++}++++function sourceList(sources) {++ const rows = (sources || []).map((s) => ({++ name: `<a href="${esc(s.url)}" rel="noreferrer">${esc(s.name)}</a>`,++ fields: (s.fields || []).join(', '),++ read: day(s.read_at) || '–',++ state: s.unavailable ? 'unavailable when read' : 'read',++ }));++ return kit.table({++ columns: [++ { key: 'name', label: 'Source', primary: true, html: true },++ { key: 'fields', label: 'Fields' },++ { key: 'read', label: 'Read' },++ { key: 'state', label: 'State' },++ ],++ rows,++ label: 'Sources',++ });++}++++function remarkForm({ models = [], comparison = null, field = null }) {++ const hidden = models.map((m) => `<input type="hidden" name="models" value="${esc(m)}">`).join('');++ const options = [++ ['', 'the whole comparison'], ['price', 'the price'], ['speed', 'the speed or latency'],++ ['quality', 'the quality score'], ['privacy', 'privacy or data policy'], ['context', 'the context length'], ['other', 'something else'],++ ].map(([v, l]) => `<option value="${v}"${field === v ? ' selected' : ''}>${esc(l)}</option>`).join('');++ return `<form class="ovk-form" method="post" action="/remarks">++${hidden}${comparison ? `<input type="hidden" name="comparison" value="${esc(comparison)}">` : ''}++<div class="ovk-sr" aria-hidden="true"><label>Leave this empty <input type="text" name="website" tabindex="-1" autocomplete="off"></label></div>++<label for="remark-text">What did you notice?</label>++<textarea id="remark-text" name="remark" rows="4" minlength="10" maxlength="2000" required placeholder="For example: on a long refactor the newer mini kept the whole file in view where the other dropped the last function."></textarea>++<label for="remark-field">Which number is it about?</label>++<select id="remark-field" name="field">${options}</select>++<label for="remark-source">A link that shows it (optional)</label>++<input id="remark-source" type="url" name="source_url" maxlength="500" placeholder="https://…">++<div class="ovk-form-actions">${kit.button({ label: 'Send it for review', type: 'submit' })}<span class="ovk-muted">A person reads it before anything is run or published.</span></div>++</form>`;++}++++function picker(models, { action = '/compare', selected = [] } = {}) {++ const rows = models.map((m) => ({++ pick: `<input type="checkbox" name="models" value="${esc(m.slug)}" aria-label="Choose ${esc(m.name)}"${selected.includes(m.slug) ? ' checked' : ''}>`,++ model: `<a href="${modelHref(m.slug)}">${esc(m.name)}</a>${m.uncensored ? ` ${badgeFor('18+', 'bad')}` : ''}${m.open_weights ? ` ${badgeFor('open weights')}` : ''}<br><span class="ovk-muted">${esc(m.family || '')}${m.providers.length ? ` · ${esc(m.providers.join(', '))}` : ''}</span>`,++ price: m.price && m.price.in != null ? `<b>${money(m.price.in)}</b> in · ${money(m.price.out)} out` : '<span class="ovk-muted">no price</span>',++ speed: m.speed && m.speed.p50_ms != null ? `${ms(m.speed.p50_ms)} <small>p50</small> · ${ms(m.speed.p95_ms)} <small>p95</small>` : '<span class="ovk-muted">not measured</span>',++ quality: m.quality && m.quality.available ? `${m.quality.score} <small>Elo · ${count(m.quality.games)} games</small>` : `<span class="ovk-muted">${esc((m.quality && m.quality.reason) || 'not measured')}</span>`,++ privacy: privacyText(m.privacy),++ offers: count(m.offers_count),++ }));++ const body = rows.length ? `<form method="get" action="${esc(action)}">++${kit.table({++ columns: [++ { key: 'pick', label: 'Compare', html: true },++ { key: 'model', label: 'Model', primary: true, html: true },++ { key: 'price', label: 'Cheapest price, per 1M', html: true },++ { key: 'speed', label: 'Speed', html: true },++ { key: 'quality', label: 'Bench quality', html: true },++ { key: 'privacy', label: 'Privacy', html: true },++ { key: 'offers', label: 'Offers', num: true },++ ],++ rows, label: 'Models',++ })}++<p class="ovk-note">Choose two to four, then compare them side by side. Nothing is estimated: a missing number says why it is missing.</p>++<div class="ovk-form-actions">${kit.button({ label: 'Compare selected', type: 'submit' })}</div>++</form>` : kit.empty({ title: 'No model matches', text: 'Try another search, or browse every model.', icon: 'search' });++ return body;++}++++function createCompareSite(ctx) {++ const { config, ai, compare } = ctx;++ const site = { ...SITE, baseUrl: config.compare.baseUrl };++ const isCompare = (req) => {++ const host = String(req.hostname || '').toLowerCase();++ return host === config.compare.host || host === `www.${config.compare.host}`;++ };++ const r = asyncRouter();++ r.use((req, _res, next) => (isCompare(req) ? next() : next('router')));++ r.use(express.urlencoded({ extended: false, limit: '32kb' }));++++ const problemPage = (res, status, title, text) => send(res, status, {++ config, site, path: '/', viewer: res.req && res.req.viewer,++ title, description: text, index: false,++ body: raw(kit.page({ head: kit.head({ title, sub: text }), body: kit.empty({ title, text, icon: 'alert', action: { href: '/', label: 'All models' } }) })),++ });++++ const page = (req, res, { title, description, path, body, jsonLd = [], index = true, status = 200, canonical = null }) => send(res, status, {++ config, site, viewer: req.viewer, path, canonical, title, description, jsonLd, index,++ body: raw(kit.page({ head: '', body })),++ });++++ // ── Home ───────────────────────────────────────────────++ r.get('/', async (req, res) => {++ try {++ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';++ const family = typeof req.query.family === 'string' ? req.query.family.slice(0, 80) : '';++ const sort = ['requests', 'cheap', 'fast', 'quality', 'name'].includes(req.query.sort) ? req.query.sort : 'requests';++ const [list, versions] = await Promise.all([++ compare.methods.models({ q, family, sort, limit: 200 }),++ compare.methods.versions(),++ ]);++ const families = list.facets.families.map((f) => `<a class="ovk-chip" href="/?family=${encodeURIComponent(f)}">${esc(f)}</a>`).join('');++ const familyNote = family ? `<p class="ovk-note">Showing ${esc(family)}. <a href="/">All families</a></p>` : '';++ const versionRows = versions.lineages.map((l) => ({++ line: `<a href="/versions/${encodeURIComponent(l.key)}">${esc(l.label)}</a>`,++ count: l.models.length,++ latest: esc(l.latest.name),++ quality: l.latest.quality && l.latest.quality.available ? String(l.latest.quality.score) : 'not measured',++ }));++ const body = `++${kit.head({ title: 'Compare AI models', sub: 'Price from every provider that serves a model, measured speed and latency, Bench quality, privacy and tier — each with its date and source.', actions: [kit.button({ href: '/method', label: 'How it measures', kind: 'quiet', icon: 'ruler' })] })}++${list.unavailable ? kit.notice({ kind: 'warn', text: 'The model catalog is not reachable right now. These pages will fill in when it is; nothing is shown as live when it is not.' }) : ''}++${kit.find({ action: '/', name: 'q', placeholder: 'Find a model, a family or a provider', value: q })}++${families ? `<div class="ovk-chips" role="group" aria-label="Families">${families}</div>` : ''}++${familyNote}++${kit.section({ title: 'Every model, with its numbers', count: list.total, note: 'The cheapest live offer’s price; the fastest measured offer’s p50 and p95; Bench Elo from public judged games. A row with no quality says why.', body: picker(list.models) })}++${versions.lineages.length ? kit.section({ title: 'Versions compared properly', note: 'The same model line, each version against the one before it: price, speed, context and Bench Elo. An Elo move whose intervals overlap is not called a change.', body: kit.table({++ columns: [++ { key: 'line', label: 'Model line', primary: true, html: true },++ { key: 'count', label: 'Versions', num: true },++ { key: 'latest', label: 'Latest' },++ { key: 'quality', label: 'Latest Bench Elo' },++ ],++ rows: versionRows, label: 'Model lines with versions',++ }) }) : ''}++${kit.section({ title: 'What a comparison is', body: `<p class="ovk-prose">A comparison is data: the things (models), the criteria (price, speed, quality, privacy, tier), the sources, the method, and the results with the date each was read. The API is at <a href="/api/v1/compare">/api/v1/compare</a>; the pages and the compare builder are next.</p>` })}++${kit.section({ title: 'Sources', body: sourceList(list.sources) })}++`;++ return page(req, res, {++ title: 'Compare AI models: price, speed, quality and privacy',++ description: 'Compare AI models by the cheapest price per provider, measured speed and latency, OpenVibe.Bench quality, privacy and tier. Every number dated and sourced; no invented scores.',++ path: '/',++ jsonLd: [seo.jsonLd.webPage({ name: 'OpenVibe.Compare', url: `${site.baseUrl}/`, description: 'Compare AI models by price, speed, Bench quality, privacy and tier.', siteUrl: site.baseUrl })],++ body,++ });++ } catch (err) {++ ctx.log.warn('[openvibe.compare] home failed:', String((err && err.message) || err).slice(0, 200));++ return problemPage(res, 503, 'The comparison is not available', 'AI or Bench could not be read. Try again in a moment.');++ }++ });++++ // ── One model ──────────────────────────────────────────++ r.get('/models/:org/:model', async (req, res) => {++ try {++ const slug = `${req.params.org}/${req.params.model}`;++ const out = await compare.methods.model(slug);++ if (!out) return problemPage(res, 404, 'No such model', `Nothing here serves ${slug}.`);++ const m = out.model;++ const offers = out.offers.map((o) => ({++ offer: `<b>${esc(o.provider_name)}</b>${o.tier ? ` ${badgeFor(o.tier, o.tier === 'community' ? 'accent' : '')}` : ''}${o.free_now ? ` ${badgeFor('free now', 'ok')}` : o.free ? ` ${badgeFor('free')}` : ''}<br><span class="ovk-muted">${esc(o.model || '')}</span>`,++ price: o.price ? `<b>${money(o.price.in)}</b> in · ${money(o.price.out)} out${o.price.cached != null ? `<br><span class="ovk-muted">cached ${money(o.price.cached)}</span>` : ''}` : '<span class="ovk-muted">no per-token price</span>',++ speed: o.speed.p50_ms == null && o.speed.p95_ms == null ? '<span class="ovk-muted">not measured</span>' : `${ms(o.speed.p50_ms)} <small>p50</small> · ${ms(o.speed.p95_ms)} <small>p95</small>`,++ uptime: o.uptime == null ? '–' : pct(o.uptime),++ privacy: `${privacyText(o.privacy)}<br><span class="ovk-muted">${policyText(o.data_policy)}</span>`,++ measured: day(o.measured_at) || '–',++ }));++ const qualityRows = (m.quality.by_benchmark || []).map((b) => ({++ board: esc(b.name),++ elo: b.elo == null ? '–' : String(b.elo),++ games: count(b.games),++ interval: b.ci_lower == null || b.ci_upper == null ? '–' : `${b.ci_lower}–${b.ci_upper}`,++ state: b.provisional ? badgeFor('provisional', 'warn') : badgeFor('judged', 'ok'),++ }));++ const versionRows = (out.versions ? out.versions.models : []).map((v, i) => ({++ version: `<a href="${modelHref(v.slug)}">${esc(v.name)}</a>${i === out.versions.models.length - 1 ? ` ${badgeFor('latest', 'accent')}` : ''}`,++ price: v.price && v.price.in != null ? `${money(v.price.in)} / ${money(v.price.out)}` : '–',++ speed: v.speed && v.speed.p50_ms != null ? ms(v.speed.p50_ms) : '–',++ quality: v.quality && v.quality.available ? String(v.quality.score) : 'not measured',++ changed: v.changes ? esc(v.changes.summary) : 'first version in the line',++ }));++ const alternatives = out.alternatives.map((a) => `<li><a href="${modelHref(a.slug)}">${esc(a.name)}</a> — ${a.price && a.price.in != null ? `${money(a.price.in)} in · ${money(a.price.out)} out` : 'no price'}${a.quality && a.quality.available ? ` · ${a.quality.score} Elo` : ''}</li>`).join('');++ const body = `++${kit.head({ title: m.name, sub: `${m.family || ''}${m.line.version_label ? ` · version ${m.line.version_label}` : ''} · ${m.kind}`, back: { href: '/', label: 'All models' }, actions: [kit.button({ href: `/compare?models=${encodeURIComponent(m.slug)}`, label: 'Compare it', kind: 'quiet', icon: 'columns' })] })}++${kit.stats([++ [money(m.price && m.price.in), 'cheapest in, per 1M'],++ [money(m.price && m.price.out), 'cheapest out, per 1M'],++ [ms(m.speed && m.speed.p50_ms), 'typical p50'],++ [qualityText(m.quality).split(' · ')[0], 'Bench Elo'],++ [count(m.offers_count), 'live offers'],++ ])}++${m.quality.available ? '' : kit.notice({ kind: 'info', text: m.quality.reason || 'No quality score yet.' })}++${kit.section({ title: 'Every offer that serves it', count: offers.length, note: 'The price book: each provider’s own price, speed, uptime, privacy and tier. “Not stated” is not a guess.', body: kit.table({++ columns: [++ { key: 'offer', label: 'Provider', primary: true, html: true },++ { key: 'price', label: 'In / out per 1M', html: true },++ { key: 'speed', label: 'Speed', html: true },++ { key: 'uptime', label: 'Uptime' },++ { key: 'privacy', label: 'Privacy and data policy', html: true },++ { key: 'measured', label: 'Price read' },++ ],++ rows: offers, label: `Offers for ${m.name}`,++ }) })}++${kit.section({ title: 'Bench quality', note: `Read from OpenVibe.Bench on ${day(m.quality.read_at) || '–'}. Elo is shown with its own interval per board; a pooled score is not a new interval.`, body: qualityRows.length ? kit.table({++ columns: [++ { key: 'board', label: 'Board', primary: true },++ { key: 'elo', label: 'Elo', num: true },++ { key: 'games', label: 'Games', num: true },++ { key: 'interval', label: 'Interval' },++ { key: 'state', label: 'State', html: true },++ ],++ rows: qualityRows, label: 'Bench quality',++ }) : kit.notice({ kind: 'info', text: m.quality.reason || 'No public Bench rounds for this model yet.' }) })}++${out.versions ? kit.section({ title: `Versions of the ${out.versions.label} line`, note: 'Each row is compared with the one before it on price, speed, context and Bench Elo.', body: kit.table({++ columns: [++ { key: 'version', label: 'Version', primary: true, html: true },++ { key: 'price', label: 'In / out' },++ { key: 'speed', label: 'p50' },++ { key: 'quality', label: 'Bench Elo' },++ { key: 'changed', label: 'What changed' },++ ],++ rows: versionRows, label: 'Versions',++ }) }) : ''}++${alternatives ? kit.section({ title: 'Close alternatives', body: `<ul class="ovk-list">${alternatives}</ul>` }) : ''}++${kit.section({ title: 'Teach the benchmarks', note: 'A remark on this comparison becomes a pending benchmark case. It is reviewed before anything runs or is published.', body: remarkForm({ models: [m.slug], comparison: m.slug }) })}++${kit.section({ title: 'Sources', body: sourceList(out.sources) })}++`;++ return page(req, res, {++ title: `${m.name} benchmarks, price and speed (${monthYear(m.measured_at)})`,++ description: `${m.name}: ${m.offers_count} live offers, from ${money(m.price && m.price.in)} in and ${money(m.price && m.price.out)} out per 1M, ${ms(m.speed && m.speed.p50_ms)} p50, ${m.quality.available ? `${m.quality.score} Bench Elo` : 'no Bench score yet'}. Measured ${day(m.measured_at) || 'date unknown'}.`,++ path: modelHref(m.slug),++ jsonLd: [seo.jsonLd.softwareApp({ name: m.name, url: `${site.baseUrl}${modelHref(m.slug)}`, description: `${m.name}: price, speed and Bench quality.` }), seo.jsonLd.breadcrumbs([{ name: 'Compare', url: `${site.baseUrl}/` }, { name: m.name, url: `${site.baseUrl}${modelHref(m.slug)}` }])],++ body,++ });++ } catch (err) {++ ctx.log.warn('[openvibe.compare] model failed:', String((err && err.message) || err).slice(0, 200));++ return problemPage(res, 503, 'The model is not available', 'AI or Bench could not be read. Try again in a moment.');++ }++ });++++ // ── Side by side ───────────────────────────────────────++ r.get('/compare', async (req, res) => {++ const refs = [...new Set([].concat(req.query.models || []).flatMap((x) => String(x || '').split(',')).map((x) => x.trim()).filter(Boolean))];++ try {++ if (refs.length < 2 || refs.length > 4) {++ const list = await compare.methods.models({ limit: 200 });++ const body = `++${kit.head({ title: 'Compare side by side', sub: 'Choose two to four models. Each criterion has its own winner; there is no invented overall score.', back: { href: '/', label: 'All models' } })}++${refs.length ? kit.notice({ kind: 'info', text: 'Choose between two and four models.' }) : ''}++${kit.section({ title: 'Choose the models', body: picker(list.models, { selected: refs }) })}++`;++ return page(req, res, { title: 'Compare two models side by side', description: 'Pick two to four AI models and compare their price, speed, Bench quality, context, privacy and tier.', path: '/compare', body, index: refs.length === 0 });++ }++ const out = await compare.methods.compare(refs);++ if (out.error) {++ const list = await compare.methods.models({ limit: 200 });++ const body = `${kit.head({ title: 'Compare side by side', sub: out.message })}++${kit.notice({ kind: 'warn', text: out.message })}++${kit.section({ title: 'Choose the models', body: picker(list.models, { selected: refs }) })}`;++ return page(req, res, { title: 'Compare AI models side by side', description: out.message, path: '/compare', body, index: false, status: out.status || 400 });++ }++ const models = out.models;++ const columns = [++ { key: 'criterion', label: 'Criterion', primary: true, html: true },++ ...models.map((m) => ({ key: m.slug, label: m.name, html: true })),++ ];++ const rows = out.criteria.map((c) => {++ const row = { criterion: `<b>${esc(c.label)}</b>${c.better ? `<br><span class="ovk-muted">${c.better === 'lower' ? 'lower is better' : 'higher is better'}</span>` : ''}` };++ for (const v of c.values) {++ const isWinner = c.winner && c.winner === v.slug;++ row[v.slug] = v.available++ ? `${v.value == null ? '–' : c.unit === 'USD' ? money(v.value) : c.unit === 'ms' ? ms(v.value) : c.unit === 'share' ? pct(v.value) : count(v.value)}${isWinner ? ` ${badgeFor('best', 'ok')}` : ''}`++ : `<span class="ovk-muted">${esc(v.reason || 'not measured')}</span>`;++ }++ return row;++ });++ const body = `++${kit.head({ title: models.map((m) => m.name).join(' vs '), sub: `${models.length} models, ${out.criteria.length} criteria, every number with its source and date`, back: { href: '/', label: 'All models' }, actions: [kit.button({ href: `/compare?models=${encodeURIComponent(models.map((m) => m.slug).join(','))}`, label: 'This comparison', kind: 'quiet', icon: 'link' })] })}++${kit.notice({ kind: 'info', text: out.verdict.headline })}++${out.price_spread ? `<p class="ovk-prose"><b>Price spread:</b> ${esc(out.price_spread.cheapest.name)} at ${money(out.price_spread.cheapest.price.out)} out is ${out.price_spread.percent == null ? '' : `${Math.abs(out.price_spread.percent).toFixed(0)}% cheaper`} than ${esc(out.price_spread.dearest.name)} at ${money(out.price_spread.dearest.price.out)}.</p>` : ''}++${kit.section({ title: 'Side by side', note: out.verdict.caveat, body: kit.table({ columns, rows, label: 'Comparison', stack: true }) })}++${out.verdict.quality_note ? kit.notice({ kind: 'info', text: out.verdict.quality_note }) : ''}++${kit.section({ title: 'What each model is', body: `<ul class="ovk-list">${models.map((m) => `<li><a href="${modelHref(m.slug)}">${esc(m.name)}</a> — ${m.offers_count} offers; ${m.price && m.price.in != null ? `${money(m.price.in)} in · ${money(m.price.out)} out` : 'no price'}; ${m.quality.available ? `${m.quality.score} Bench Elo` : 'no Bench score yet'}</li>`).join('')}</ul>` })}++${kit.section({ title: 'Teach the benchmarks', note: 'A remark on this comparison becomes a pending benchmark case for review.', body: remarkForm({ models: models.map((m) => m.slug), comparison: models.map((m) => m.slug).join('-vs-') }) })}++${kit.section({ title: 'Sources', body: sourceList(out.sources) })}++`;++ return page(req, res, {++ title: `${models.map((m) => m.name).join(' vs ')}: price, speed and Bench quality`,++ description: `Compare ${models.map((m) => m.name).join(' and ')} on price per provider, measured speed, Bench Elo, context, privacy and tier. ${out.verdict.headline}`,++ path: '/compare',++ canonical: `${site.baseUrl}/compare?models=${encodeURIComponent(models.map((m) => m.slug).join(','))}`,++ jsonLd: [seo.jsonLd.article({ headline: `${models.map((m) => m.name).join(' vs ')}: price, speed and quality`, url: `${site.baseUrl}/compare?models=${encodeURIComponent(models.map((m) => m.slug).join(','))}`, description: out.verdict.headline, datePublished: null, dateModified: day(out.generated_at) })],++ body,++ });++ } catch (err) {++ ctx.log.warn('[openvibe.compare] compare failed:', String((err && err.message) || err).slice(0, 200));++ return problemPage(res, 503, 'The comparison is not available', 'AI or Bench could not be read. Try again in a moment.');++ }++ });++++ // ── Versions ───────────────────────────────────────────++ r.get('/versions', async (req, res) => {++ try {++ const out = await compare.methods.versions();++ const rows = out.lineages.map((l) => ({++ line: `<a href="/versions/${encodeURIComponent(l.key)}">${esc(l.label)}</a>`,++ family: esc(l.family || ''),++ versions: l.models.length,++ latest: esc(l.latest.name),++ changed: l.latest.changes ? esc(l.latest.changes.summary) : '',++ read: day(l.measured_at) || '–',++ }));++ const body = `${kit.head({ title: 'Versions compared properly', sub: 'The same model line, each version against the one before it: price, speed, context and Bench Elo. An Elo move whose intervals overlap is not called a change.' })}++${kit.section({ title: 'Model lines with two or more versions', count: rows.length, body: kit.table({++ columns: [++ { key: 'line', label: 'Line', primary: true, html: true },++ { key: 'family', label: 'Family' },++ { key: 'versions', label: 'Versions', num: true },++ { key: 'latest', label: 'Latest' },++ { key: 'changed', label: 'Latest change' },++ { key: 'read', label: 'Read' },++ ],++ rows, label: 'Model lines',++ empty: 'No model line has two versions in the catalog yet. When one does, its versions appear here.',++ }) })}++${kit.section({ title: 'How versions are read', body: `<p class="ovk-prose">${esc(compare.method.versions)}</p>` })}++${kit.section({ title: 'Sources', body: sourceList(out.sources) })}`;++ return page(req, res, { title: 'AI model versions compared: what changed', description: 'The same model line’s versions in one table: price, speed, context and Bench Elo, with an interval-aware “what changed”.', path: '/versions', body });++ } catch (err) {++ ctx.log.warn('[openvibe.compare] versions failed:', String((err && err.message) || err).slice(0, 200));++ return problemPage(res, 503, 'Versions are not available', 'AI or Bench could not be read. Try again in a moment.');++ }++ });++++ r.get('/versions/:key', async (req, res) => {++ try {++ const out = await compare.methods.versions(req.params.key);++ if (!out.lineages.length) return problemPage(res, 404, 'No such model line', `No line with versions is called ${req.params.key}.`);++ const l = out.lineages[0];++ const rows = l.models.map((m, i) => ({++ version: `<a href="${modelHref(m.slug)}">${esc(m.name)}</a>${i === l.models.length - 1 ? ` ${badgeFor('latest', 'accent')}` : ''}`,++ price: m.price && m.price.in != null ? `${money(m.price.in)} / ${money(m.price.out)}` : '–',++ speed: m.speed && m.speed.p50_ms != null ? ms(m.speed.p50_ms) : '–',++ quality: m.quality && m.quality.available ? `${m.quality.score}` : 'not measured',++ context: m.context == null ? '–' : count(m.context),++ changed: m.changes ? `<b>${esc(m.changes.summary)}</b><br>${m.changes.changes.filter((c) => c.available && c.direction !== 'same').map(changeLine).join('<br>') || '<span class="ovk-muted">No measured criterion changed.</span>'}${m.changes.quality && !m.changes.quality.meaningful ? `<br><span class="ovk-muted">${esc(m.changes.quality.reason)}</span>` : ''}` : 'first version in the line',++ }));++ const body = `${kit.head({ title: `${l.label}: versions compared`, sub: `${l.models.length} versions · ${l.family || 'family'} · read ${day(l.measured_at) || '–'}`, back: { href: '/versions', label: 'Versions' } })}++${kit.section({ title: 'What changed, version by version', note: 'Price in/out per 1M, p50 speed, context and Bench Elo. A quality move inside its interval is not called a change.', body: kit.table({++ columns: [++ { key: 'version', label: 'Version', primary: true, html: true },++ { key: 'price', label: 'In / out' },++ { key: 'speed', label: 'p50' },++ { key: 'quality', label: 'Bench Elo' },++ { key: 'context', label: 'Context' },++ { key: 'changed', label: 'What changed', html: true },++ ],++ rows, label: `${l.label} versions`,++ }) })}++${l.latest.quality && l.latest.quality.available ? kit.section({ title: 'The latest version’s boards', body: kit.table({++ columns: [++ { key: 'board', label: 'Board', primary: true },++ { key: 'elo', label: 'Elo', num: true },++ { key: 'games', label: 'Games', num: true },++ { key: 'interval', label: 'Interval' },++ ],++ rows: l.latest.quality.by_benchmark.map((b) => ({ board: esc(b.name), elo: b.elo == null ? '–' : String(b.elo), games: count(b.games), interval: b.ci_lower == null ? '–' : `${b.ci_lower}–${b.ci_upper}` })),++ label: 'Boards',++ }) }) : ''}++${kit.section({ title: 'How versions are read', body: `<p class="ovk-prose">${esc(compare.method.versions)}</p>` })}++${kit.section({ title: 'Sources', body: sourceList(out.sources) })}`;++ return page(req, res, { title: `${l.label} versions: what changed`, description: `${l.models.length} versions of ${l.label} compared on price, speed, context and Bench Elo, each against the one before it.`, path: `/versions/${encodeURIComponent(l.key)}`, body });++ } catch (err) {++ ctx.log.warn('[openvibe.compare] version line failed:', String((err && err.message) || err).slice(0, 200));++ return problemPage(res, 503, 'The line is not available', 'AI or Bench could not be read. Try again in a moment.');++ }++ });++++ // ── Method ─────────────────────────────────────────────++ r.get('/method', async (_req, res) => {++ const out = await compare.methods.models({ limit: 1 }).catch(() => null);++ const body = `${kit.head({ title: 'How OpenVibe.Compare measures', sub: 'A comparison is data: things, criteria, sources, a method, results with dates. Nothing here is estimated.' })}++${kit.section({ title: 'Price', body: `<p class="ovk-prose">${esc(compare.method.price)}</p>` })}++${kit.section({ title: 'Speed and latency', body: `<p class="ovk-prose">${esc(compare.method.speed)}</p>` })}++${kit.section({ title: 'Quality', body: `<p class="ovk-prose">${esc(compare.method.quality)}</p>` })}++${kit.section({ title: 'Privacy and tier', body: `<p class="ovk-prose">${esc(compare.method.privacy)}</p><p class="ovk-prose">${esc(compare.method.tier)}</p>` })}++${kit.section({ title: 'Versions', body: `<p class="ovk-prose">${esc(compare.method.versions)}</p>` })}++${kit.section({ title: 'When a source cannot be read', body: `<p class="ovk-prose">${esc(compare.method.unavailable)}</p>` })}++${kit.section({ title: 'A reader’s remark', body: `<p class="ovk-prose">Any remark on a comparison can become a pending benchmark case. It is stored with the models it names, the criterion it questions and an optional link. The owner reads the queue; only an accepted case is turned into a Bench task. A remark is never a result and never runs by itself.</p>` })}++${out ? kit.section({ title: 'Sources', body: sourceList(out.sources) }) : ''}`;++ return page(_req, res, { title: 'How OpenVibe.Compare measures price, speed, quality and privacy', description: 'The method behind every comparison: dated price from AI’s catalog, measured speed, Bench Elo with intervals, privacy and tier, and version deltas that respect uncertainty.', path: '/method', body });++ });++++ // ── The remark ─────────────────────────────────────────++ r.post('/remarks', rateLimit({ windowMs: 60_000, limit: 10, standardHeaders: true, legacyHeaders: false }), async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ if (b.website) return res.redirect(303, '/remarks/thanks');++ const clean = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);++ const refs = [...new Set([].concat(b.models || []).flatMap((x) => String(x || '').split(',')).map((x) => clean(x, 160)).filter(Boolean))].slice(0, 4);++ const remark = clean(b.remark, 2000);++ const field = ['price', 'speed', 'quality', 'privacy', 'context', 'other'].includes(String(b.field)) ? String(b.field) : null;++ if (!refs.length || remark.length < 10) {++ return send(res, 400, {++ config, site, viewer: req.viewer, path: '/remarks', index: false,++ title: 'That remark needs a model and a little more detail',++ description: 'A remark needs at least one model and ten characters.',++ body: raw(kit.page({ head: kit.head({ title: 'That remark needs a model and a little more detail', sub: 'Go back and add at least one model and ten characters.' }), body: kit.empty({ title: 'Not sent', text: 'A remark needs at least one model and ten characters.', icon: 'alert', action: { href: '/', label: 'All models' } }) })),++ });++ }++ try {++ const catalog = await ai.catalog();++ const bySlug = new Map((catalog.models || []).map((m) => [String(m.slug).toLowerCase(), m]));++ const known = refs.filter((x) => bySlug.has(x.toLowerCase()));++ if (!known.length) throw Object.assign(new Error('No such model'), { status: 400 });++ let sourceUrl = null;++ if (b.source_url) {++ const u = new URL(String(b.source_url));++ if (['http:', 'https:'].includes(u.protocol)) sourceUrl = u.toString().slice(0, 500);++ }++ await compare.methods.remarks.create({++ models: known.map((x) => bySlug.get(x.toLowerCase()).slug),++ comparison: clean(b.comparison, 200) || null,++ field, remark, proposedPrompt: clean(`Test ${known.map((x) => bySlug.get(x.toLowerCase()).name).join(', ')}: ${remark}`, 4000),++ sourceUrl,++ subject: req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null,++ });++ return res.redirect(303, '/remarks/thanks');++ } catch (err) {++ ctx.log.warn('[openvibe.compare] remark failed:', String((err && err.message) || err).slice(0, 200));++ return send(res, 503, {++ config, site, viewer: req.viewer, path: '/remarks', index: false,++ title: 'The remark could not be stored',++ description: 'Try again in a moment.',++ body: raw(kit.page({ head: kit.head({ title: 'The remark could not be stored', sub: 'Try again in a moment.' }), body: kit.empty({ title: 'Not sent', text: 'The catalog or the store could not be read. Nothing was saved.', icon: 'alert', action: { href: '/', label: 'All models' } }) })),++ });++ }++ });++++ r.get('/remarks/thanks', (req, res) => send(res, 200, {++ config, site, viewer: req.viewer, path: '/remarks/thanks', index: false,++ title: 'Thank you — a person will read it',++ description: 'Your remark became a pending benchmark case.',++ body: raw(kit.page({ head: kit.head({ title: 'Thank you — a person will read it', sub: 'Your remark is a pending benchmark case now, not a result.' }), body: `${kit.notice({ kind: 'ok', text: 'Nothing runs and nothing is published until the owner accepts the case.' })}<p class="ovk-prose"><a href="/">Back to the models</a></p>` })),++ }));++++ // ── Robots, sitemap and llms.txt ───────────────────────++ r.get('/robots.txt', (_req, res) => {++ res.type('text/plain').send(seo.robotsTxt({ sitemaps: [`${site.baseUrl}/sitemap.xml`], disallow: ['/api/', '/auth/', '/remarks/'] }));++ });++++ r.get('/sitemap.xml', async (_req, res) => {++ try {++ const [list, versions] = await Promise.all([compare.methods.models({ limit: 250 }), compare.methods.versions()]);++ const urls = [++ { loc: `${site.baseUrl}/`, lastmod: day(list.catalog_generated_at), changefreq: 'daily', priority: 1 },++ { loc: `${site.baseUrl}/versions`, lastmod: day(versions.generated_at), changefreq: 'daily', priority: 0.8 },++ { loc: `${site.baseUrl}/method`, changefreq: 'monthly', priority: 0.4 },++ ...list.models.map((m) => ({ loc: `${site.baseUrl}${modelHref(m.slug)}`, lastmod: day(m.measured_at), changefreq: 'daily', priority: 0.7 })),++ ...versions.lineages.map((l) => ({ loc: `${site.baseUrl}/versions/${encodeURIComponent(l.key)}`, lastmod: day(l.measured_at), changefreq: 'weekly', priority: 0.6 })),++ ];++ res.type('application/xml').send(seo.sitemapXml(urls));++ } catch (err) {++ ctx.log.warn('[openvibe.compare] sitemap failed:', String((err && err.message) || err).slice(0, 200));++ res.status(503).type('text/plain').send('The sitemap is not available right now.');++ }++ });++++ r.get('/llms.txt', async (_req, res) => {++ const list = await compare.methods.models({ limit: 50 }).catch(() => null);++ res.type('text/plain').send(seo.llmsTxt({++ name: 'OpenVibe.Compare',++ summary: 'Compare anything, starting with AI models: price per provider, measured speed and latency, OpenVibe.Bench quality, privacy and tier. Every number is dated and sourced; no figure is estimated.',++ base: site.baseUrl,++ sections: [++ {++ title: 'Compare', links: [++ { title: 'Every model', url: `${site.baseUrl}/`, note: 'the price book, speed and Bench quality' },++ { title: 'Side by side', url: `${site.baseUrl}/compare`, note: 'two to four models, one winner per criterion' },++ { title: 'Versions', url: `${site.baseUrl}/versions`, note: 'the same model line, what changed' },++ { title: 'How it measures', url: `${site.baseUrl}/method`, note: 'sources, dates and the interval rule' },++ { title: 'JSON API', url: `${site.baseUrl}/api/v1/compare`, note: 'the same data as JSON' },++ ],++ },++ ...(list ? [{++ title: 'A few models', links: list.models.slice(0, 20).map((m) => ({ title: m.name, url: `${site.baseUrl}${modelHref(m.slug)}`, note: `${m.price && m.price.in != null ? `$${m.price.in}/$${m.price.out} per 1M` : 'no price'}${m.quality && m.quality.available ? `, ${m.quality.score} Elo` : ''}` })),++ }] : []),++ ],++ }));++ });++++ r.use((_req, res) => problemPage(res, 404, 'Not found', 'That page is not part of openvibe.compare.'));++ return r;++}++++module.exports = { createCompareSite, SITE };@@@ -52,16 +52,25 @@ function renderPage(o) {const signedIn = viewer.kind === 'user';const pathNow = o.path || '/';const loginNext = encodeURIComponent(pathNow);-- const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ // A page may belong to a site served by this process behind another name (openvibe.compare): its own name,++ // home, nav and canonical base, while the sign-in, the assets and the theme stay this service's.++ const site = o.site || {};++ const siteName = site.name || SITE_NAME;++ const siteTagline = site.tagline || TAGLINE;++ const siteHome = site.home || '/';++ const siteNav = Array.isArray(site.nav) ? site.nav : NAV;++ const siteBase = String(site.baseUrl || o.config.baseUrl).replace(/\/+$/, '');++ const siteService = site.service || 'exchange';++ const canonical = o.canonical || `${siteBase}${pathNow.split('?')[0]}`;const nav = {-- service: 'exchange',++ service: siteService,apiBase: NETWORK_URL,links: [-- ...NAV.map((l) => ({ label: l.label, href: l.href, icon: l.icon, ...(l.more ? { more: true } : {}), active: active(pathNow, l.href) })),++ ...siteNav.map((l) => ({ label: l.label, href: l.href, icon: l.icon, ...(l.more ? { more: true } : {}), active: active(pathNow, l.href) })),...(signedIn ? [{ label: 'Dashboard', href: '/dashboard', icon: 'fa-key', active: active(pathNow, '/dashboard') }] : []),...(viewer.role === 'owner' ? [{ label: 'AI console', href: '/console', icon: 'fa-sliders', active: active(pathNow, '/console') }] : []),],-- history: { type: 'page', title: o.title || SITE_NAME },++ history: { type: 'page', title: o.title || siteName },silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',tokenUrl: '/auth/navbar-token',@@@ -69,28 +78,28 @@ function renderPage(o) {logoutUrl: '/auth/logout?next={path}',notificationsRealtime: true,};-- const footer = { service: 'exchange', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };++ const footer = { service: siteService, variant: 'full', mount: '#ov-footer', brandName: siteName };const account = signedIn? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`: `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;return shell.page({-- name: SITE_NAME,++ name: siteName,lang: 'en',-- title: o.title || `${SITE_NAME}: ${TAGLINE}`,-- titleSuffix: o.title ? ` · ${SITE_NAME}` : '',-- siteName: SITE_NAME,-- description: o.description || `${SITE_NAME} — ${TAGLINE} Every model's price, speed and uptime, live; one OpenAI-compatible endpoint; chat with one model or three side by side.`,++ title: o.title || `${siteName}: ${siteTagline}`,++ titleSuffix: o.title ? ` · ${siteName}` : '',++ siteName,++ description: o.description || `${siteName} — ${siteTagline} Every model's price, speed and uptime, live; one OpenAI-compatible endpoint; chat with one model or three side by side.`,canonical,agentLayer: Boolean(o.agentLayer),robots: o.index === false ? 'noindex, nofollow' : 'index, follow',jsonLd: (o.jsonLd || []).filter(Boolean),-- home: '/',-- navLinks: NAV,++ home: siteHome,++ navLinks: siteNav,bodyClass: o.bodyClass || (o.app ? 'ex-app' : undefined),navbar: nav,footer,head: `<meta name="referrer" content="strict-origin-when-cross-origin">--${appIcon.headTags({ site: 'exchange' })}++${appIcon.headTags({ site: site.icon || 'exchange' })}<link rel="stylesheet" href="${ovServe.url('kit.css')}"><link rel="stylesheet" href="${ovServe.url('kit-data.css')}"><link rel="stylesheet" href="${ovServe.url('room.css')}">@@@ -319,6 +319,7 @@ ${families.length ? `<div class="ex-chips" role="group" aria-label="Family">${fa${unavailable(catalog)}${panel({ title: '', body: modelList(models, { empty: 'No model matches that search. Try a family or a provider.' }) })}${communityPools(community)}++<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p><p class="ex-more"><a href="/status">Uptime and incidents →</a></p></div>`;}@@@ -382,6 +383,7 @@ ${kit.head({ title: model.name, sub: `${familyLabel(model)}${model.variant ? `${stats.length ? kit.stats(stats) : ''}${model.description ? `<p class="ex-prose">${esc(model.description)}</p>` : ''}${panel({ title: 'Every offer that serves it', note: 'Price in, out and cached per million tokens; speed and uptime from the last seven days of real requests; the privacy label and the provider’s own data policy.', id: 'offers', body: kit.table({ columns, rows, label: `Offers for ${model.name}`, empty: 'No live offer serves this model right now.' }) })}++<p class="ex-more"><a href="https://openvibe.compare/models/${String(model.slug).split('/').map(encodeURIComponent).join('/')}">Compare ${esc(model.name)} with another model →</a></p>${activityPanel(model.activity)}${require('./analysis').modelFacts(analysis)}${panel({ title: 'Presets', note: 'A preset fixes the router’s floor, ceiling and policy, and you name it as the model.', id: 'presets', body: presetsBox() })}nnew file mode 100644@@@ -0,0 +1,183 @@++'use strict';++/**++ * openvibe.compare's pages (plan 297). One process serves Exchanges's own site and the compare site; the Host++ * header decides which. These tests ask for the compare pages with the compare name, check the plain-kit markup,++ * the version table, the remark form, the sitemap — and that the same paths on openvibe.exchange never become the++ * compare site.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { createFakeAi } = require('./helpers/ai');++const { createFakeBench } = require('./helpers/bench');++++const HOST = { 'x-forwarded-host': 'openvibe.compare' };++++(async () => {++ const ai = createFakeAi();++ ai.catalogData.models.push(++ {++ slug: 'openai/gpt-5-mini', canonical: 'openai/gpt-5-mini', name: 'gpt-5-mini', family: 'GPT', variant: null,++ uncensored: false, kind: 'chat', context: 400000, max_output: 128000, open_weights: false,++ capabilities: { tools: true, json: true, vision: true, stream: true }, modalities: ['text'], description: null,++ requests_7d: 50, requests_today: 4, community: false,++ offers: [{++ provider: 'openai', provider_name: 'OpenAI', model: 'gpt-5-mini', id: 'openai/gpt-5-mini@v1', trust: 'first-party',++ access: 'OWN_DEPLOYMENT', free: false, free_tier: false, status: 'up',++ price: { in: 0.25, out: 2, cached: 0.025, per: '1M tokens', currency: 'USD' },++ speed: { p50_ms: 900, p95_ms: 3000 }, uptime: 0.99, requests_7d: 50, requests_today: 4,++ privacy: { label: 'anonymized', retention: '0', training: false, region: 'us', known: true },++ data_policy: { trains: false, retention_days: 0, region: 'us' },++ updated_at: '2026-11-06T00:00:00.000Z',++ }],++ },++ {++ slug: 'openai/gpt-5.1-mini', canonical: 'openai/gpt-5.1-mini', name: 'gpt-5.1-mini', family: 'GPT', variant: null,++ uncensored: false, kind: 'chat', context: 400000, max_output: 128000, open_weights: false,++ capabilities: { tools: true, json: true, vision: true, stream: true }, modalities: ['text'], description: null,++ requests_7d: 80, requests_today: 6, community: false,++ offers: [{++ provider: 'openai', provider_name: 'OpenAI', model: 'gpt-5.1-mini', id: 'openai/gpt-5.1-mini@v1', trust: 'first-party',++ access: 'OWN_DEPLOYMENT', free: false, free_tier: false, status: 'up',++ price: { in: 0.2, out: 1.5, cached: 0.02, per: '1M tokens', currency: 'USD' },++ speed: { p50_ms: 700, p95_ms: 2400 }, uptime: 0.995, requests_7d: 80, requests_today: 6,++ privacy: { label: 'anonymized', retention: '0', training: false, region: 'us', known: true },++ data_policy: { trains: false, retention_days: 0, region: 'us' },++ updated_at: '2026-11-06T00:00:00.000Z',++ }],++ },++ );++ ai.catalogData.count = ai.catalogData.models.length;++++ const bench = createFakeBench({++ boards: {++ 'chat.arena': [++ { subject: 'deepseek/deepseek-chat', elo: 1040, games: 20, wins: 12, losses: 6, ties: 2, win_rate: 0.65, ci_lower: 1020, ci_upper: 1060, provisional: false },++ { subject: 'openai/gpt-5-mini', elo: 1100, games: 30, wins: 20, losses: 8, ties: 2, win_rate: 0.7, ci_lower: 1080, ci_upper: 1120, provisional: false },++ { subject: 'openai/gpt-5.1-mini', elo: 1120, games: 30, wins: 22, losses: 6, ties: 2, win_rate: 0.77, ci_lower: 1125, ci_upper: 1145, provisional: false },++ ],++ },++ });++++ const t = await boot({ ai, bench });++++ try {++ await check('the compare home is plain kit markup, complete without JavaScript', async () => {++ const r = await t.get('/', { headers: HOST });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('OpenVibe.Compare'), 'the compare name');++ assert.ok(r.text.includes('Compare AI models'));++ assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('gpt-5.1-mini'));++ assert.ok(r.text.includes('Bench quality'));++ assert.ok(r.text.includes('name="models"'), 'checkboxes to compare');++ assert.ok(r.text.includes('/api/v1/compare'), 'the API is named');++ assert.ok(!r.text.includes('OpenVibe.Exchange: Every AI model'), 'not the Exchange home');++ });++++ await check('one model is its price book, its Bench boards and its versions', async () => {++ const r = await t.get('/models/openai/gpt-5.1-mini', { headers: HOST });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('gpt-5.1-mini'));++ assert.ok(r.text.includes('Every offer that serves it'));++ assert.ok(r.text.includes('OpenAI'));++ assert.ok(r.text.includes('$0.20') && r.text.includes('$1.50'), 'the price book');++ assert.ok(r.text.includes('Bench quality'));++ assert.ok(r.text.includes('1120'), 'the Bench score');++ assert.ok(r.text.includes('Versions of the gpt-mini line'));++ assert.ok(r.text.includes('gpt-5-mini'), 'the older version');++ assert.ok(r.text.includes('Teach the benchmarks'), 'the remark form');++ assert.ok(r.text.includes('send it for review') || r.text.includes('Send it for review'));++ const missing = await t.get('/models/nope/nothing', { headers: HOST });++ assert.strictEqual(missing.status, 404);++ assert.ok(missing.text.includes('No such model'));++ });++++ await check('two models side by side name a winner per criterion and no invented overall score', async () => {++ const r = await t.get('/compare?models=openai/gpt-5.1-mini,deepseek/deepseek-chat', { headers: HOST });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('gpt-5.1-mini vs DeepSeek Chat'));++ assert.ok(r.text.includes('Price out, per 1M'));++ assert.ok(r.text.includes('Bench Elo'));++ assert.ok(r.text.includes('best'));++ assert.ok(r.text.includes('never one invented overall score') || r.text.includes('no invented overall score'));++ assert.ok(r.text.includes('Price spread'));++ assert.ok(r.text.includes('Teach the benchmarks'));++ const one = await t.get('/compare?models=deepseek/deepseek-chat', { headers: HOST });++ assert.strictEqual(one.status, 200);++ assert.ok(one.text.includes('Choose the models'), 'one model goes back to the picker');++ const five = await t.get('/compare?models=a,b,c,d,e', { headers: HOST });++ assert.strictEqual(five.status, 200);++ assert.ok(five.text.includes('between two and four') || five.text.includes('Choose the models'));++ });++++ await check('the versions pages order a line and say what changed, with the interval rule', async () => {++ const list = await t.get('/versions', { headers: HOST });++ assert.strictEqual(list.status, 200, list.text);++ assert.ok(list.text.includes('gpt-mini'));++ const line = await t.get('/versions/gpt-mini', { headers: HOST });++ assert.strictEqual(line.status, 200, line.text);++ assert.ok(line.text.includes('gpt-5-mini') && line.text.includes('gpt-5.1-mini'));++ assert.ok(line.text.includes('What changed'));++ assert.ok(line.text.includes('better'));++ assert.ok(line.text.includes('An Elo move whose intervals overlap is not called a change'));++ const missing = await t.get('/versions/nope', { headers: HOST });++ assert.strictEqual(missing.status, 404);++ });++++ await check('method, robots, sitemap and llms.txt are there and say where they came from', async () => {++ const method = await t.get('/method', { headers: HOST });++ assert.strictEqual(method.status, 200);++ assert.ok(method.text.includes('OpenVibe.AI catalog') && method.text.includes('OpenVibe.Bench'));++ assert.ok(method.text.includes('reader’s remark') || method.text.includes('reader'));++ const robots = await t.get('/robots.txt', { headers: HOST });++ assert.strictEqual(robots.status, 200);++ assert.ok(robots.text.includes('Sitemap: https://openvibe.compare/sitemap.xml'));++ assert.ok(robots.text.includes('Disallow: /api/'));++ const sitemap = await t.get('/sitemap.xml', { headers: HOST });++ assert.strictEqual(sitemap.status, 200);++ assert.ok(sitemap.text.includes('<loc>https://openvibe.compare/</loc>'));++ assert.ok(sitemap.text.includes('https://openvibe.compare/models/openai/gpt-5.1-mini'));++ assert.ok(sitemap.text.includes('https://openvibe.compare/versions/gpt-mini'));++ const llms = await t.get('/llms.txt', { headers: HOST });++ assert.strictEqual(llms.status, 200);++ assert.ok(llms.text.includes('# OpenVibe.Compare'));++ });++++ await check('a remark posted from the page is pending, and the thanks page says nothing ran', async () => {++ const r = await t.get('/remarks', {++ headers: HOST,++ form: {++ models: 'openai/gpt-5.1-mini',++ comparison: 'openai/gpt-5.1-mini-vs-deepseek/deepseek-chat',++ field: 'quality',++ remark: 'The newer mini kept the whole file in view where the other dropped the last function.',++ },++ });++ assert.strictEqual(r.status, 303, r.text);++ assert.strictEqual(r.headers.get('location'), '/remarks/thanks');++ const row = await t.ctx.s.db.maybe('SELECT * FROM exchange_compare_remarks');++ assert.ok(row, 'stored');++ assert.strictEqual(row.status, 'pending');++ const thanks = await t.get('/remarks/thanks', { headers: HOST });++ assert.strictEqual(thanks.status, 200);++ assert.ok(thanks.text.includes('Nothing runs and nothing is published'));++ });++++ await check('the same paths on openvibe.exchange are Exchange, never the compare site', async () => {++ const home = await t.get('/');++ assert.strictEqual(home.status, 200);++ assert.ok(home.text.includes('OpenVibe.Exchange'));++ assert.ok(!home.text.includes('OpenVibe.Compare: Compare anything'));++ const model = await t.get('/models/openai/gpt-5.1-mini');++ assert.strictEqual(model.status, 200, 'Exchange has its own model page at the same path');++ assert.ok(model.text.includes('OpenVibe.Exchange'), 'the Exchange name, not the compare site');++ assert.ok(!model.text.includes('Teach the benchmarks'), 'Exchange does not get the compare page');++ const api = await t.get('/api/v1/compare/ai/models');++ assert.strictEqual(api.status, 200, 'the API is reachable on both names');++ const unknown = await t.get('/nope', { headers: HOST });++ assert.strictEqual(unknown.status, 404);++ });++ } finally {++ await t.close();++ }++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.compare — reference vhost for ov-vhost-install.
+##
+## The comparison site is served by OpenVibe.Exchange's process on 127.0.0.1:5150 (the same app, behind its own
+## name: server/http/compare-site.js). This vhost passes the name through in Host, which is what makes the request
+## the compare site's; every other name reaches Exchange's own pages. No proxy_cache: the app decides cacheability.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=compare_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=compare_api:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=compare_forms:10m rate=1r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.compare www.openvibe.compare;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.compare$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.compare;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.compare/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.compare/privkey.pem;
+
+ return 301 https://openvibe.compare$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.compare;
+ include snippets/security-txt.conf;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.compare/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.compare/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.compare.access.log;
+ error_log /var/log/nginx/openvibe.compare.error.log;
+
+ # The remark API's JSON body cap is 32 KB; leave room for headers.
+ client_max_body_size 64k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ location = /auth/me {
+ limit_req zone=compare_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5150;
+ }
+
+ location /auth/ {
+ limit_req zone=compare_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5150;
+ }
+
+ location /api/ {
+ limit_req zone=compare_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5150;
+ }
+
+ # A reader's remark is the only form on the site.
+ location = /remarks {
+ limit_req zone=compare_forms burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5150;
+ }
+
+ # Host-local scrapers only; OpenVibe.Events' account delivery is loopback-only.
+ location ~* ^/metrics(/|$) { return 404; }
+ location ~* ^/internal(/|$) { return 404; }
+
+ location / {
+ proxy_pass http://127.0.0.1:5150;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
@@ -48,6 +48,7 @@ const { createPageRoutes } = require('./http/pages');
@@ -48,6 +48,7 @@ const { createPageRoutes } = require('./http/pages');
const { createConsoleRoutes } = require('./http/console');
const { createConsoleRoutes } = require('./http/console');
const { createArenaRoutes } = require('./http/arena');
const { createArenaRoutes } = require('./http/arena');
const { createCompareApi } = require('./http/compare');
const { createCompareApi } = require('./http/compare');
+const { createCompareSite } = require('./http/compare-site');
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');
@@ -263,6 +264,11 @@ async function createApp(opts = {}) {
@@ -263,6 +264,11 @@ async function createApp(opts = {}) {
// Mounted before Exchange v1's catch-all, which answers 410 to everything else under /api/v1.
// Mounted before Exchange v1's catch-all, which answers 410 to everything else under /api/v1.
app.use('/api/v1/compare', createCompareApi(ctx));
app.use('/api/v1/compare', createCompareApi(ctx));
+ // ── openvibe.compare, behind its own name ───────────────
+ // The vhost for openvibe.compare proxies here with Host: openvibe.compare; every other Host passes through to
+ // Exchange's own pages, so one process serves both names without either site's routes leaking into the other.
+ app.use(createCompareSite(ctx));
+
// ── Exchange v1's JSON API: gone ────────────────────────
// ── Exchange v1's JSON API: gone ────────────────────────
app.use('/api/v1', (req, res) => {
app.use('/api/v1', (req, res) => {
res.set('Cache-Control', 'public, max-age=86400');
res.set('Cache-Control', 'public, max-age=86400');
@@ -27,7 +27,7 @@ const QUALITY_KINDS = Object.freeze([
@@ -27,7 +27,7 @@ const QUALITY_KINDS = Object.freeze([
const NOT_CHAT = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);
const NOT_CHAT = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);
const KIND = 'ai.models';
const KIND = 'ai.models';
-const finite = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);
+const finite = (v) => (v === null || v === undefined || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
const iso = (v, fallback = null) => {
const iso = (v, fallback = null) => {
if (!v) return fallback;
if (!v) return fallback;
const d = new Date(v);
const d = new Date(v);
new file mode 100644
new file mode 100644
@@ -0,0 +1,558 @@
@@ -0,0 +1,558 @@
+'use strict';
+
+/**
+ * openvibe.compare — the comparison site itself (plan 297), served by Exchange behind its own name.
+ *
+ * The pages are plain kit markup and complete without JavaScript. They are a read of the API in
+ * server/http/compare.js; the only write is a reader's remark, which becomes a pending benchmark case for review
+ * and is never run or published before a person accepts it.
+ */
+const express = require('express');
+const rateLimit = require('express-rate-limit');
+const kit = require('openvibe-shared/kit');
+const seo = require('openvibe-shared/seo');
+const { asyncRouter } = require('./router');
+const { send } = require('../render/layout');
+const { esc, raw } = require('../render/html');
+
+const SITE = Object.freeze({
+ name: 'OpenVibe.Compare',
+ tagline: 'Compare anything, starting with AI models.',
+ service: 'exchange',
+ home: '/',
+ icon: 'exchange',
+ nav: [
+ { label: 'Compare', href: '/', icon: 'fa-scale-balanced' },
+ { label: 'Side by side', href: '/compare', icon: 'fa-table-columns' },
+ { label: 'Versions', href: '/versions', icon: 'fa-code-branch' },
+ { label: 'How it measures', href: '/method', icon: 'fa-ruler' },
+ { label: 'Exchange', href: 'https://openvibe.exchange', icon: 'fa-arrow-up-right-from-square' },
+ ],
+});
+
+const money = (n) => (n == null ? '–' : Number(n) === 0 ? '$0' : `$${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 day = (v) => {
+ if (!v) return null;
+ const d = new Date(v);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
+};
+const monthYear = (v) => {
+ const d = v ? new Date(v) : new Date();
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
+};
+const modelHref = (slug) => `/models/${String(slug).split('/').map(encodeURIComponent).join('/')}`;
+const modelOf = (href) => String(href || '').split('/models/')[1] || '';
+
+function qualityText(q) {
+ if (!q || !q.available) return 'not measured';
+ return `${q.score}${q.games ? ` · ${count(q.games)} games` : ''}`;
+}
+
+function privacyText(p) {
+ if (!p || !p.label) return '<span class="ovk-muted">not stated</span>';
+ return esc(p.label);
+}
+
+function policyText(p) {
+ if (!p) return '<span class="ovk-muted">not stated</span>';
+ const bits = [];
+ bits.push(p.trains === false ? 'no training' : p.trains === true ? 'may train' : 'training not stated');
+ if (p.retention_days != null) bits.push(p.retention_days === 0 ? 'no retention' : `kept ${p.retention_days} d`);
+ return esc(bits.join(' · '));
+}
+
+function badgeFor(text, tone = '') {
+ return kit.badge(text, tone);
+}
+
+function changeLine(c) {
+ if (!c || !c.available) return '<span class="ovk-muted">not measured</span>';
+ const arrow = c.delta === 0 ? 'same' : `${c.delta > 0 ? '+' : ''}${c.delta}${c.unit === 'ms' ? ' ms' : c.unit === 'USD' ? ` $${Math.abs(c.delta).toFixed(4)}` : c.unit === 'Elo' ? ' Elo' : ''}`;
+ return `${esc(c.label)}: <b>${esc(arrow)}</b>${c.pct == null ? '' : ` (${c.pct > 0 ? '+' : ''}${c.pct}%)`}${c.direction === 'better' ? ' — better' : c.direction === 'worse' ? ' — worse' : ''}`;
+}
+
+function sourceList(sources) {
+ const rows = (sources || []).map((s) => ({
+ name: `<a href="${esc(s.url)}" rel="noreferrer">${esc(s.name)}</a>`,
+ fields: (s.fields || []).join(', '),
+ read: day(s.read_at) || '–',
+ state: s.unavailable ? 'unavailable when read' : 'read',
+ }));
+ return kit.table({
+ columns: [
+ { key: 'name', label: 'Source', primary: true, html: true },
+ { key: 'fields', label: 'Fields' },
+ { key: 'read', label: 'Read' },
+ { key: 'state', label: 'State' },
+ ],
+ rows,
+ label: 'Sources',
+ });
+}
+
+function remarkForm({ models = [], comparison = null, field = null }) {
+ const hidden = models.map((m) => `<input type="hidden" name="models" value="${esc(m)}">`).join('');
+ const options = [
+ ['', 'the whole comparison'], ['price', 'the price'], ['speed', 'the speed or latency'],
+ ['quality', 'the quality score'], ['privacy', 'privacy or data policy'], ['context', 'the context length'], ['other', 'something else'],
+ ].map(([v, l]) => `<option value="${v}"${field === v ? ' selected' : ''}>${esc(l)}</option>`).join('');
+ return `<form class="ovk-form" method="post" action="/remarks">
+${hidden}${comparison ? `<input type="hidden" name="comparison" value="${esc(comparison)}">` : ''}
+<div class="ovk-sr" aria-hidden="true"><label>Leave this empty <input type="text" name="website" tabindex="-1" autocomplete="off"></label></div>
+<label for="remark-text">What did you notice?</label>
+<textarea id="remark-text" name="remark" rows="4" minlength="10" maxlength="2000" required placeholder="For example: on a long refactor the newer mini kept the whole file in view where the other dropped the last function."></textarea>
+<label for="remark-field">Which number is it about?</label>
+<select id="remark-field" name="field">${options}</select>
+<label for="remark-source">A link that shows it (optional)</label>
+<input id="remark-source" type="url" name="source_url" maxlength="500" placeholder="https://…">
+<div class="ovk-form-actions">${kit.button({ label: 'Send it for review', type: 'submit' })}<span class="ovk-muted">A person reads it before anything is run or published.</span></div>
+</form>`;
+}
+
+function picker(models, { action = '/compare', selected = [] } = {}) {
+ const rows = models.map((m) => ({
+ pick: `<input type="checkbox" name="models" value="${esc(m.slug)}" aria-label="Choose ${esc(m.name)}"${selected.includes(m.slug) ? ' checked' : ''}>`,
+ model: `<a href="${modelHref(m.slug)}">${esc(m.name)}</a>${m.uncensored ? ` ${badgeFor('18+', 'bad')}` : ''}${m.open_weights ? ` ${badgeFor('open weights')}` : ''}<br><span class="ovk-muted">${esc(m.family || '')}${m.providers.length ? ` · ${esc(m.providers.join(', '))}` : ''}</span>`,
+ price: m.price && m.price.in != null ? `<b>${money(m.price.in)}</b> in · ${money(m.price.out)} out` : '<span class="ovk-muted">no price</span>',
+ speed: m.speed && m.speed.p50_ms != null ? `${ms(m.speed.p50_ms)} <small>p50</small> · ${ms(m.speed.p95_ms)} <small>p95</small>` : '<span class="ovk-muted">not measured</span>',
+ quality: m.quality && m.quality.available ? `${m.quality.score} <small>Elo · ${count(m.quality.games)} games</small>` : `<span class="ovk-muted">${esc((m.quality && m.quality.reason) || 'not measured')}</span>`,
+ privacy: privacyText(m.privacy),
+ offers: count(m.offers_count),
+ }));
+ const body = rows.length ? `<form method="get" action="${esc(action)}">
+${kit.table({
+ columns: [
+ { key: 'pick', label: 'Compare', html: true },
+ { key: 'model', label: 'Model', primary: true, html: true },
+ { key: 'price', label: 'Cheapest price, per 1M', html: true },
+ { key: 'speed', label: 'Speed', html: true },
+ { key: 'quality', label: 'Bench quality', html: true },
+ { key: 'privacy', label: 'Privacy', html: true },
+ { key: 'offers', label: 'Offers', num: true },
+ ],
+ rows, label: 'Models',
+ })}
+<p class="ovk-note">Choose two to four, then compare them side by side. Nothing is estimated: a missing number says why it is missing.</p>
+<div class="ovk-form-actions">${kit.button({ label: 'Compare selected', type: 'submit' })}</div>
+</form>` : kit.empty({ title: 'No model matches', text: 'Try another search, or browse every model.', icon: 'search' });
+ return body;
+}
+
+function createCompareSite(ctx) {
+ const { config, ai, compare } = ctx;
+ const site = { ...SITE, baseUrl: config.compare.baseUrl };
+ const isCompare = (req) => {
+ const host = String(req.hostname || '').toLowerCase();
+ return host === config.compare.host || host === `www.${config.compare.host}`;
+ };
+ const r = asyncRouter();
+ r.use((req, _res, next) => (isCompare(req) ? next() : next('router')));
+ r.use(express.urlencoded({ extended: false, limit: '32kb' }));
+
+ const problemPage = (res, status, title, text) => send(res, status, {
+ config, site, path: '/', viewer: res.req && res.req.viewer,
+ title, description: text, index: false,
+ body: raw(kit.page({ head: kit.head({ title, sub: text }), body: kit.empty({ title, text, icon: 'alert', action: { href: '/', label: 'All models' } }) })),
+ });
+
+ const page = (req, res, { title, description, path, body, jsonLd = [], index = true, status = 200, canonical = null }) => send(res, status, {
+ config, site, viewer: req.viewer, path, canonical, title, description, jsonLd, index,
+ body: raw(kit.page({ head: '', body })),
+ });
+
+ // ── Home ───────────────────────────────────────────────
+ r.get('/', async (req, res) => {
+ try {
+ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
+ const family = typeof req.query.family === 'string' ? req.query.family.slice(0, 80) : '';
+ const sort = ['requests', 'cheap', 'fast', 'quality', 'name'].includes(req.query.sort) ? req.query.sort : 'requests';
+ const [list, versions] = await Promise.all([
+ compare.methods.models({ q, family, sort, limit: 200 }),
+ compare.methods.versions(),
+ ]);
+ const families = list.facets.families.map((f) => `<a class="ovk-chip" href="/?family=${encodeURIComponent(f)}">${esc(f)}</a>`).join('');
+ const familyNote = family ? `<p class="ovk-note">Showing ${esc(family)}. <a href="/">All families</a></p>` : '';
+ const versionRows = versions.lineages.map((l) => ({
+ line: `<a href="/versions/${encodeURIComponent(l.key)}">${esc(l.label)}</a>`,
+ count: l.models.length,
+ latest: esc(l.latest.name),
+ quality: l.latest.quality && l.latest.quality.available ? String(l.latest.quality.score) : 'not measured',
+ }));
+ const body = `
+${kit.head({ title: 'Compare AI models', sub: 'Price from every provider that serves a model, measured speed and latency, Bench quality, privacy and tier — each with its date and source.', actions: [kit.button({ href: '/method', label: 'How it measures', kind: 'quiet', icon: 'ruler' })] })}
+${list.unavailable ? kit.notice({ kind: 'warn', text: 'The model catalog is not reachable right now. These pages will fill in when it is; nothing is shown as live when it is not.' }) : ''}
+${kit.find({ action: '/', name: 'q', placeholder: 'Find a model, a family or a provider', value: q })}
+${families ? `<div class="ovk-chips" role="group" aria-label="Families">${families}</div>` : ''}
+${familyNote}
+${kit.section({ title: 'Every model, with its numbers', count: list.total, note: 'The cheapest live offer’s price; the fastest measured offer’s p50 and p95; Bench Elo from public judged games. A row with no quality says why.', body: picker(list.models) })}
+${versions.lineages.length ? kit.section({ title: 'Versions compared properly', note: 'The same model line, each version against the one before it: price, speed, context and Bench Elo. An Elo move whose intervals overlap is not called a change.', body: kit.table({
+ columns: [
+ { key: 'line', label: 'Model line', primary: true, html: true },
+ { key: 'count', label: 'Versions', num: true },
+ { key: 'latest', label: 'Latest' },
+ { key: 'quality', label: 'Latest Bench Elo' },
+ ],
+ rows: versionRows, label: 'Model lines with versions',
+ }) }) : ''}
+${kit.section({ title: 'What a comparison is', body: `<p class="ovk-prose">A comparison is data: the things (models), the criteria (price, speed, quality, privacy, tier), the sources, the method, and the results with the date each was read. The API is at <a href="/api/v1/compare">/api/v1/compare</a>; the pages and the compare builder are next.</p>` })}
+${kit.section({ title: 'Sources', body: sourceList(list.sources) })}
+`;
+ return page(req, res, {
+ title: 'Compare AI models: price, speed, quality and privacy',
+ description: 'Compare AI models by the cheapest price per provider, measured speed and latency, OpenVibe.Bench quality, privacy and tier. Every number dated and sourced; no invented scores.',
+ path: '/',
+ jsonLd: [seo.jsonLd.webPage({ name: 'OpenVibe.Compare', url: `${site.baseUrl}/`, description: 'Compare AI models by price, speed, Bench quality, privacy and tier.', siteUrl: site.baseUrl })],
+ body,
+ });
+ } catch (err) {
+ ctx.log.warn('[openvibe.compare] home failed:', String((err && err.message) || err).slice(0, 200));
+ return problemPage(res, 503, 'The comparison is not available', 'AI or Bench could not be read. Try again in a moment.');
+ }
+ });
+
+ // ── One model ──────────────────────────────────────────
+ r.get('/models/:org/:model', async (req, res) => {
+ try {
+ const slug = `${req.params.org}/${req.params.model}`;
+ const out = await compare.methods.model(slug);
+ if (!out) return problemPage(res, 404, 'No such model', `Nothing here serves ${slug}.`);
+ const m = out.model;
+ const offers = out.offers.map((o) => ({
+ offer: `<b>${esc(o.provider_name)}</b>${o.tier ? ` ${badgeFor(o.tier, o.tier === 'community' ? 'accent' : '')}` : ''}${o.free_now ? ` ${badgeFor('free now', 'ok')}` : o.free ? ` ${badgeFor('free')}` : ''}<br><span class="ovk-muted">${esc(o.model || '')}</span>`,
+ price: o.price ? `<b>${money(o.price.in)}</b> in · ${money(o.price.out)} out${o.price.cached != null ? `<br><span class="ovk-muted">cached ${money(o.price.cached)}</span>` : ''}` : '<span class="ovk-muted">no per-token price</span>',
+ speed: o.speed.p50_ms == null && o.speed.p95_ms == null ? '<span class="ovk-muted">not measured</span>' : `${ms(o.speed.p50_ms)} <small>p50</small> · ${ms(o.speed.p95_ms)} <small>p95</small>`,
+ uptime: o.uptime == null ? '–' : pct(o.uptime),
+ privacy: `${privacyText(o.privacy)}<br><span class="ovk-muted">${policyText(o.data_policy)}</span>`,
+ measured: day(o.measured_at) || '–',
+ }));
+ const qualityRows = (m.quality.by_benchmark || []).map((b) => ({
+ board: esc(b.name),
+ elo: b.elo == null ? '–' : String(b.elo),
+ games: count(b.games),
+ interval: b.ci_lower == null || b.ci_upper == null ? '–' : `${b.ci_lower}–${b.ci_upper}`,
+ state: b.provisional ? badgeFor('provisional', 'warn') : badgeFor('judged', 'ok'),
+ }));
+ const versionRows = (out.versions ? out.versions.models : []).map((v, i) => ({
+ version: `<a href="${modelHref(v.slug)}">${esc(v.name)}</a>${i === out.versions.models.length - 1 ? ` ${badgeFor('latest', 'accent')}` : ''}`,
+ price: v.price && v.price.in != null ? `${money(v.price.in)} / ${money(v.price.out)}` : '–',
+ speed: v.speed && v.speed.p50_ms != null ? ms(v.speed.p50_ms) : '–',
+ quality: v.quality && v.quality.available ? String(v.quality.score) : 'not measured',
+ changed: v.changes ? esc(v.changes.summary) : 'first version in the line',
+ }));
+ const alternatives = out.alternatives.map((a) => `<li><a href="${modelHref(a.slug)}">${esc(a.name)}</a> — ${a.price && a.price.in != null ? `${money(a.price.in)} in · ${money(a.price.out)} out` : 'no price'}${a.quality && a.quality.available ? ` · ${a.quality.score} Elo` : ''}</li>`).join('');
+ const body = `
+${kit.head({ title: m.name, sub: `${m.family || ''}${m.line.version_label ? ` · version ${m.line.version_label}` : ''} · ${m.kind}`, back: { href: '/', label: 'All models' }, actions: [kit.button({ href: `/compare?models=${encodeURIComponent(m.slug)}`, label: 'Compare it', kind: 'quiet', icon: 'columns' })] })}
+${kit.stats([
+ [money(m.price && m.price.in), 'cheapest in, per 1M'],
+ [money(m.price && m.price.out), 'cheapest out, per 1M'],
+ [ms(m.speed && m.speed.p50_ms), 'typical p50'],
+ [qualityText(m.quality).split(' · ')[0], 'Bench Elo'],
+ [count(m.offers_count), 'live offers'],
+ ])}
+${m.quality.available ? '' : kit.notice({ kind: 'info', text: m.quality.reason || 'No quality score yet.' })}
+${kit.section({ title: 'Every offer that serves it', count: offers.length, note: 'The price book: each provider’s own price, speed, uptime, privacy and tier. “Not stated” is not a guess.', body: kit.table({
+ columns: [
+ { key: 'offer', label: 'Provider', primary: true, html: true },
+ { key: 'price', label: 'In / out per 1M', html: true },
+ { key: 'speed', label: 'Speed', html: true },
+ { key: 'uptime', label: 'Uptime' },
+ { key: 'privacy', label: 'Privacy and data policy', html: true },
+ { key: 'measured', label: 'Price read' },
+ ],
+ rows: offers, label: `Offers for ${m.name}`,
+ }) })}
+${kit.section({ title: 'Bench quality', note: `Read from OpenVibe.Bench on ${day(m.quality.read_at) || '–'}. Elo is shown with its own interval per board; a pooled score is not a new interval.`, body: qualityRows.length ? kit.table({
+ columns: [
+ { key: 'board', label: 'Board', primary: true },
+ { key: 'elo', label: 'Elo', num: true },
+ { key: 'games', label: 'Games', num: true },
+ { key: 'interval', label: 'Interval' },
+ { key: 'state', label: 'State', html: true },
+ ],
+ rows: qualityRows, label: 'Bench quality',
+ }) : kit.notice({ kind: 'info', text: m.quality.reason || 'No public Bench rounds for this model yet.' }) })}
+${out.versions ? kit.section({ title: `Versions of the ${out.versions.label} line`, note: 'Each row is compared with the one before it on price, speed, context and Bench Elo.', body: kit.table({
+ columns: [
+ { key: 'version', label: 'Version', primary: true, html: true },
+ { key: 'price', label: 'In / out' },
+ { key: 'speed', label: 'p50' },
+ { key: 'quality', label: 'Bench Elo' },
+ { key: 'changed', label: 'What changed' },
+ ],
+ rows: versionRows, label: 'Versions',
+ }) }) : ''}
+${alternatives ? kit.section({ title: 'Close alternatives', body: `<ul class="ovk-list">${alternatives}</ul>` }) : ''}
+${kit.section({ title: 'Teach the benchmarks', note: 'A remark on this comparison becomes a pending benchmark case. It is reviewed before anything runs or is published.', body: remarkForm({ models: [m.slug], comparison: m.slug }) })}
+${kit.section({ title: 'Sources', body: sourceList(out.sources) })}
+`;
+ return page(req, res, {
+ title: `${m.name} benchmarks, price and speed (${monthYear(m.measured_at)})`,
+ description: `${m.name}: ${m.offers_count} live offers, from ${money(m.price && m.price.in)} in and ${money(m.price && m.price.out)} out per 1M, ${ms(m.speed && m.speed.p50_ms)} p50, ${m.quality.available ? `${m.quality.score} Bench Elo` : 'no Bench score yet'}. Measured ${day(m.measured_at) || 'date unknown'}.`,
+ path: modelHref(m.slug),
+ jsonLd: [seo.jsonLd.softwareApp({ name: m.name, url: `${site.baseUrl}${modelHref(m.slug)}`, description: `${m.name}: price, speed and Bench quality.` }), seo.jsonLd.breadcrumbs([{ name: 'Compare', url: `${site.baseUrl}/` }, { name: m.name, url: `${site.baseUrl}${modelHref(m.slug)}` }])],
+ body,
+ });
+ } catch (err) {
+ ctx.log.warn('[openvibe.compare] model failed:', String((err && err.message) || err).slice(0, 200));
+ return problemPage(res, 503, 'The model is not available', 'AI or Bench could not be read. Try again in a moment.');
+ }
+ });
+
+ // ── Side by side ───────────────────────────────────────
+ r.get('/compare', async (req, res) => {
+ const refs = [...new Set([].concat(req.query.models || []).flatMap((x) => String(x || '').split(',')).map((x) => x.trim()).filter(Boolean))];
+ try {
+ if (refs.length < 2 || refs.length > 4) {
+ const list = await compare.methods.models({ limit: 200 });
+ const body = `
+${kit.head({ title: 'Compare side by side', sub: 'Choose two to four models. Each criterion has its own winner; there is no invented overall score.', back: { href: '/', label: 'All models' } })}
+${refs.length ? kit.notice({ kind: 'info', text: 'Choose between two and four models.' }) : ''}
+${kit.section({ title: 'Choose the models', body: picker(list.models, { selected: refs }) })}
+`;
+ return page(req, res, { title: 'Compare two models side by side', description: 'Pick two to four AI models and compare their price, speed, Bench quality, context, privacy and tier.', path: '/compare', body, index: refs.length === 0 });
+ }
+ const out = await compare.methods.compare(refs);
+ if (out.error) {
+ const list = await compare.methods.models({ limit: 200 });
+ const body = `${kit.head({ title: 'Compare side by side', sub: out.message })}
+${kit.notice({ kind: 'warn', text: out.message })}
+${kit.section({ title: 'Choose the models', body: picker(list.models, { selected: refs }) })}`;
+ return page(req, res, { title: 'Compare AI models side by side', description: out.message, path: '/compare', body, index: false, status: out.status || 400 });
+ }
+ const models = out.models;
+ const columns = [
+ { key: 'criterion', label: 'Criterion', primary: true, html: true },
+ ...models.map((m) => ({ key: m.slug, label: m.name, html: true })),
+ ];
+ const rows = out.criteria.map((c) => {
+ const row = { criterion: `<b>${esc(c.label)}</b>${c.better ? `<br><span class="ovk-muted">${c.better === 'lower' ? 'lower is better' : 'higher is better'}</span>` : ''}` };
+ for (const v of c.values) {
+ const isWinner = c.winner && c.winner === v.slug;
+ row[v.slug] = v.available
+ ? `${v.value == null ? '–' : c.unit === 'USD' ? money(v.value) : c.unit === 'ms' ? ms(v.value) : c.unit === 'share' ? pct(v.value) : count(v.value)}${isWinner ? ` ${badgeFor('best', 'ok')}` : ''}`
+ : `<span class="ovk-muted">${esc(v.reason || 'not measured')}</span>`;
+ }
+ return row;
+ });
+ const body = `
+${kit.head({ title: models.map((m) => m.name).join(' vs '), sub: `${models.length} models, ${out.criteria.length} criteria, every number with its source and date`, back: { href: '/', label: 'All models' }, actions: [kit.button({ href: `/compare?models=${encodeURIComponent(models.map((m) => m.slug).join(','))}`, label: 'This comparison', kind: 'quiet', icon: 'link' })] })}
+${kit.notice({ kind: 'info', text: out.verdict.headline })}
+${out.price_spread ? `<p class="ovk-prose"><b>Price spread:</b> ${esc(out.price_spread.cheapest.name)} at ${money(out.price_spread.cheapest.price.out)} out is ${out.price_spread.percent == null ? '' : `${Math.abs(out.price_spread.percent).toFixed(0)}% cheaper`} than ${esc(out.price_spread.dearest.name)} at ${money(out.price_spread.dearest.price.out)}.</p>` : ''}
+${kit.section({ title: 'Side by side', note: out.verdict.caveat, body: kit.table({ columns, rows, label: 'Comparison', stack: true }) })}
+${out.verdict.quality_note ? kit.notice({ kind: 'info', text: out.verdict.quality_note }) : ''}
+${kit.section({ title: 'What each model is', body: `<ul class="ovk-list">${models.map((m) => `<li><a href="${modelHref(m.slug)}">${esc(m.name)}</a> — ${m.offers_count} offers; ${m.price && m.price.in != null ? `${money(m.price.in)} in · ${money(m.price.out)} out` : 'no price'}; ${m.quality.available ? `${m.quality.score} Bench Elo` : 'no Bench score yet'}</li>`).join('')}</ul>` })}
+${kit.section({ title: 'Teach the benchmarks', note: 'A remark on this comparison becomes a pending benchmark case for review.', body: remarkForm({ models: models.map((m) => m.slug), comparison: models.map((m) => m.slug).join('-vs-') }) })}
+${kit.section({ title: 'Sources', body: sourceList(out.sources) })}
+`;
+ return page(req, res, {
+ title: `${models.map((m) => m.name).join(' vs ')}: price, speed and Bench quality`,
+ description: `Compare ${models.map((m) => m.name).join(' and ')} on price per provider, measured speed, Bench Elo, context, privacy and tier. ${out.verdict.headline}`,
+ path: '/compare',
+ canonical: `${site.baseUrl}/compare?models=${encodeURIComponent(models.map((m) => m.slug).join(','))}`,
+ jsonLd: [seo.jsonLd.article({ headline: `${models.map((m) => m.name).join(' vs ')}: price, speed and quality`, url: `${site.baseUrl}/compare?models=${encodeURIComponent(models.map((m) => m.slug).join(','))}`, description: out.verdict.headline, datePublished: null, dateModified: day(out.generated_at) })],
+ body,
+ });
+ } catch (err) {
+ ctx.log.warn('[openvibe.compare] compare failed:', String((err && err.message) || err).slice(0, 200));
+ return problemPage(res, 503, 'The comparison is not available', 'AI or Bench could not be read. Try again in a moment.');
+ }
+ });
+
+ // ── Versions ───────────────────────────────────────────
+ r.get('/versions', async (req, res) => {
+ try {
+ const out = await compare.methods.versions();
+ const rows = out.lineages.map((l) => ({
+ line: `<a href="/versions/${encodeURIComponent(l.key)}">${esc(l.label)}</a>`,
+ family: esc(l.family || ''),
+ versions: l.models.length,
+ latest: esc(l.latest.name),
+ changed: l.latest.changes ? esc(l.latest.changes.summary) : '',
+ read: day(l.measured_at) || '–',
+ }));
+ const body = `${kit.head({ title: 'Versions compared properly', sub: 'The same model line, each version against the one before it: price, speed, context and Bench Elo. An Elo move whose intervals overlap is not called a change.' })}
+${kit.section({ title: 'Model lines with two or more versions', count: rows.length, body: kit.table({
+ columns: [
+ { key: 'line', label: 'Line', primary: true, html: true },
+ { key: 'family', label: 'Family' },
+ { key: 'versions', label: 'Versions', num: true },
+ { key: 'latest', label: 'Latest' },
+ { key: 'changed', label: 'Latest change' },
+ { key: 'read', label: 'Read' },
+ ],
+ rows, label: 'Model lines',
+ empty: 'No model line has two versions in the catalog yet. When one does, its versions appear here.',
+ }) })}
+${kit.section({ title: 'How versions are read', body: `<p class="ovk-prose">${esc(compare.method.versions)}</p>` })}
+${kit.section({ title: 'Sources', body: sourceList(out.sources) })}`;
+ return page(req, res, { title: 'AI model versions compared: what changed', description: 'The same model line’s versions in one table: price, speed, context and Bench Elo, with an interval-aware “what changed”.', path: '/versions', body });
+ } catch (err) {
+ ctx.log.warn('[openvibe.compare] versions failed:', String((err && err.message) || err).slice(0, 200));
+ return problemPage(res, 503, 'Versions are not available', 'AI or Bench could not be read. Try again in a moment.');
+ }
+ });
+
+ r.get('/versions/:key', async (req, res) => {
+ try {
+ const out = await compare.methods.versions(req.params.key);
+ if (!out.lineages.length) return problemPage(res, 404, 'No such model line', `No line with versions is called ${req.params.key}.`);
+ const l = out.lineages[0];
+ const rows = l.models.map((m, i) => ({
+ version: `<a href="${modelHref(m.slug)}">${esc(m.name)}</a>${i === l.models.length - 1 ? ` ${badgeFor('latest', 'accent')}` : ''}`,
+ price: m.price && m.price.in != null ? `${money(m.price.in)} / ${money(m.price.out)}` : '–',
+ speed: m.speed && m.speed.p50_ms != null ? ms(m.speed.p50_ms) : '–',
+ quality: m.quality && m.quality.available ? `${m.quality.score}` : 'not measured',
+ context: m.context == null ? '–' : count(m.context),
+ changed: m.changes ? `<b>${esc(m.changes.summary)}</b><br>${m.changes.changes.filter((c) => c.available && c.direction !== 'same').map(changeLine).join('<br>') || '<span class="ovk-muted">No measured criterion changed.</span>'}${m.changes.quality && !m.changes.quality.meaningful ? `<br><span class="ovk-muted">${esc(m.changes.quality.reason)}</span>` : ''}` : 'first version in the line',
+ }));
+ const body = `${kit.head({ title: `${l.label}: versions compared`, sub: `${l.models.length} versions · ${l.family || 'family'} · read ${day(l.measured_at) || '–'}`, back: { href: '/versions', label: 'Versions' } })}
+${kit.section({ title: 'What changed, version by version', note: 'Price in/out per 1M, p50 speed, context and Bench Elo. A quality move inside its interval is not called a change.', body: kit.table({
+ columns: [
+ { key: 'version', label: 'Version', primary: true, html: true },
+ { key: 'price', label: 'In / out' },
+ { key: 'speed', label: 'p50' },
+ { key: 'quality', label: 'Bench Elo' },
+ { key: 'context', label: 'Context' },
+ { key: 'changed', label: 'What changed', html: true },
+ ],
+ rows, label: `${l.label} versions`,
+ }) })}
+${l.latest.quality && l.latest.quality.available ? kit.section({ title: 'The latest version’s boards', body: kit.table({
+ columns: [
+ { key: 'board', label: 'Board', primary: true },
+ { key: 'elo', label: 'Elo', num: true },
+ { key: 'games', label: 'Games', num: true },
+ { key: 'interval', label: 'Interval' },
+ ],
+ rows: l.latest.quality.by_benchmark.map((b) => ({ board: esc(b.name), elo: b.elo == null ? '–' : String(b.elo), games: count(b.games), interval: b.ci_lower == null ? '–' : `${b.ci_lower}–${b.ci_upper}` })),
+ label: 'Boards',
+ }) }) : ''}
+${kit.section({ title: 'How versions are read', body: `<p class="ovk-prose">${esc(compare.method.versions)}</p>` })}
+${kit.section({ title: 'Sources', body: sourceList(out.sources) })}`;
+ return page(req, res, { title: `${l.label} versions: what changed`, description: `${l.models.length} versions of ${l.label} compared on price, speed, context and Bench Elo, each against the one before it.`, path: `/versions/${encodeURIComponent(l.key)}`, body });
+ } catch (err) {
+ ctx.log.warn('[openvibe.compare] version line failed:', String((err && err.message) || err).slice(0, 200));
+ return problemPage(res, 503, 'The line is not available', 'AI or Bench could not be read. Try again in a moment.');
+ }
+ });
+
+ // ── Method ─────────────────────────────────────────────
+ r.get('/method', async (_req, res) => {
+ const out = await compare.methods.models({ limit: 1 }).catch(() => null);
+ const body = `${kit.head({ title: 'How OpenVibe.Compare measures', sub: 'A comparison is data: things, criteria, sources, a method, results with dates. Nothing here is estimated.' })}
+${kit.section({ title: 'Price', body: `<p class="ovk-prose">${esc(compare.method.price)}</p>` })}
+${kit.section({ title: 'Speed and latency', body: `<p class="ovk-prose">${esc(compare.method.speed)}</p>` })}
+${kit.section({ title: 'Quality', body: `<p class="ovk-prose">${esc(compare.method.quality)}</p>` })}
+${kit.section({ title: 'Privacy and tier', body: `<p class="ovk-prose">${esc(compare.method.privacy)}</p><p class="ovk-prose">${esc(compare.method.tier)}</p>` })}
+${kit.section({ title: 'Versions', body: `<p class="ovk-prose">${esc(compare.method.versions)}</p>` })}
+${kit.section({ title: 'When a source cannot be read', body: `<p class="ovk-prose">${esc(compare.method.unavailable)}</p>` })}
+${kit.section({ title: 'A reader’s remark', body: `<p class="ovk-prose">Any remark on a comparison can become a pending benchmark case. It is stored with the models it names, the criterion it questions and an optional link. The owner reads the queue; only an accepted case is turned into a Bench task. A remark is never a result and never runs by itself.</p>` })}
+${out ? kit.section({ title: 'Sources', body: sourceList(out.sources) }) : ''}`;
+ return page(_req, res, { title: 'How OpenVibe.Compare measures price, speed, quality and privacy', description: 'The method behind every comparison: dated price from AI’s catalog, measured speed, Bench Elo with intervals, privacy and tier, and version deltas that respect uncertainty.', path: '/method', body });
+ });
+
+ // ── The remark ─────────────────────────────────────────
+ r.post('/remarks', rateLimit({ windowMs: 60_000, limit: 10, standardHeaders: true, legacyHeaders: false }), async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ if (b.website) return res.redirect(303, '/remarks/thanks');
+ const clean = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);
+ const refs = [...new Set([].concat(b.models || []).flatMap((x) => String(x || '').split(',')).map((x) => clean(x, 160)).filter(Boolean))].slice(0, 4);
+ const remark = clean(b.remark, 2000);
+ const field = ['price', 'speed', 'quality', 'privacy', 'context', 'other'].includes(String(b.field)) ? String(b.field) : null;
+ if (!refs.length || remark.length < 10) {
+ return send(res, 400, {
+ config, site, viewer: req.viewer, path: '/remarks', index: false,
+ title: 'That remark needs a model and a little more detail',
+ description: 'A remark needs at least one model and ten characters.',
+ body: raw(kit.page({ head: kit.head({ title: 'That remark needs a model and a little more detail', sub: 'Go back and add at least one model and ten characters.' }), body: kit.empty({ title: 'Not sent', text: 'A remark needs at least one model and ten characters.', icon: 'alert', action: { href: '/', label: 'All models' } }) })),
+ });
+ }
+ try {
+ const catalog = await ai.catalog();
+ const bySlug = new Map((catalog.models || []).map((m) => [String(m.slug).toLowerCase(), m]));
+ const known = refs.filter((x) => bySlug.has(x.toLowerCase()));
+ if (!known.length) throw Object.assign(new Error('No such model'), { status: 400 });
+ let sourceUrl = null;
+ if (b.source_url) {
+ const u = new URL(String(b.source_url));
+ if (['http:', 'https:'].includes(u.protocol)) sourceUrl = u.toString().slice(0, 500);
+ }
+ await compare.methods.remarks.create({
+ models: known.map((x) => bySlug.get(x.toLowerCase()).slug),
+ comparison: clean(b.comparison, 200) || null,
+ field, remark, proposedPrompt: clean(`Test ${known.map((x) => bySlug.get(x.toLowerCase()).name).join(', ')}: ${remark}`, 4000),
+ sourceUrl,
+ subject: req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null,
+ });
+ return res.redirect(303, '/remarks/thanks');
+ } catch (err) {
+ ctx.log.warn('[openvibe.compare] remark failed:', String((err && err.message) || err).slice(0, 200));
+ return send(res, 503, {
+ config, site, viewer: req.viewer, path: '/remarks', index: false,
+ title: 'The remark could not be stored',
+ description: 'Try again in a moment.',
+ body: raw(kit.page({ head: kit.head({ title: 'The remark could not be stored', sub: 'Try again in a moment.' }), body: kit.empty({ title: 'Not sent', text: 'The catalog or the store could not be read. Nothing was saved.', icon: 'alert', action: { href: '/', label: 'All models' } }) })),
+ });
+ }
+ });
+
+ r.get('/remarks/thanks', (req, res) => send(res, 200, {
+ config, site, viewer: req.viewer, path: '/remarks/thanks', index: false,
+ title: 'Thank you — a person will read it',
+ description: 'Your remark became a pending benchmark case.',
+ body: raw(kit.page({ head: kit.head({ title: 'Thank you — a person will read it', sub: 'Your remark is a pending benchmark case now, not a result.' }), body: `${kit.notice({ kind: 'ok', text: 'Nothing runs and nothing is published until the owner accepts the case.' })}<p class="ovk-prose"><a href="/">Back to the models</a></p>` })),
+ }));
+
+ // ── Robots, sitemap and llms.txt ───────────────────────
+ r.get('/robots.txt', (_req, res) => {
+ res.type('text/plain').send(seo.robotsTxt({ sitemaps: [`${site.baseUrl}/sitemap.xml`], disallow: ['/api/', '/auth/', '/remarks/'] }));
+ });
+
+ r.get('/sitemap.xml', async (_req, res) => {
+ try {
+ const [list, versions] = await Promise.all([compare.methods.models({ limit: 250 }), compare.methods.versions()]);
+ const urls = [
+ { loc: `${site.baseUrl}/`, lastmod: day(list.catalog_generated_at), changefreq: 'daily', priority: 1 },
+ { loc: `${site.baseUrl}/versions`, lastmod: day(versions.generated_at), changefreq: 'daily', priority: 0.8 },
+ { loc: `${site.baseUrl}/method`, changefreq: 'monthly', priority: 0.4 },
+ ...list.models.map((m) => ({ loc: `${site.baseUrl}${modelHref(m.slug)}`, lastmod: day(m.measured_at), changefreq: 'daily', priority: 0.7 })),
+ ...versions.lineages.map((l) => ({ loc: `${site.baseUrl}/versions/${encodeURIComponent(l.key)}`, lastmod: day(l.measured_at), changefreq: 'weekly', priority: 0.6 })),
+ ];
+ res.type('application/xml').send(seo.sitemapXml(urls));
+ } catch (err) {
+ ctx.log.warn('[openvibe.compare] sitemap failed:', String((err && err.message) || err).slice(0, 200));
+ res.status(503).type('text/plain').send('The sitemap is not available right now.');
+ }
+ });
+
+ r.get('/llms.txt', async (_req, res) => {
+ const list = await compare.methods.models({ limit: 50 }).catch(() => null);
+ res.type('text/plain').send(seo.llmsTxt({
+ name: 'OpenVibe.Compare',
+ summary: 'Compare anything, starting with AI models: price per provider, measured speed and latency, OpenVibe.Bench quality, privacy and tier. Every number is dated and sourced; no figure is estimated.',
+ base: site.baseUrl,
+ sections: [
+ {
+ title: 'Compare', links: [
+ { title: 'Every model', url: `${site.baseUrl}/`, note: 'the price book, speed and Bench quality' },
+ { title: 'Side by side', url: `${site.baseUrl}/compare`, note: 'two to four models, one winner per criterion' },
+ { title: 'Versions', url: `${site.baseUrl}/versions`, note: 'the same model line, what changed' },
+ { title: 'How it measures', url: `${site.baseUrl}/method`, note: 'sources, dates and the interval rule' },
+ { title: 'JSON API', url: `${site.baseUrl}/api/v1/compare`, note: 'the same data as JSON' },
+ ],
+ },
+ ...(list ? [{
+ title: 'A few models', links: list.models.slice(0, 20).map((m) => ({ title: m.name, url: `${site.baseUrl}${modelHref(m.slug)}`, note: `${m.price && m.price.in != null ? `$${m.price.in}/$${m.price.out} per 1M` : 'no price'}${m.quality && m.quality.available ? `, ${m.quality.score} Elo` : ''}` })),
+ }] : []),
+ ],
+ }));
+ });
+
+ r.use((_req, res) => problemPage(res, 404, 'Not found', 'That page is not part of openvibe.compare.'));
+ return r;
+}
+
+module.exports = { createCompareSite, SITE };
@@ -52,16 +52,25 @@ function renderPage(o) {
@@ -52,16 +52,25 @@ function renderPage(o) {
const signedIn = viewer.kind === 'user';
const signedIn = viewer.kind === 'user';
const pathNow = o.path || '/';
const pathNow = o.path || '/';
const loginNext = encodeURIComponent(pathNow);
const loginNext = encodeURIComponent(pathNow);
- const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
+ // A page may belong to a site served by this process behind another name (openvibe.compare): its own name,
+ // home, nav and canonical base, while the sign-in, the assets and the theme stay this service's.
+ const site = o.site || {};
+ const siteName = site.name || SITE_NAME;
+ const siteTagline = site.tagline || TAGLINE;
+ const siteHome = site.home || '/';
+ const siteNav = Array.isArray(site.nav) ? site.nav : NAV;
+ const siteBase = String(site.baseUrl || o.config.baseUrl).replace(/\/+$/, '');
+ const siteService = site.service || 'exchange';
+ const canonical = o.canonical || `${siteBase}${pathNow.split('?')[0]}`;
const nav = {
const nav = {
- service: 'exchange',
+ service: siteService,
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
links: [
links: [
- ...NAV.map((l) => ({ label: l.label, href: l.href, icon: l.icon, ...(l.more ? { more: true } : {}), active: active(pathNow, l.href) })),
+ ...siteNav.map((l) => ({ label: l.label, href: l.href, icon: l.icon, ...(l.more ? { more: true } : {}), active: active(pathNow, l.href) })),
...(signedIn ? [{ label: 'Dashboard', href: '/dashboard', icon: 'fa-key', active: active(pathNow, '/dashboard') }] : []),
...(signedIn ? [{ label: 'Dashboard', href: '/dashboard', icon: 'fa-key', active: active(pathNow, '/dashboard') }] : []),
...(viewer.role === 'owner' ? [{ label: 'AI console', href: '/console', icon: 'fa-sliders', active: active(pathNow, '/console') }] : []),
...(viewer.role === 'owner' ? [{ label: 'AI console', href: '/console', icon: 'fa-sliders', active: active(pathNow, '/console') }] : []),
],
],
- history: { type: 'page', title: o.title || SITE_NAME },
+ history: { type: 'page', title: o.title || siteName },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
tokenUrl: '/auth/navbar-token',
tokenUrl: '/auth/navbar-token',
@@ -69,28 +78,28 @@ function renderPage(o) {
@@ -69,28 +78,28 @@ function renderPage(o) {
logoutUrl: '/auth/logout?next={path}',
logoutUrl: '/auth/logout?next={path}',
notificationsRealtime: true,
notificationsRealtime: true,
};
};
- const footer = { service: 'exchange', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
+ const footer = { service: siteService, variant: 'full', mount: '#ov-footer', brandName: siteName };
const account = signedIn
const account = signedIn
? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
: `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
: `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
return shell.page({
return shell.page({
- name: SITE_NAME,
+ name: siteName,
lang: 'en',
lang: 'en',
- title: o.title || `${SITE_NAME}: ${TAGLINE}`,
- titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
- siteName: SITE_NAME,
- description: o.description || `${SITE_NAME} — ${TAGLINE} Every model's price, speed and uptime, live; one OpenAI-compatible endpoint; chat with one model or three side by side.`,
+ title: o.title || `${siteName}: ${siteTagline}`,
+ titleSuffix: o.title ? ` · ${siteName}` : '',
+ siteName,
+ description: o.description || `${siteName} — ${siteTagline} Every model's price, speed and uptime, live; one OpenAI-compatible endpoint; chat with one model or three side by side.`,
canonical,
canonical,
agentLayer: Boolean(o.agentLayer),
agentLayer: Boolean(o.agentLayer),
robots: o.index === false ? 'noindex, nofollow' : 'index, follow',
robots: o.index === false ? 'noindex, nofollow' : 'index, follow',
jsonLd: (o.jsonLd || []).filter(Boolean),
jsonLd: (o.jsonLd || []).filter(Boolean),
- home: '/',
- navLinks: NAV,
+ home: siteHome,
+ navLinks: siteNav,
bodyClass: o.bodyClass || (o.app ? 'ex-app' : undefined),
bodyClass: o.bodyClass || (o.app ? 'ex-app' : undefined),
navbar: nav,
navbar: nav,
footer,
footer,
head: `<meta name="referrer" content="strict-origin-when-cross-origin">
head: `<meta name="referrer" content="strict-origin-when-cross-origin">
-${appIcon.headTags({ site: 'exchange' })}
+${appIcon.headTags({ site: site.icon || 'exchange' })}
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${ovServe.url('kit-data.css')}">
<link rel="stylesheet" href="${ovServe.url('kit-data.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
@@ -319,6 +319,7 @@ ${families.length ? `<div class="ex-chips" role="group" aria-label="Family">${fa
@@ -319,6 +319,7 @@ ${families.length ? `<div class="ex-chips" role="group" aria-label="Family">${fa
${unavailable(catalog)}
${unavailable(catalog)}
${panel({ title: '', body: modelList(models, { empty: 'No model matches that search. Try a family or a provider.' }) })}
${panel({ title: '', body: modelList(models, { empty: 'No model matches that search. Try a family or a provider.' }) })}
${communityPools(community)}
${communityPools(community)}
+<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p>
<p class="ex-more"><a href="/status">Uptime and incidents →</a></p>
<p class="ex-more"><a href="/status">Uptime and incidents →</a></p>
</div>`;
</div>`;
}
}
@@ -382,6 +383,7 @@ ${kit.head({ title: model.name, sub: `${familyLabel(model)}${model.variant ? `
@@ -382,6 +383,7 @@ ${kit.head({ title: model.name, sub: `${familyLabel(model)}${model.variant ? `
${stats.length ? kit.stats(stats) : ''}
${stats.length ? kit.stats(stats) : ''}
${model.description ? `<p class="ex-prose">${esc(model.description)}</p>` : ''}
${model.description ? `<p class="ex-prose">${esc(model.description)}</p>` : ''}
${panel({ title: 'Every offer that serves it', note: 'Price in, out and cached per million tokens; speed and uptime from the last seven days of real requests; the privacy label and the provider’s own data policy.', id: 'offers', body: kit.table({ columns, rows, label: `Offers for ${model.name}`, empty: 'No live offer serves this model right now.' }) })}
${panel({ title: 'Every offer that serves it', note: 'Price in, out and cached per million tokens; speed and uptime from the last seven days of real requests; the privacy label and the provider’s own data policy.', id: 'offers', body: kit.table({ columns, rows, label: `Offers for ${model.name}`, empty: 'No live offer serves this model right now.' }) })}
+<p class="ex-more"><a href="https://openvibe.compare/models/${String(model.slug).split('/').map(encodeURIComponent).join('/')}">Compare ${esc(model.name)} with another model →</a></p>
${activityPanel(model.activity)}
${activityPanel(model.activity)}
${require('./analysis').modelFacts(analysis)}
${require('./analysis').modelFacts(analysis)}
${panel({ title: 'Presets', note: 'A preset fixes the router’s floor, ceiling and policy, and you name it as the model.', id: 'presets', body: presetsBox() })}
${panel({ title: 'Presets', note: 'A preset fixes the router’s floor, ceiling and policy, and you name it as the model.', id: 'presets', body: presetsBox() })}
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -0,0 +1,183 @@
+'use strict';
+/**
+ * openvibe.compare's pages (plan 297). One process serves Exchanges's own site and the compare site; the Host
+ * header decides which. These tests ask for the compare pages with the compare name, check the plain-kit markup,
+ * the version table, the remark form, the sitemap — and that the same paths on openvibe.exchange never become the
+ * compare site.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { createFakeAi } = require('./helpers/ai');
+const { createFakeBench } = require('./helpers/bench');
+
+const HOST = { 'x-forwarded-host': 'openvibe.compare' };
+
+(async () => {
+ const ai = createFakeAi();
+ ai.catalogData.models.push(
+ {
+ slug: 'openai/gpt-5-mini', canonical: 'openai/gpt-5-mini', name: 'gpt-5-mini', family: 'GPT', variant: null,
+ uncensored: false, kind: 'chat', context: 400000, max_output: 128000, open_weights: false,
+ capabilities: { tools: true, json: true, vision: true, stream: true }, modalities: ['text'], description: null,
+ requests_7d: 50, requests_today: 4, community: false,
+ offers: [{
+ provider: 'openai', provider_name: 'OpenAI', model: 'gpt-5-mini', id: 'openai/gpt-5-mini@v1', trust: 'first-party',
+ access: 'OWN_DEPLOYMENT', free: false, free_tier: false, status: 'up',
+ price: { in: 0.25, out: 2, cached: 0.025, per: '1M tokens', currency: 'USD' },
+ speed: { p50_ms: 900, p95_ms: 3000 }, uptime: 0.99, requests_7d: 50, requests_today: 4,
+ privacy: { label: 'anonymized', retention: '0', training: false, region: 'us', known: true },
+ data_policy: { trains: false, retention_days: 0, region: 'us' },
+ updated_at: '2026-11-06T00:00:00.000Z',
+ }],
+ },
+ {
+ slug: 'openai/gpt-5.1-mini', canonical: 'openai/gpt-5.1-mini', name: 'gpt-5.1-mini', family: 'GPT', variant: null,
+ uncensored: false, kind: 'chat', context: 400000, max_output: 128000, open_weights: false,
+ capabilities: { tools: true, json: true, vision: true, stream: true }, modalities: ['text'], description: null,
+ requests_7d: 80, requests_today: 6, community: false,
+ offers: [{
+ provider: 'openai', provider_name: 'OpenAI', model: 'gpt-5.1-mini', id: 'openai/gpt-5.1-mini@v1', trust: 'first-party',
+ access: 'OWN_DEPLOYMENT', free: false, free_tier: false, status: 'up',
+ price: { in: 0.2, out: 1.5, cached: 0.02, per: '1M tokens', currency: 'USD' },
+ speed: { p50_ms: 700, p95_ms: 2400 }, uptime: 0.995, requests_7d: 80, requests_today: 6,
+ privacy: { label: 'anonymized', retention: '0', training: false, region: 'us', known: true },
+ data_policy: { trains: false, retention_days: 0, region: 'us' },
+ updated_at: '2026-11-06T00:00:00.000Z',
+ }],
+ },
+ );
+ ai.catalogData.count = ai.catalogData.models.length;
+
+ const bench = createFakeBench({
+ boards: {
+ 'chat.arena': [
+ { subject: 'deepseek/deepseek-chat', elo: 1040, games: 20, wins: 12, losses: 6, ties: 2, win_rate: 0.65, ci_lower: 1020, ci_upper: 1060, provisional: false },
+ { subject: 'openai/gpt-5-mini', elo: 1100, games: 30, wins: 20, losses: 8, ties: 2, win_rate: 0.7, ci_lower: 1080, ci_upper: 1120, provisional: false },
+ { subject: 'openai/gpt-5.1-mini', elo: 1120, games: 30, wins: 22, losses: 6, ties: 2, win_rate: 0.77, ci_lower: 1125, ci_upper: 1145, provisional: false },
+ ],
+ },
+ });
+
+ const t = await boot({ ai, bench });
+
+ try {
+ await check('the compare home is plain kit markup, complete without JavaScript', async () => {
+ const r = await t.get('/', { headers: HOST });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('OpenVibe.Compare'), 'the compare name');
+ assert.ok(r.text.includes('Compare AI models'));
+ assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('gpt-5.1-mini'));
+ assert.ok(r.text.includes('Bench quality'));
+ assert.ok(r.text.includes('name="models"'), 'checkboxes to compare');
+ assert.ok(r.text.includes('/api/v1/compare'), 'the API is named');
+ assert.ok(!r.text.includes('OpenVibe.Exchange: Every AI model'), 'not the Exchange home');
+ });
+
+ await check('one model is its price book, its Bench boards and its versions', async () => {
+ const r = await t.get('/models/openai/gpt-5.1-mini', { headers: HOST });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('gpt-5.1-mini'));
+ assert.ok(r.text.includes('Every offer that serves it'));
+ assert.ok(r.text.includes('OpenAI'));
+ assert.ok(r.text.includes('$0.20') && r.text.includes('$1.50'), 'the price book');
+ assert.ok(r.text.includes('Bench quality'));
+ assert.ok(r.text.includes('1120'), 'the Bench score');
+ assert.ok(r.text.includes('Versions of the gpt-mini line'));
+ assert.ok(r.text.includes('gpt-5-mini'), 'the older version');
+ assert.ok(r.text.includes('Teach the benchmarks'), 'the remark form');
+ assert.ok(r.text.includes('send it for review') || r.text.includes('Send it for review'));
+ const missing = await t.get('/models/nope/nothing', { headers: HOST });
+ assert.strictEqual(missing.status, 404);
+ assert.ok(missing.text.includes('No such model'));
+ });
+
+ await check('two models side by side name a winner per criterion and no invented overall score', async () => {
+ const r = await t.get('/compare?models=openai/gpt-5.1-mini,deepseek/deepseek-chat', { headers: HOST });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('gpt-5.1-mini vs DeepSeek Chat'));
+ assert.ok(r.text.includes('Price out, per 1M'));
+ assert.ok(r.text.includes('Bench Elo'));
+ assert.ok(r.text.includes('best'));
+ assert.ok(r.text.includes('never one invented overall score') || r.text.includes('no invented overall score'));
+ assert.ok(r.text.includes('Price spread'));
+ assert.ok(r.text.includes('Teach the benchmarks'));
+ const one = await t.get('/compare?models=deepseek/deepseek-chat', { headers: HOST });
+ assert.strictEqual(one.status, 200);
+ assert.ok(one.text.includes('Choose the models'), 'one model goes back to the picker');
+ const five = await t.get('/compare?models=a,b,c,d,e', { headers: HOST });
+ assert.strictEqual(five.status, 200);
+ assert.ok(five.text.includes('between two and four') || five.text.includes('Choose the models'));
+ });
+
+ await check('the versions pages order a line and say what changed, with the interval rule', async () => {
+ const list = await t.get('/versions', { headers: HOST });
+ assert.strictEqual(list.status, 200, list.text);
+ assert.ok(list.text.includes('gpt-mini'));
+ const line = await t.get('/versions/gpt-mini', { headers: HOST });
+ assert.strictEqual(line.status, 200, line.text);
+ assert.ok(line.text.includes('gpt-5-mini') && line.text.includes('gpt-5.1-mini'));
+ assert.ok(line.text.includes('What changed'));
+ assert.ok(line.text.includes('better'));
+ assert.ok(line.text.includes('An Elo move whose intervals overlap is not called a change'));
+ const missing = await t.get('/versions/nope', { headers: HOST });
+ assert.strictEqual(missing.status, 404);
+ });
+
+ await check('method, robots, sitemap and llms.txt are there and say where they came from', async () => {
+ const method = await t.get('/method', { headers: HOST });
+ assert.strictEqual(method.status, 200);
+ assert.ok(method.text.includes('OpenVibe.AI catalog') && method.text.includes('OpenVibe.Bench'));
+ assert.ok(method.text.includes('reader’s remark') || method.text.includes('reader'));
+ const robots = await t.get('/robots.txt', { headers: HOST });
+ assert.strictEqual(robots.status, 200);
+ assert.ok(robots.text.includes('Sitemap: https://openvibe.compare/sitemap.xml'));
+ assert.ok(robots.text.includes('Disallow: /api/'));
+ const sitemap = await t.get('/sitemap.xml', { headers: HOST });
+ assert.strictEqual(sitemap.status, 200);
+ assert.ok(sitemap.text.includes('<loc>https://openvibe.compare/</loc>'));
+ assert.ok(sitemap.text.includes('https://openvibe.compare/models/openai/gpt-5.1-mini'));
+ assert.ok(sitemap.text.includes('https://openvibe.compare/versions/gpt-mini'));
+ const llms = await t.get('/llms.txt', { headers: HOST });
+ assert.strictEqual(llms.status, 200);
+ assert.ok(llms.text.includes('# OpenVibe.Compare'));
+ });
+
+ await check('a remark posted from the page is pending, and the thanks page says nothing ran', async () => {
+ const r = await t.get('/remarks', {
+ headers: HOST,
+ form: {
+ models: 'openai/gpt-5.1-mini',
+ comparison: 'openai/gpt-5.1-mini-vs-deepseek/deepseek-chat',
+ field: 'quality',
+ remark: 'The newer mini kept the whole file in view where the other dropped the last function.',
+ },
+ });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.strictEqual(r.headers.get('location'), '/remarks/thanks');
+ const row = await t.ctx.s.db.maybe('SELECT * FROM exchange_compare_remarks');
+ assert.ok(row, 'stored');
+ assert.strictEqual(row.status, 'pending');
+ const thanks = await t.get('/remarks/thanks', { headers: HOST });
+ assert.strictEqual(thanks.status, 200);
+ assert.ok(thanks.text.includes('Nothing runs and nothing is published'));
+ });
+
+ await check('the same paths on openvibe.exchange are Exchange, never the compare site', async () => {
+ const home = await t.get('/');
+ assert.strictEqual(home.status, 200);
+ assert.ok(home.text.includes('OpenVibe.Exchange'));
+ assert.ok(!home.text.includes('OpenVibe.Compare: Compare anything'));
+ const model = await t.get('/models/openai/gpt-5.1-mini');
+ assert.strictEqual(model.status, 200, 'Exchange has its own model page at the same path');
+ assert.ok(model.text.includes('OpenVibe.Exchange'), 'the Exchange name, not the compare site');
+ assert.ok(!model.text.includes('Teach the benchmarks'), 'Exchange does not get the compare page');
+ const api = await t.get('/api/v1/compare/ai/models');
+ assert.strictEqual(api.status, 200, 'the API is reachable on both names');
+ const unknown = await t.get('/nope', { headers: HOST });
+ assert.strictEqual(unknown.status, 404);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();