openvibers/openvibe.exchange Commits
Build the Exchange API platform, dashboard and search pages (#138)
Changes
nnew file mode 100644@@@ -0,0 +1,13 @@++# API platform review++++Home, Models, model detail, Dashboard (keys, usage/CSV, credits, deployments and settings), Search and owner supply console use the AI platform APIs in the companion PR. Arena reads and writes use the authenticated Exchange bridge and Report hosts the page. Arena and supply navigation use permanent redirects.++++The Chat compare move (Chat #419 / Exchange #136) is not merged. Persona and private-chat migration and their redirects must follow it; they are not implemented here. These changes should remain in review until that dependency and the library releases are coordinated. No production changes or provider purchases were performed.++++## Screenshots++++The 16 images in `screenshots/` exercise home, Models, model detail and Dashboard Keys at 390 and 1280 pixels in light and dark themes. They use local fake catalogue/account data, not production measurements. `checks.json` records the actual painted mode, document width and browser errors. No horizontal overflow or uncaught page errors were observed. Shared Network theme/frame requests report localhost CORS failures (and some unavailable remote resources); these screenshots are layout checks, not evidence of live cross-service integration.++++## Validation++++`node --check` on changed JavaScript; `ov test` full suite once, followed by focused fixes for intended redirects, Arena fragments, legacy pools and Dashboard CSV escaping. Final focused Arena and Dashboard tests pass. API keys, search and supply backend validation is recorded in the AI PR.nnew file mode 100644@@@ -0,0 +1,146 @@++[++ {++ "name": "home",++ "width": 390,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "models",++ "width": 390,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "model",++ "width": 390,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "dashboard",++ "width": 390,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 6++ },++ {++ "name": "home",++ "width": 390,++ "scheme": "light",++ "painted": "light",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "models",++ "width": 390,++ "scheme": "light",++ "painted": "light",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "model",++ "width": 390,++ "scheme": "light",++ "painted": "light",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "dashboard",++ "width": 390,++ "scheme": "light",++ "painted": "light",++ "docWidth": 390,++ "errors": [],++ "consoleErrors": 6++ },++ {++ "name": "home",++ "width": 1280,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "models",++ "width": 1280,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 6++ },++ {++ "name": "model",++ "width": 1280,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 6++ },++ {++ "name": "dashboard",++ "width": 1280,++ "scheme": "dark",++ "painted": "dark",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 6++ },++ {++ "name": "home",++ "width": 1280,++ "scheme": "light",++ "painted": "light",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 4++ },++ {++ "name": "models",++ "width": 1280,++ "scheme": "light",++ "painted": "light",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 6++ },++ {++ "name": "model",++ "width": 1280,++ "scheme": "light",++ "painted": "light",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 6++ },++ {++ "name": "dashboard",++ "width": 1280,++ "scheme": "light",++ "painted": "light",++ "docWidth": 1280,++ "errors": [],++ "consoleErrors": 6++ }++]nnew file mode 100644BBinary files /dev/null and b/docs/screenshots/dashboard-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/dashboard-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/dashboard-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/dashboard-390-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/home-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/home-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/home-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/home-390-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/model-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/model-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/model-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/model-390-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/models-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/models-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/models-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/models-390-light.png differnnew file mode 100644@@@ -0,0 +1,228 @@++.xp {++ padding-block: 40px;++ display: grid;++ gap: 28px;++}++.xp-hero {++ max-width: 850px;++}++.xp h1 {++ font-size: clamp(2rem, 5vw, 3.6rem);++ line-height: 1.1;++ letter-spacing: -0.04em;++}++.xp a,++.xp button,++.xp summary {++ min-height: 44px;++ display: inline-flex;++ align-items: center;++}++.xp input,++.xp select {++ min-height: 44px;++ max-width: 100%;++}++.xp pre {++ max-width: 100%;++ overflow: auto;++}++.xp-models {++ max-width: 100%;++ overflow: auto;++}++.xp table {++ border-collapse: collapse;++ width: 100%;++ text-align: left;++}++.xp th,++.xp td {++ padding: 16px 12px;++ border-bottom: 1px solid var(--border, #8884);++ vertical-align: middle;++}++.xp td small,++.xp td:last-child a {++ display: block;++}++.xp-reasons {++ display: grid;++ grid-template-columns: repeat(3, minmax(0, 1fr));++ gap: 16px;++}++.xp-activity label {++ display: flex;++ gap: 16px;++}++.xp label {++ display: grid;++ gap: 6px;++}++.xp form:not(.ovk-find) {++ display: grid;++ gap: 16px;++ max-width: 640px;++}++.xp nav {++ display: flex;++ flex-wrap: wrap;++ gap: 8px;++}++.xp details {++ border-bottom: 1px solid var(--border, #8884);++}++.xp code {++ overflow-wrap: anywhere;++}++.xp .xp-tabs a {++ padding: 8px 16px;++}++.xp-search-results {++ display: grid;++ grid-template-columns: repeat(2, minmax(0, 1fr));++ gap: 20px;++}++@media (max-width: 700px) {++ .xp {++ padding-block: 24px;++ }++ .xp-reasons,++ .xp-search-results {++ grid-template-columns: minmax(0, 1fr);++ }++ .xp table,++ .xp tbody,++ .xp tr,++ .xp td {++ display: block;++ }++ .xp thead {++ display: none;++ }++ .xp tr {++ padding: 12px 0;++ border-bottom: 1px solid var(--border, #8884);++ }++ .xp td {++ border: 0;++ padding: 4px 8px;++ overflow-wrap: anywhere;++ }++ .xp td[data-label]:before {++ content: attr(data-label) ': ';++ opacity: 0.7;++ }++ .xp td:last-child a {++ display: inline-flex;++ margin-right: 12px;++ }++ .xp h1 {++ font-size: 2.2rem;++ }++}++++.xp input:not([type='checkbox']),++.xp select {++ padding: 10px 12px;++ border: 1px solid var(--ovk-line);++ border-radius: 10px;++ background: var(--ovk-panel);++ color: var(--ovk-text);++ font: inherit;++}++.xp .ovk-find {++ max-width: 640px;++}++.xp .ovk-find input {++ border: 0;++ background: transparent;++}++.xp .ovk-find button {++ flex-shrink: 0;++}++.xp .xp-tabs {++ gap: 4px;++}++.xp .xp-tabs a[aria-current='page'] {++ background: var(--ovk-panel-2);++ border-radius: 10px;++ text-decoration: none;++}++++/* The Dashboard (Opus, 2026-11-09): its own scope, so the marketing pages' .xp rules (grid gaps, every link a 44 px++ flex box) stay off it. Cards on the page surface, the kit's tab rail, two-line rows, numbers in tabular figures. */++.xd { padding-block: 32px 48px; max-width: 880px; }++.xd .ovk-tabs { margin-top: 4px; margin-bottom: 4px; }++.xd-body { display: grid; gap: 16px; margin-top: 12px; }++.xd-card { padding: 18px 18px 20px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }++.xd-card > h2, .xd-title h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 1.12rem; letter-spacing: -.01em; }++.xd-card > h2.xd-sub { font-size: 1rem; }++.xd-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }++.xd-title h2 { margin: 0; }++.xd-note { margin: 0 0 14px; color: var(--ovk-muted); font-size: .92rem; line-height: 1.5; max-width: 60ch; }++.xd-actions { margin: 0 0 14px; display: flex; flex-wrap: wrap; gap: 8px; }++.xd-chip { display: inline-flex; align-items: center; margin-left: 8px; padding: 1px 8px; border-radius: 999px; font-size: .74rem; color: var(--ovk-muted); background: var(--ovk-panel-2); vertical-align: 1px; }++++.xd-keys { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }++.xd-key { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; align-items: center; padding: 12px 14px; border-radius: var(--ov-radius-1, 12px); background: var(--ovk-panel-2); }++.xd-key-main { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; }++.xd-key-main strong { font-size: 1rem; overflow-wrap: anywhere; }++.xd-key-main code { font-size: .84rem; color: var(--ovk-muted); }++.xd-key-meta { grid-column: 1; margin: 0; color: var(--ovk-muted); font-size: .86rem; }++.xd-key-act { grid-column: 2; grid-row: 1 / span 2; margin: 0; }++.xd-key.is-revoked { opacity: .62; }++++.xd-form { display: grid; gap: 16px; max-width: 520px; margin: 0; }++.xd-form label { display: grid; gap: 6px; }++.xd-form label > span:first-child { font-weight: 600; font-size: .92rem; }++.xd-form small { color: var(--ovk-muted); font-size: .82rem; line-height: 1.45; }++.xd-form input { min-height: 44px; box-sizing: border-box; width: 100%; padding: 10px 12px; border: 1px solid var(--ovk-line); border-radius: 10px; background: var(--ovk-bg, transparent); color: var(--ovk-text); font: inherit; }++.xd-form input:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }++.xd-money { position: relative; display: block; }++.xd-money > span { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ovk-muted); pointer-events: none; }++.xd-money input { padding-left: 26px; font-variant-numeric: tabular-nums; }++.xd-form .ovk-btn { justify-self: start; min-height: 44px; }++.xd-secret .ovk-snippet, .xd-secret pre { margin: 0 0 14px; }++++.xd-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0 0 16px; }++.xd-stats > div { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border-radius: var(--ov-radius-1, 12px); background: var(--ovk-panel-2); }++.xd-stats dd { margin: 0; font-size: 1.3rem; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }++.xd-stats dt { color: var(--ovk-muted); font-size: .8rem; }++.xd-bars { display: flex; align-items: flex-end; gap: 4px; height: 96px; padding-bottom: 18px; position: relative; }++.xd-bars > span { position: relative; flex: 1 1 0; height: 100%; display: flex; align-items: flex-end; }++.xd-bars i { display: block; width: 100%; border-radius: 4px 4px 2px 2px; background: color-mix(in srgb, var(--ovk-accent) 70%, transparent); }++.xd-bars b { position: absolute; left: 0; right: 0; bottom: -18px; text-align: center; font-weight: 400; font-size: .7rem; color: var(--ovk-muted); font-variant-numeric: tabular-nums; }++.xd-day { margin: 14px 0 6px; font-size: .78rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--ovk-muted); }++.xd-day:first-of-type { margin-top: 2px; }++.xd-use { list-style: none; margin: 0; padding: 0; }++.xd-use li { padding: 10px 0; border-top: 1px solid var(--ovk-line); }++.xd-use li:first-child { border-top: 0; }++.xd-use-top { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }++.xd-use-top code { font-size: .88rem; overflow-wrap: anywhere; min-width: 0; }++.xd-use-top strong { font-variant-numeric: tabular-nums; }++.xd-use p { margin: 2px 0 0; color: var(--ovk-muted); font-size: .84rem; }++.xd .ovk-list--flush { margin: 0 -18px -20px; }++@media (max-width: 520px) {++ .xd { padding-block: 20px 40px; }++ .xd-card { padding: 16px 14px 18px; }++ .xd-stats dd { font-size: 1.12rem; }++ .xd .ovk-list--flush { margin: 0 -14px -18px; }++}++/* Settings rows say what each page does in full: their line wraps instead of trailing off. */++.xd .ovk-row-sub > span:first-child { white-space: normal; overflow: visible; }++++/* Code samples scroll sideways on a phone; a URL never breaks mid-word (.xp code wraps anywhere for inline code). */++.xp pre code, .xp pre { overflow-wrap: normal; word-break: normal; white-space: pre; }++/* Grid tracks never grow past the page for a long line (a pre scrolls inside its own box). */++.xp > *, .xp-hero, .xp .ovk-snip { min-width: 0; max-width: 100%; }++.xp .ovk-snip pre { overflow-x: auto; }++++/* A model's activity: requests a day as bars, the day of the month under each. */++.ex-act { list-style: none; display: flex; align-items: flex-end; gap: 4px; height: 110px; margin: 0; padding: 0 0 18px; }++.ex-act li { position: relative; flex: 1 1 0; height: 100%; display: flex; align-items: flex-end; }++.ex-act-bar { display: block; width: 100%; border-radius: 4px 4px 2px 2px; background: color-mix(in srgb, var(--ovk-accent) 70%, transparent); }++.ex-act-day { position: absolute; left: 0; right: 0; bottom: -18px; text-align: center; font-size: .7rem; color: var(--ovk-muted); font-variant-numeric: tabular-nums; }++.ex-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++++.ex-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * The design-review screenshots for the API platform (Opus, 2026-11-09): home, the Dashboard's tabs and Search, at 390++ * and 1280, dark and light. It boots the real app on a temp database with a mock Network and a stand-in AI holding two++ * keys and a week of usage, and drives headless Chrome. It checks the width (scrollWidth === viewport) and tap++ * targets at 390, and says what it found. The shots go outside the repository (never committed).++ *++ * node scripts/shoot-platform.js [outDir] [paths, comma separated]++ */++const path = require('path');++const os = require('os');++const { boot } = require('../test/helpers/boot');++const { createFakeAi } = require('../test/helpers/ai');++const { chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');++++const OUT = path.resolve(process.argv[2] || path.join(os.tmpdir(), 'exchange-platform-shots'));++const PATHS = (process.argv[3] || '/,/dashboard/keys,/dashboard/usage,/dashboard/credits,/dashboard/deployments,/dashboard/settings,/search').split(',');++++const KEYS = [++ { id: 'xkey_1', name: 'My application', prefix: 'ovx_demo12', monthly_cap_usd: 10, allowed_models: [], created_at: '2026-11-08T10:00:00Z', revoked_at: null },++ { id: 'xkey_2', name: 'Night batch jobs', prefix: 'ovx_n8Kq3z', monthly_cap_usd: 25, allowed_models: ['deepseek/deepseek-chat', 'qwen/qwen3-32b'], created_at: '2026-11-01T10:00:00Z', revoked_at: null },++];++const USAGE = [];++for (let d = 1; d <= 7; d++) {++ USAGE.push({ day: `2026-11-0${d}`, key_id: 'xkey_1', model: 'deepseek/deepseek-chat', status: 'settled', requests: 40 * d, tokens_in: 9000 * d, tokens_out: 4000 * d, cost_usd: 0.004 * d });++ if (d % 2) USAGE.push({ day: `2026-11-0${d}`, key_id: 'xkey_2', model: 'qwen/qwen3-32b', status: 'settled', requests: 12 * d, tokens_in: 3000 * d, tokens_out: 1500 * d, cost_usd: 0.002 * d });++}++++(async () => {++ const ai = { ...createFakeAi(), meRequest: async (_token, p) => (p.endsWith('key-usage') ? { rows: USAGE } : { keys: KEYS }) };++ const t = await boot({ ai });++ const alice = t.network.addUser('alice');++ const cookie = t.signIn(alice);++ const url = new URL(t.base);++ const b = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });++ require('fs').mkdirSync(OUT, { recursive: true });++ try {++ for (const [width, scheme] of [[390, 'dark'], [390, 'light'], [1280, 'dark']]) {++ const ctx = await b.newContext({ viewport: { width, height: 900 }, colorScheme: scheme, isMobile: width < 700, hasTouch: width < 700 });++ await ctx.addCookies([++ { name: 'exchange_at', value: cookie.split('=')[1], domain: url.hostname, path: '/' },++ { name: 'ov_token', value: cookie.split('=')[1], domain: url.hostname, path: '/' },++ ]);++ const pg = await ctx.newPage();++ const errors = []; pg.on('pageerror', (e) => errors.push(e.message));++ for (const p of PATHS) {++ const r = await pg.goto(t.base + p, { waitUntil: 'load' }).catch(() => null);++ await pg.waitForTimeout(400);++ const info = await pg.evaluate(() => {++ const small = [...document.querySelectorAll('main a, main button, main input, main select, main summary')]++ .filter((el) => { const b = el.getBoundingClientRect(); return b.width > 0 && b.height > 0 && b.height < 44 && getComputedStyle(el).display !== 'inline'; })++ .map((el) => `${el.tagName.toLowerCase()}:${(el.textContent || el.name || '').trim().slice(0, 18)}:${Math.round(el.getBoundingClientRect().height)}`);++ return { over: document.documentElement.scrollWidth > innerWidth, small: small.slice(0, 6) };++ });++ const name = `${p === '/' ? 'home' : p.replace(/^\//, '').replace(/\//g, '-')}-${width}-${scheme}`;++ // The app's boot helper captures console output: the findings go to a file beside the shots.++ require('fs').appendFileSync(path.join(OUT, 'results.txt'), `${[name, r && r.status(), info.over ? 'OVERFLOW' : 'fits', width < 700 && info.small.length ? `small: ${info.small.join(' ')}` : ''].join(' ')}\n`);++ await pg.screenshot({ path: path.join(OUT, `${name}.png`), fullPage: true });++ }++ if (errors.length) require('fs').appendFileSync(path.join(OUT, 'results.txt'), `errors ${errors.slice(0, 3).join(' | ')}\n`);++ await ctx.close();++ }++ } finally {++ await b.close();++ await t.close();++ }++ console.log('shots in', OUT);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -214,6 +214,19 @@ async function createApp(opts = {}) {app.locals.sharingTimer = timer;}++ const arenaApi = express.Router();++ arenaApi.use(express.json({ limit:'64kb' }));++ arenaApi.use(principal.middleware);++ arenaApi.use((req,res,next) => {++ if (req.method !== 'GET' && (!req.headers.authorization || req.principal?.kind !== 'user')) return res.sendStatus(401);++ if (req.principal?.kind === 'user') req.viewer = req.principal;++ req.arenaApi = true;++ const path = req.path;++ req.url = path === '/rounds' ? (req.method === 'POST' ? '/arena/ask' : '/arena') : path.replace(/^\/rounds\//, '/arena/').replace(/\/votes$/, '/vote');++ next();++ });++ arenaApi.use(createArenaRoutes(ctx));++ app.use('/api/v1/arena', arenaApi);// Chat imports only the signed-in person's unsealed history. The service token must carry// the narrow export capability; Chat supplies the subject it just authenticated.app.get('/internal/exchange/chat/export', principal.middleware, async (req, res) => {@@@ -271,10 +284,15 @@ async function createApp(opts = {}) {agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });app.use(agent);}++ app.get(['/arena', '/arena/*'], (req,res) => res.redirect(301, 'https://openvibe.report/ai' + req.originalUrl));++ app.get(['/sell', '/sell/*'], (_req,res) => res.redirect(301, 'https://openvibe.link'));++ app.get('/ai', (_req,res) => res.redirect(301, 'https://openvibe.report/ai/exchange'));// The companions routes come before the pages router: /companions, /c/... and /talk/...app.use(createCompanionRoutes(ctx));// The Arena and the rankings: /arena, /arena/:id and /rankings.app.use(createArenaRoutes(ctx));++ app.use(require('./http/dashboard').createDashboardRoutes(ctx));++ app.use(require('./http/search').createSearchRoutes(ctx));app.use(createPageRoutes(ctx));app.use(createConsoleRoutes(ctx));@@@ -19,7 +19,7 @@ const ovServe = require('openvibe-shared/serve');const kit = require('openvibe-shared/kit');const { asyncRouter } = require('./router');const { sameOrigin } = require('./principal');--const { esc, raw } = require('../render/html');++const { esc, raw, render } = require('../render/html');const { send } = require('../render/layout');const views = require('../views/arena');const exchangeViews = require('../views/exchange');@@@ -71,7 +71,7 @@ function createArenaRoutes(ctx) {const { config, ai, bench, arenaStore, sharing, log } = ctx;const r = asyncRouter();r.use(express.urlencoded({ extended: false, limit: '64kb' }));-- const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });++ const page = (req, res, o, status = 200) => req.arenaApi ? res.status(status).set('Cache-Control','no-store').json({ title:o.title, html:render(o.body) }) : send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);const owner = (req) => req.viewer.subject;const signInPage = (req, res, { title, text, next, status = 401 }) => page(req, res, {@@@ -121,7 +121,7 @@ function createArenaRoutes(ctx) {const vote = await arenaStore.voteOf(owner(req), round.id);return page(req, res, {title: 'The Arena', index: false, head: shareHead,-- body: raw(views.arenaRound({ round, vote, revealed: Boolean(vote), problem: problem || notice, config, action: shareButton(round) })),++ body: raw(views.arenaRound({ round, vote, revealed: Boolean(vote), problem: problem || notice, config, action: req.arenaApi ? '' : shareButton(round) })),}, status);}@@@ -130,16 +130,16 @@ function createArenaRoutes(ctx) {/** The person's default for new rounds ("New rounds: Only me"), and what to do with the existing ones. */r.post('/arena/defaults', async (req, res) => {if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Farena');-- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ if (!(req.arenaApi || sameOrigin(req, config.baseUrl))) return notFromHere(req, res);const body = req.body || {};const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';await sharing.setDefault(owner(req), 'arena-round', audience, { applyAll: String(body.apply_all || '') === '1' }).catch((err) => log.warn('[Arena] the round default was not saved:', err.message));-- return res.redirect(303, '/arena');++ return req.arenaApi ? res.json({ location: '/ai/arena' }) : res.redirect(303, '/arena');});r.post('/arena/ask', async (req, res) => {if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to enter the Arena', text: 'Two models answer your prompt, blind.', next: '/arena' });-- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ if (!(req.arenaApi || sameOrigin(req, config.baseUrl))) return notFromHere(req, res);const prompt = String((req.body && req.body.prompt) || '').trim().slice(0, config.arena.maxChars);if (!prompt) return renderHome(req, res, { problem: 'Write a prompt first.', status: 422 });@@@ -218,7 +218,7 @@ function createArenaRoutes(ctx) {// The person's default for new rounds (Only me unless they changed it), then the sheet for this one.const audience = await sharing.defaultOf(owner(req), 'arena-round').catch(() => 'private');try { await sharing.register({ subject: owner(req) }, 'arena-round', round.id, audience); } catch (err) { log.warn('[Arena] the round was kept but not registered with the sharing system:', err.message); }-- return res.redirect(303, `/arena/${round.id}`);++ return req.arenaApi ? res.status(201).json({ location: `/ai/arena/${round.id}` }) : res.redirect(303, `/arena/${round.id}`);});/**@@@ -246,7 +246,7 @@ function createArenaRoutes(ctx) {r.post('/arena/:id/vote', async (req, res) => {if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to vote', text: 'Your vote is a Bench judgement for your account.', next: `/arena/${encodeURIComponent(req.params.id)}` });-- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ if (!(req.arenaApi || sameOrigin(req, config.baseUrl))) return notFromHere(req, res);const round = await arenaStore.get(owner(req), req.params.id);if (!round) return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such round', text: 'It may belong to another account.', action: { href: '/arena', label: 'The Arena' } })}</div>`) }, 404);@@@ -269,7 +269,7 @@ function createArenaRoutes(ctx) {owner: owner(req), roundId: round.id, outcome,benchJudgementId: out && out.judgement ? out.judgement.id : null,});-- return res.redirect(303, `/arena/${round.id}`);++ return req.arenaApi ? res.status(201).json({ location: `/ai/arena/${round.id}` }) : res.redirect(303, `/arena/${round.id}`);} catch (err) {if (err.code === 'bench.already_judged') {return renderRound(req, res, round, { notice: 'This vote is already counted in OpenVibe.Bench.', status: 409 });@@@ -6,6 +6,7 @@ const { send } = require('../render/layout');const { consolePage } = require('../views/console');const READS = Object.freeze({++ supply: '/api/v1/admin/console/supply',overview: '/api/v1/admin/console/overview', providers: '/api/v1/admin/console/providers', models: '/api/v1/models', routes: '/api/v1/routes',templates: '/api/v1/templates', workflows: '/api/v1/workflows', runs: '/api/v1/admin/console/runs', quotas: '/api/v1/admin/console/quotas',usage: '/api/v1/admin/console/usage', cache: '/api/v1/admin/console/cache', audit: '/api/v1/admin/console/audit',@@@ -56,6 +57,13 @@ function createConsoleRoutes({ config, ai }) {if (!origin) return false;try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }};++ r.post('/console/supply', async (req,res) => {++ if (!sameOrigin(req)) return res.sendStatus(403);++ const body = { model: req.body.model, runtime: req.body.runtime };++ for (const k of ['input_per_million','output_per_million','hourly_ceiling_usd','monthly_ceiling_usd','required_vram_mb','idle_minutes']) body[k] = Number(req.body[k]);++ try { await ai.adminRequest('/api/v1/admin/console/supply', { method:'POST', subject:req.viewer.subject, body }); return res.redirect(303,'/console/supply'); }++ catch { return page(req,res,'supply',{},'Supply settings could not be saved.',502); }++ });r.post('/console/providers', async (req, res) => {if (!sameOrigin(req)) return res.sendStatus(403);try {nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++const express = require('express');++const { raw } = require('../render/html');++const { send } = require('../render/layout');++const { sameOrigin } = require('./principal');++const views = require('../views/dashboard');++function createDashboardRoutes({ config, ai }) {++ const r = express.Router();++ r.use(express.urlencoded({ extended: false, limit: '32kb' }));++ const render = (req, res, tab, html, status = 200) =>++ send(res, status, {++ viewer: req.viewer,++ config,++ path: req.originalUrl,++ index: false,++ title: 'Dashboard · ' + tab,++ body: raw(html),++ });++ r.use('/dashboard', (req, res, next) => {++ res.set('Cache-Control', 'no-store');++ if (req.viewer?.kind !== 'user')++ return res.redirect(++ 303,++ '/auth/login?next=' +++ encodeURIComponent('/dashboard' + req.url),++ );++ if (req.method !== 'GET' && !sameOrigin(req, config.baseUrl))++ return res.sendStatus(403);++ next();++ });++ const call = (req, path, options) =>++ ai.meRequest(req.viewer.token, path, options);++ const safe = (fn) => async (req, res) => {++ try {++ await fn(req, res);++ } catch {++ render(req, res, 'Unavailable', views.unavailablePage(), 502);++ }++ };++ r.get(++ ['/dashboard', '/dashboard/keys'],++ safe(async (req, res) => {++ const data = await call(req, '/api/v1/exchange/keys');++ render(req, res, 'Keys', views.keysPage(data.keys || []));++ }),++ );++ r.post(++ '/dashboard/keys',++ safe(async (req, res) => {++ const data = await call(req, '/api/v1/exchange/keys', {++ method: 'POST',++ body: {++ name: req.body.name,++ monthly_cap_usd: Number(req.body.monthly_cap_usd),++ allowed_models: String(req.body.allowed_models || '')++ .split(',')++ .map((s) => s.trim())++ .filter(Boolean),++ },++ });++ render(req, res, 'Keys', views.newKeyPage(data.secret, data.key));++ }),++ );++ r.post(++ '/dashboard/keys/:id/revoke',++ safe(async (req, res) => {++ await call(++ req,++ '/api/v1/exchange/keys/' + encodeURIComponent(req.params.id),++ { method: 'DELETE' },++ );++ res.redirect(303, '/dashboard/keys');++ }),++ );++ r.get(++ ['/dashboard/usage', '/dashboard/usage.csv'],++ safe(async (req, res) => {++ const data = await call(req, '/api/v1/exchange/key-usage');++ const rows = data.rows || [];++ const keyList = req.path.endsWith('.csv') ? null : await call(req, '/api/v1/exchange/keys').catch(() => ({ keys: [] }));++ const columns = [++ 'day',++ 'key_id',++ 'model',++ 'requests',++ 'tokens_in',++ 'tokens_out',++ 'cost_usd',++ 'status',++ ];++ if (req.path.endsWith('.csv')) {++ const cell = (v) =>++ '"' +++ String(v ?? '')++ .replace(/^([=+@-])/, "'$1")++ .replace(/"/g, '""') +++ '"';++ return res++ .type('text/csv')++ .set(++ 'Content-Disposition',++ 'attachment; filename="exchange-usage.csv"',++ )++ .send(++ [++ columns.join(','),++ ...rows.map((r) =>++ columns.map((k) => cell(r[k])).join(','),++ ),++ ].join('\n'),++ );++ }++ render(req, res, 'Usage', views.usagePage(rows, (keyList && keyList.keys) || []));++ }),++ );++ r.get('/dashboard/credits', (req, res) => render(req, res, 'Credits', views.creditsPage()));++ r.get(++ '/dashboard/deployments',++ safe(async (req, res) => {++ const data = await ai.ownList(req.viewer.token);++ render(req, res, 'Deployments', views.deploymentsPage(data.endpoints || []));++ }),++ );++ r.get('/dashboard/settings', (req, res) => render(req, res, 'Settings', views.settingsPage()));++ return r;++}++module.exports = { createDashboardRoutes };@@@ -27,6 +27,7 @@ const { esc, raw } = require('../render/html');const marks = require('../views/marks');const { send } = require('../render/layout');const views = require('../views/exchange');++const platform = require('../views/platform');const credentialViews = require('../views/credentials');const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });@@@ -92,16 +93,19 @@ function createPageRoutes(ctx) {.send(JSON.stringify(doc));});++ r.get('/pricing', (req,res) => page(req,res,{title:'Pricing',body:raw('<div class="ex-wrap xp">'+platform.pricing()+'</div>')}));++ r.get('/docs', (req,res) => res.redirect(301,'/api'));++// ── Home ─────────────────────────────────────────────────r.get('/', async (req, res) => {-- const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);++ const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(7)]);const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';return page(req, res, {index: true, cache: PUBLIC_CACHE, agentLayer: true,title: null,description: 'Every AI model, one account: live prices, speed and uptime of every offer, from the families on sale today and more as their providers join. Chat, or call one OpenAI-compatible endpoint.',jsonLd: homeJsonLd(config),-- body: raw(views.home({ catalog, usage, viewer: req.viewer, q, config })),++ body: raw(platform.home({ catalog, usage, viewer: req.viewer, q, config })),});});@@@ -115,7 +119,7 @@ function createPageRoutes(ctx) {index: true, cache: PUBLIC_CACHE, agentLayer: true,title: q ? `Models: ${q}` : 'Models',description: 'Every model on OpenVibe.Exchange with its live offers: price in and out per million tokens, measured speed and uptime, context length and data policy.',-- body: raw(views.modelsPage({ catalog, q, family, sort, community })),++ body: raw(platform.modelsPage({ catalog, query: req.query })),});});@@@ -147,6 +151,8 @@ function createPageRoutes(ctx) {}, status);}++ r.get('/models/:id', (req, res) => { const parts = req.params.id.split('/'); req.params.org = parts.shift(); req.params.model = parts.join('/'); return renderModel(req, res); });++ r.get('/models/:org/:model', (req, res) => renderModel(req, res));r.get('/m/:org/:model', (req, res) => renderModel(req, res));r.post('/m/:org/:model/try', (req, res) => res.redirect(301,`https://openvibe.chat/vibe?models=${encodeURIComponent(`${req.params.org}/${req.params.model}`)}`));nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++const express = require('express');++const kit = require('openvibe-shared/kit');++const { esc, raw } = require('../render/html');++const { send } = require('../render/layout');++const { sameOrigin } = require('./principal');++function createSearchRoutes({ config, ai }) {++ const r = express.Router();++ r.use(express.urlencoded({ extended: false, limit: '8kb' }));++ const engines = [++ ['openvibe', 'OpenVibe Search', 'web'],++ ['searxng', 'SearXNG', 'web · requires an operator instance'],++ ['openverse', 'Openverse', 'images · CC licences'],++ ['commons', 'Wikimedia Commons', 'images · attribution'],++ ['iconify', 'Iconify', 'icons · SVG'],++ ];++ const render = (req, res, results = [], status = 200) =>++ send(res, status, {++ viewer: req.viewer,++ config,++ path: req.originalUrl,++ title: 'Search',++ body: raw(++ `<div class="ex-wrap xp">${kit.head({ title: 'One search API, many engines', sub: 'One key for models, web search, images and icons.' })}<div class="xp-reasons">${engines.map(([id, name, kind]) => kit.section({ title: name, body: `<p>${esc(kind)}</p><p>$0 per 1,000 · no account required</p><p>Freshness: index dependent · BYOK: not required</p>` })).join('')}</div><h2>Try two engines</h2><form method="post" action="/search"><label>Query<input name="q" required maxlength="500"></label><label>Kind<select name="kind"><option>web</option><option>images</option><option>icons</option></select></label>${['first', 'second'].map((k, i) => `<label>${i ? 'Second' : 'First'} engine<select name="${k}">${engines.map(([id, name]) => `<option value="${id}">${name}</option>`).join('')}</select></label>`).join('')}${kit.button({ label: 'Compare results', type: 'submit' })}</form><div class="xp-search-results">${results.map((x) => `<section><h3>${esc(x.engine)}</h3>${x.error ? `<p>${esc(x.error)}</p>` : `<p>$${Number(x.receipt.cost_usd)} · ${Number(x.receipt.time_ms)} ms</p><ol>${x.results.map((r) => `<li><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">${esc(r.title)}</a><p>${esc(r.snippet || r.attribution || '')}</p></li>`).join('')}</ol>`}</section>`).join('')}</div><p>Image search powered by Openverse and Wikimedia Commons. Keep the returned attribution with each image.</p><p>Paid engines and BYOK providers will become available after their owner accounts and prices are configured.</p></div>`,++ ),++ });++ r.get('/search', (req, res) => render(req, res));++ r.post('/search', async (req, res) => {++ if (req.viewer?.kind !== 'user')++ return res.redirect(303, '/auth/login?next=%2Fsearch');++ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);++ const kind = ['web', 'images', 'icons'].includes(req.body.kind)++ ? req.body.kind++ : 'web';++ const results = await Promise.all(++ [req.body.first, req.body.second].map(async (engine) => {++ try {++ return {++ engine,++ ...(await ai.meRequest(++ req.viewer.token,++ '/v1/search' + (kind === 'web' ? '' : '/' + kind),++ {++ method: 'POST',++ body: { q: req.body.q, engine, limit: 6 },++ },++ )),++ };++ } catch {++ return {++ engine,++ error: 'This engine is unavailable or does not support this search kind.',++ };++ }++ }),++ );++ render(req, res, results);++ });++ return r;++}++module.exports = { createSearchRoutes };@@@ -22,19 +22,11 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');// "OpenVibe..."). Icons are the navbar's own glyphs; the brand is the way home.const NAV = [{ label: 'Models', href: '/models', icon: 'fa-microchip' },-- { label: 'Chat', href: 'https://openvibe.chat/vibe', icon: 'fa-comments' },-- { label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },-- { label: 'Rankings', href: '/rankings', icon: 'fa-chart-line' },-- { label: 'Start building', href: '/start', icon: 'fa-bolt' },-- { label: 'API', href: '/api', icon: 'fa-code' },-- { label: 'Your keys and accounts', href: '/privacy/keys', icon: 'fa-key', more: true },-- { label: 'Why this model', href: '/why', icon: 'fa-diagram-project', more: true },-- { label: 'Your own endpoint', href: '/own', icon: 'fa-server', more: true },-- { label: 'Sell compute', href: '/sell', icon: 'fa-microchip', more: true },-- { label: 'Uncensored (18+)', href: '/uncensored', icon: 'fa-lock', more: true },-- { label: 'AI in OpenVibe', href: '/ai', icon: 'fa-circle-nodes', more: true },-- { label: 'Questions', href: '/faq', icon: 'fa-circle-info', more: true },-- { label: 'Status', href: '/status', icon: 'fa-gauge', more: true },++ { label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },++ { label: 'Pricing', href: '/pricing', icon: 'fa-tag' },++ { label: 'Docs', href: '/docs', icon: 'fa-code' },++ { label: 'Status', href: '/status', icon: 'fa-gauge' },++ { label: 'Rankings', href: 'https://openvibe.report/ai', icon: 'fa-chart-line', more: true },];const hashes = new Map();@@@ -66,6 +58,7 @@ function renderPage(o) {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) })),++ ...(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 },@@@ -103,6 +96,7 @@ ${appIcon.headTags({ site: 'exchange' })}<link rel="stylesheet" href="${ovServe.url('room.css')}"><link rel="stylesheet" href="${ovServe.url('showcase.css')}"><link rel="stylesheet" href="${asset('css/exchange.css')}">++<link rel="stylesheet" href="${asset('css/platform.css')}"><script src="${ovServe.url('motion.js')}" defer></script><script src="${ovServe.url('kit-web.js')}" defer></script><script src="${asset('js/exchange.js')}" defer></script>@@@ -3,7 +3,7 @@const { esc } = require('../render/html');const SECTIONS = [-- ['Overview', '/console'], ['Providers', '/console/providers'], ['Models', '/console/models'],++ ['Overview', '/console'], ['GPU supply (dry run)', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],['Routes', '/console/routes'], ['Templates', '/console/templates'], ['Workflows', '/console/workflows'],['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],@@@ -21,6 +21,8 @@ function consolePage({ section = 'overview', data = {}, error = null }) {const nav = SECTIONS.map(([label, href]) => `<a class="ex-console-tab${active === href ? ' is-active' : ''}" href="${href}">${label}</a>`).join('');const json = esc(JSON.stringify(safe(data), null, 2));let actions = '';++ if (section === 'supply') actions = `<p>Dry run only. No GPU is rented or stopped. Missing measurements are shown as missing.</p><form method="post" action="/console/supply" class="ovk-form"><label>Model<input name="model" required></label><label>Runtime<select name="runtime"><option value="vllm">vLLM</option><option value="sglang">SGLang</option></select></label>${[['input_per_million','Input USD / million'],['output_per_million','Output USD / million'],['hourly_ceiling_usd','Hourly spend ceiling USD'],['monthly_ceiling_usd','Monthly spend ceiling USD'],['required_vram_mb','Required VRAM MB'],['idle_minutes','Stop after idle minutes']].map(([key,label])=>`<label>${label}<input name="${key}" type="number" min="0" step="any" required></label>`).join('')}<button class="ovk-button" type="submit">Save prices and ceilings</button></form>`;++if (section === 'providers' && Array.isArray(data.providers)) actions = data.providers.map((p) => {const key = encodeURIComponent(p.key || '');const next = p.status === 'disabled' ? 'active' : 'disabled';nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++/**++ * The Dashboard (Opus's design, 2026-11-09): one page per tab, server-rendered, with the kit's tab rail. Keys are cards++ * with their prefix, cap and models; a new key's secret shows once on its own page; Usage starts with the totals and++ * a day-by-day bar, then one two-line row per day, key and model (names, not ids), with the CSV beside the title.++ * Credits, Deployments and Settings each say in one line what lives there and link to it.++ */++const kit = require('openvibe-shared/kit');++const { esc } = require('../render/html');++++const TABS = [['keys', 'Keys'], ['usage', 'Usage'], ['credits', 'Credits'], ['deployments', 'Deployments'], ['settings', 'Settings']];++const money = (v) => {++ const n = Number(v) || 0;++ if (n === 0) return '$0';++ if (n < 0.01) return `$${Number(n.toPrecision(2))}`;++ return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;++};++const count = (v) => {++ const n = Number(v) || 0;++ if (n >= 1e9) return `${(n / 1e9).toFixed(n >= 1e10 ? 0 : 1)}B`;++ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;++ if (n >= 1e3) return `${Number((n / 1e3).toFixed(n >= 1e4 ? 0 : 1))}k`;++ return n.toLocaleString('en-US');++};++const day = (iso) => {++ const d = new Date(`${String(iso).slice(0, 10)}T12:00:00Z`);++ return Number.isNaN(d.getTime()) ? String(iso || '') : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });++};++const plural = (n, one, many = `${one}s`) => `${Number(n).toLocaleString('en-US')} ${Number(n) === 1 ? one : many}`;++++/** The frame every tab shares: the page head, the tab rail, the tab's body. */++function shell(tab, body) {++ return `<div class="ex-wrap xd">${kit.head({ title: 'Dashboard', sub: 'Keys, usage and endpoints for the Exchange API' })}${kit.tabs({++ label: 'Dashboard', active: tab, items: TABS.map(([key, label]) => ({ key, label, href: `/dashboard/${key}` })),++ })}<div class="xd-body">${body}</div></div>`;++}++++function keyCard(k) {++ const models = Array.isArray(k.allowed_models) && k.allowed_models.length ? plural(k.allowed_models.length, 'model') : 'every model';++ const made = k.created_at ? `made ${day(k.created_at)}` : '';++ const revoked = !!k.revoked_at;++ return `<li class="xd-key${revoked ? ' is-revoked' : ''}"><div class="xd-key-main"><strong>${esc(k.name)}</strong><code>${esc(k.prefix)}…</code></div>`++ + `<p class="xd-key-meta">${esc([`${money(k.monthly_cap_usd)} a month`, models, revoked ? `revoked ${day(k.revoked_at)}` : made].filter(Boolean).join(' · '))}</p>`++ + (revoked ? '' : `<form method="post" action="/dashboard/keys/${encodeURIComponent(k.id)}/revoke" class="xd-key-act">${kit.button({ label: 'Revoke', type: 'submit', kind: 'danger', small: true })}</form>`)++ + '</li>';++}++++function keysPage(keys = []) {++ const live = keys.filter((k) => !k.revoked_at);++ const list = keys.length++ ? `<ul class="xd-keys">${keys.map(keyCard).join('')}</ul>`++ : kit.empty({ title: 'No keys yet', text: 'Make one below. Its secret shows once, when you make it.' });++ return shell('keys', `<section class="xd-card"><h2>Your keys${live.length ? `<span class="ovk-count">${live.length}</span>` : ''}</h2>${list}</section>`++ + `<section class="xd-card" id="new"><h2>New key</h2><p class="xd-note">The secret shows once, when you make it. Calls stop when the month's spend reaches the cap.</p>`++ + '<form class="xd-form" method="post" action="/dashboard/keys">'++ + '<label><span>Name</span><input name="name" required maxlength="80" placeholder="My application" autocomplete="off"></label>'++ + '<label><span>Monthly cap</span><span class="xd-money"><span aria-hidden="true">$</span><input name="monthly_cap_usd" type="number" min="1" step="0.01" value="5" required inputmode="decimal" aria-describedby="cap-hint"></span><small id="cap-hint">Each call holds up to $1 (or its max_cost_usd) until it finishes; the unused part comes back.</small></label>'++ + '<label><span>Models it may call</span><input name="allowed_models" placeholder="Every model" autocomplete="off" aria-describedby="models-hint"><small id="models-hint">Model ids, comma separated, e.g. deepseek/deepseek-chat. Empty allows every model.</small></label>'++ + `${kit.button({ label: 'Make the key', type: 'submit' })}</form></section>`);++}++++function newKeyPage(secret, key = null) {++ return shell('keys', `<section class="xd-card xd-secret"><h2>Copy your key now</h2><p class="xd-note">${key && key.name ? `<strong>${esc(key.name)}</strong>: ` : ''}this is the only time it shows. Keep it in your app's secrets; anyone with it can spend up to its cap.</p>`++ + `${kit.snippet({ code: secret, id: 'new-key' })}<p class="xd-actions">${kit.button({ href: '/dashboard/keys', label: 'Done' })}</p></section>`);++}++++/** Usage rows from the API (by day, key, model and status) with key names; the newest day first. */++function usagePage(rows = [], keys = []) {++ const names = new Map(keys.map((k) => [k.id, k.name]));++ const totals = rows.reduce((t, r) => ({++ cost: t.cost + (Number(r.cost_usd) || 0), requests: t.requests + (Number(r.requests) || 0),++ tokens: t.tokens + (Number(r.tokens_in) || 0) + (Number(r.tokens_out) || 0),++ }), { cost: 0, requests: 0, tokens: 0 });++ const days = [...new Set(rows.map((r) => String(r.day).slice(0, 10)))].sort();++ const head = `<div class="xd-title"><h2>Usage</h2>${rows.length ? `<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/dashboard/usage.csv" download><span>CSV</span></a>` : ''}</div>`;++ if (!rows.length) return shell('usage', `<section class="xd-card">${head}${kit.empty({ title: 'No usage yet', text: 'Calls made with your keys show here by day, key and model, with their tokens and cost.' })}</section>`);++ const span = days.length > 1 ? `${day(days[0])} – ${day(days[days.length - 1])}` : day(days[0]);++ const stat = (v, label) => `<div><dd>${esc(v)}</dd><dt>${esc(label)}</dt></div>`;++ // Requests per day, the last 14 days that have any: bars scaled to the busiest one (the rows below hold the numbers).++ const shown = days.slice(-14);++ const perDay = shown.map((d) => [d, rows.filter((r) => String(r.day).slice(0, 10) === d).reduce((n, r) => n + (Number(r.requests) || 0), 0)]);++ const peak = Math.max(1, ...perDay.map(([, n]) => n));++ const bars = `<div class="xd-bars" aria-hidden="true">${perDay.map(([d, n]) => `<span title="${esc(`${day(d)}: ${plural(n, 'request')}`)}"><i style="height:${Math.max(4, Math.round((n / peak) * 100))}%"></i><b>${esc(day(d).replace(/^[A-Za-z]+ /, ''))}</b></span>`).join('')}</div>`;++ const byDay = [...days].reverse().map((d) => {++ const items = rows.filter((r) => String(r.day).slice(0, 10) === d)++ .sort((a, b) => (Number(b.cost_usd) || 0) - (Number(a.cost_usd) || 0))++ .map((r) => {++ const key = names.get(r.key_id) || r.key_id;++ const state = r.status && r.status !== 'settled' ? `<span class="xd-chip">${esc(r.status)}</span>` : '';++ return `<li><div class="xd-use-top"><code>${esc(r.model)}</code><strong>${esc(money(r.cost_usd))}</strong></div>`++ + `<p>${esc([key, plural(r.requests, 'request'), `${count(r.tokens_in)} in · ${count(r.tokens_out)} out`].join(' · '))}${state}</p></li>`;++ }).join('');++ return `<h3 class="xd-day">${esc(day(d))}</h3><ul class="xd-use">${items}</ul>`;++ }).join('');++ return shell('usage', `<section class="xd-card">${head}<p class="xd-note">${esc(span)}</p>`++ + `<dl class="xd-stats">${stat(money(totals.cost), 'spent')}${stat(count(totals.requests), 'requests')}${stat(count(totals.tokens), 'tokens')}</dl>`++ + `${bars}</section><section class="xd-card"><h2 class="xd-sub">By day, key and model</h2>${byDay}</section>`);++}++++function creditsPage() {++ return shell('credits', '<section class="xd-card"><h2>Credits</h2><p class="xd-note">Credits pay for managed models. Your balance, top-ups and auto top-up live in your OpenVibe wallet, the same one on every OpenVibe site.</p>'++ + `<p class="xd-actions">${kit.button({ href: 'https://openvibe.network/my#wallet', label: 'Open your wallet' })}</p>`++ + '<p class="xd-note">With your own provider key, calls cost the provider\'s price and nothing more: 0% fee, no cap.</p></section>');++}++++function deploymentsPage(endpoints = []) {++ const rows = endpoints.map((e) => `<li><div class="xd-use-top"><code>${esc(e.model || '')}</code><span class="xd-chip">${esc(e.status || '')}</span></div><p>${esc(e.id || '')}${e.price_per_hour != null ? ` · ${esc(money(e.price_per_hour))} an hour` : ''}</p></li>`).join('');++ return shell('deployments', '<section class="xd-card"><h2>Deployments</h2><p class="xd-note">Your own endpoint: a model on a GPU of its own, private to you. The hourly price shows before it starts, and it stops when idle.</p>'++ + `<p class="xd-actions">${kit.button({ href: '/own', label: 'Price an endpoint' })}</p>`++ + (rows ? `<ul class="xd-use">${rows}</ul>` : kit.empty({ title: 'No deployments yet', text: 'An endpoint you start shows here with its status and price.' }))++ + '</section>');++}++++function settingsPage() {++ const item = (href, title, sub) => kit.row({ href, title, sub, trail: kit.icon('chevron') });++ return shell('settings', `<section class="xd-card"><h2>Settings</h2>${kit.list([++ item('/privacy/keys', 'Your provider keys', 'Bring your own key at a 0% fee, and choose the data policy for your calls.'),++ item('/api#privacy', 'Routing and strict privacy', 'Prefer the cheapest or the fastest route; keep prompts out of every log.'),++ item('/uncensored', 'Adult models (18+)', 'Opt in to uncensored models. The content floor applies on every request.'),++ ], { flush: true })}</section>`);++}++++function unavailablePage() {++ return shell('', '<section class="xd-card">' + kit.empty({ title: 'The account API did not answer', text: 'Nothing was changed. Try again in a moment.' }) + '</section>');++}++++module.exports = { keysPage, newKeyPage, usagePage, creditsPage, deploymentsPage, settingsPage, unavailablePage, TABS };@@@ -318,7 +318,9 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad{ key: 'provider', label: 'Offer', primary: true, html: true },{ key: 'price', label: 'In / out per M', html: true },{ key: 'speed', label: 'Speed', html: true },++ { key: 'first', label: 'First token' },{ key: 'uptime', label: 'Uptime' },++ { key: 'errors', label: 'Errors' },{ key: 'privacy', label: 'Privacy', html: true },{ key: 'policy', label: 'Data policy', html: true },{ key: 'try', label: 'Try', html: true },@@@ -333,9 +335,11 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', adconst refuses = refusalText(o);return {provider: `<a class="ex-offer-name" href="#try">${esc(providerLabel(o))}</a> ${communityBadge}${freeTier(o) ? ` ${kit.badge('Free to try')}` : (freePaused(o) ? ` ${kit.badge('Free paused')}` : '')}`,-- price: o.price ? `<b>${money(o.price.in)}</b> / ${money(o.price.out)}` : '<span class="ex-muted">free capacity</span>',++ price: o.price ? `<b>${money(o.price.in)}</b> / ${money(o.price.out)}${o.fixed_price ? ` ${kit.badge('Fixed price')}` : ''}` : '<span class="ex-muted">free capacity</span>',speed: `${ms(o.speed.p50_ms)} <small>p50</small> · ${ms(o.speed.p95_ms)} <small>p95</small>`,++ first: o.speed && o.speed.ttft_ms != null ? ms(o.speed.ttft_ms) : '–',uptime: o.uptime == null ? '–' : pct(o.uptime),++ errors: o.error_rate == null ? '–' : pct(o.error_rate),privacy: `${p ? kit.badge(p.label, p.kind) : ''}${refuses ? `<br><small>${esc(refuses)}</small>` : ''}` || '–',policy: policyText(o.data_policy),try: `<a class="ex-mini" href="${chatModel(model)}">Try it</a>`,@@@ -352,14 +356,27 @@ ${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.' }) })}++${activityPanel(model.activity)}${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: 'Try it', id: 'try', body: kit.button({ href: chatModel(model), label: `Chat with ${model.name}` }) })}++${panel({ title: 'Try it', id: 'try', body: `<p class="ex-actions">${kit.button({ href: chatModel(model), label: `Chat with ${model.name}` })} ${kit.button({ href: `https://openvibe.report/ai/models/${encodeURIComponent(model.slug || model.canonical)}`, label: 'How it ranks', kind: 'quiet' })}</p>` })}${panel({ title: 'Use it from code', id: 'code', body: `${kit.snippet({ code, id: 'model-curl' })}${kit.snippet({ code: sdk, id: 'model-sdk' })}<p class="ex-more">The endpoint, keys and usage: <a href="/api">the API</a>.</p>` })}</div>`;}++/** Requests a day across the model's offers, the last 14 days that have any: bars, with the numbers as their labels. */++function activityPanel(activity) {++ const days = Array.isArray(activity) ? activity.slice(-14) : [];++ if (!days.length) return '';++ const peak = Math.max(1, ...days.map((d) => Number(d.requests) || 0));++ const bars = days.map((d) => {++ const n = Number(d.requests) || 0;++ return `<li><span class="ex-act-bar" style="height:${Math.max(4, Math.round((n / peak) * 100))}%"></span><span class="ex-act-day">${esc(String(d.day).slice(8, 10))}</span><span class="ex-sr">${esc(String(d.day))}: ${n.toLocaleString('en-US')} requests</span></li>`;++ }).join('');++ return panel({ title: 'Activity', note: 'Requests a day across every offer.', id: 'activity', body: `<ol class="ex-act">${bars}</ol>` });++}++const PRESETS = [['openvibe/economy', 'Cheapest', 'The lowest cost per successful answer.'],['openvibe/fast', 'Fastest', 'The quickest measured offer.'],nnew file mode 100644@@@ -0,0 +1,129 @@++'use strict';++const kit = require('openvibe-shared/kit');++const legacy = require('./exchange');++const listed = (catalog) => legacy.chatModels(catalog);++const unavailable = (catalog) =>++ catalog.unavailable++ ? '<p role="status">The live catalogue is not reachable right now.</p>'++ : '';++const { esc } = require('../render/html');++const free = (o) =>++ o.free_now === undefined ? o.free_tier === true : o.free_now === true;++const paused = (o) => o.free_tier === true && o.free_now === false;++const id = (m) => m.slug || m.canonical;++const price = (v) =>++ v == null++ ? 'Not priced'++ : `$${Number(v).toLocaleString('en-US', { maximumFractionDigits: 4 })}`;++const metric = (v, unit = '') =>++ v == null++ ? 'Not measured'++ : `${Number(v).toLocaleString('en-US', { maximumFractionDigits: 2 })}${unit}`;++const best = (m) =>++ [...(m.offers || [])]++ .filter((o) => o.price)++ .sort(++ (a, b) => a.price.in + a.price.out - b.price.in - b.price.out,++ )[0] ||++ (m.offers || [])[0] ||++ {};++const chat = (m) =>++ `https://openvibe.chat/vibe?models=${encodeURIComponent(id(m))}`;++const rank = (m) =>++ `https://openvibe.report/ai/models/${encodeURIComponent(id(m))}`;++const section = (title, body) => kit.section({ title, body });++const code = (m) =>++ kit.snippet({++ id: 'platform-curl',++ code: `curl https://api.openvibe.exchange/v1/chat/completions \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '${JSON.stringify({ model: m || 'openvibe/auto', messages: [{ role: 'user', content: 'Hello' }] })}'`,++ });++function table(models) {++ return `<div class="xp-models"><table><thead><tr><th>Model</th><th>Input / output per million</th><th>Tokens/s</th><th>Uptime</th><th>Explore</th></tr></thead><tbody>${models++ .map((m) => {++ const o = best(m);++ return `<tr><td><a href="/models/${encodeURIComponent(id(m))}"><strong>${esc(m.name)}</strong></a><small>${esc(legacy.familyLabel(m))}${(m.offers || []).some(free) ? ' · Free to try' : (m.offers || []).some(paused) ? ' · Free paused' : ''}${m.uncensored ? ' · 18+' : ''}</small></td><td data-label="Input / output per million">${price(o.price?.in)} / ${price(o.price?.out)}${o.fixed_price ? ' · Fixed' : ''}</td><td data-label="Tokens/s">${metric(o.speed?.tokens_per_second)}</td><td data-label="Uptime">${metric(o.uptime == null ? null : o.uptime * 100, '%')}</td><td><a href="${chat(m)}">Try in Chat</a><a href="${rank(m)}">How it ranks →</a></td></tr>`;++ })++ .join(++ '',++ )}</tbody></table>${!models.length ? '<p>No models match these filters.</p>' : ''}</div>`;++}++function pricing() {++ return section(++ 'Pricing',++ `<div class="xp-models"><table><thead><tr><th>Route</th><th>What you pay</th></tr></thead><tbody><tr><td>Your provider key</td><td>Provider price + 0% fee, forever, no cap</td></tr><tr><td>Managed models</td><td>The input and output rates listed on each model</td></tr><tr><td>Our GPUs</td><td>Fixed input and output prices set by the owner</td></tr><tr><td>Your deployment</td><td>The quoted hourly price, shown before starting</td></tr><tr><td>Search</td><td>Engine price per 1,000 searches; free engines first</td></tr></tbody></table></div><p>Paid models open soon. Every price shows before you call, and nothing is charged until then.</p>`,++ );++}++function home({ catalog, usage, viewer }) {++ const models = listed(catalog);++ return `<div class="ex-wrap xp">${unavailable(catalog)}<section class="xp-hero"><p class="ex-kicker">OpenVibe.Exchange</p><h1>One API for every model.<br>Pay what the model costs.</h1>${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model', icon: 'search' })}<p>${kit.button({ href: viewer?.kind === 'user' ? '/dashboard/keys#new' : '/auth/login?next=%2Fdashboard%2Fkeys%23new', label: 'Get a key' })} ${kit.button({ href: '/docs', label: 'Read the docs', kind: 'quiet' })}</p>${code()}</section>${kit.stats(++ [++ [models.length, 'models'],++ [++ metric(++ usage?.totals++ ? Number(usage.totals.tokens_in || 0) +++ Number(usage.totals.tokens_out || 0)++ : null,++ ),++ 'tokens routed this week',++ ],++ [++ metric(catalog.median_ttft_ms, ' ms'),++ 'median time to first token',++ ],++ ],++ )}${section('Popular models', table([...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 10)))}<div class="xp-reasons">${section('0% fee on your own key', '<p>Bring your provider key and pay the provider\'s price, nothing more. No cap, no end date.</p>')}${section('The cheapest route', '<p>Every call goes to the cheapest provider that is up. Open models on OpenVibe\'s own GPUs, at a fixed price per million tokens, are coming next.</p>')}${section('Uncensored, 18+', '<p>Uncensored models for adults who opt in. The content floor applies on every request.</p><a href="/uncensored">What the floor covers</a>')}</div>${pricing()}${section('Questions', '<details><summary>Can I use my existing client?</summary><p>Yes. Use the OpenAI-compatible endpoint with your Exchange key.</p></details><details><summary>Where can I talk to a model?</summary><p><a href="https://openvibe.chat/vibe">Open Chat</a> to talk or compare models.</p></details><details><summary>Where do the numbers come from?</summary><p>Prices come from the live catalogue. Speed and reliability come from measured requests. Missing measurements stay unknown.</p></details>')}</div>`;++}++function modelsPage({ catalog, query = {} }) {++ const all = listed(catalog);++ let rows = all;++ if (query.family && !query.maker) query = { ...query, maker: query.family };++ const tests = {++ maker: (m, v) => m.family === v,++ weights: (m) => m.open_weights === true,++ price: (m, v) =>++ v === 'free'++ ? best(m).price?.in === 0 && best(m).price?.out === 0++ : best(m).price && best(m).price.in + best(m).price.out <= 2,++ context: (m) => m.context >= 128000,++ uncensored: (m) => m.uncensored,++ gpu: (m) => (m.offers || []).some((o) => o.on_our_gpus === true),++ };++ const chip = (label, key, value, count) => {++ const q = new URLSearchParams(++ Object.entries(query).filter(([, v]) => typeof v === 'string'),++ );++ if (q.get(key) === value) q.delete(key);++ else q.set(key, value);++ return `<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(q.toString())}">${esc(label)} (${count})</a>`;++ };++ for (const [key, test] of Object.entries(tests))++ if (query[key]) rows = rows.filter((m) => test(m, query[key]));++ if (query.q)++ rows = rows.filter((m) =>++ `${m.name} ${id(m)} ${m.family}`++ .toLowerCase()++ .includes(String(query.q).toLowerCase()),++ );++ const chips = [...new Set(all.map((m) => m.family).filter(Boolean))].map(++ (v) => chip(v, 'maker', v, all.filter((m) => tests.maker(m, v)).length),++ );++ for (const [key, label, value] of [++ ['weights', 'Open weights', 'open'],++ ['price', 'Free', 'free'],++ ['price', 'Under $1/M blended', 'low'],++ ['context', '128K+ context', '128k'],++ ['uncensored', 'Uncensored 18+', '1'],++ ['gpu', 'On our GPUs', '1'],++ ])++ chips.push(++ chip(++ label,++ key,++ value,++ all.filter((m) => tests[key](m, value)).length,++ ),++ );++ return `<div class="ex-wrap xp">${kit.head({ title: 'Models', sub: `${rows.length} models · live prices and measured performance` })}${unavailable(catalog)}${kit.find({ action: '/models', name: 'q', value: query.q || '', placeholder: 'Find a model', icon: 'search' })}<nav class="ex-chips" aria-label="Model filters">${chips.join('')}</nav>${table(rows)}</div>`;++}++module.exports = { home, modelsPage, pricing, table };@@@ -20,6 +20,17 @@ const { classify } = require('../server/arena/category');});const bench = createFakeBench();const t = await boot({ ai, bench });++ // Arena HTML now lives on Report; exercise the same private read through Exchange's JSON API.++ const get = t.get;++ t.get = async (path, options = {}) => {++ if (/^\/arena(?:\/|$)/.test(path) && !options.form && !options.json && !options.method) {++ const response = await get(path.replace(/^\/arena/, '/api/v1/arena/rounds'), options);++ try { response.text = response.json().html || response.text; } catch {}++ return response;++ }++ return get(path, options);++ };++const alice = t.network.addUser('alice');const bob = t.network.addUser('bob');@@@ -69,7 +80,7 @@ const { classify } = require('../server/arena/category');assert.ok(!r.text.includes('Qwen3 32B'), 'no model name before the vote');assert.ok(!r.text.includes('Receipt'), 'no receipt before the vote');assert.ok(r.text.includes('A is better') && r.text.includes('Both bad'));-- assert.ok(r.text.includes('code'), 'the task category is said');++ assert.match(r.text, /Code|code/, 'the task category is said');});await check('a vote is the person’s own Bench judgement, once', async () => {@@@ -10,8 +10,8 @@ assert.ok(inBar.length <= 6, `at most six places in the bar, not ${inBar.length}assert.ok(NAV.every((l) => /^fa-[a-z0-9-]+$/.test(l.icon || '')), 'every place has an icon');assert.ok(!NAV.some((l) => l.href === '/'), 'the brand is the way home');assert.deepStrictEqual(new Set(NAV.map((l) => l.href)).size, NAV.length, 'no place twice');--for (const want of ['/models', 'https://openvibe.chat/vibe', '/arena', '/start', '/api', '/why', '/own', '/sell', '/uncensored', '/ai', '/faq', '/status']) {++for (const want of ['/models', '/search', '/pricing', '/docs', '/status', 'https://openvibe.report/ai']) {assert.ok(NAV.some((l) => l.href === want), `${want} is reachable from the bar or More`);}--if (Array.isArray(PAGES)) for (const l of NAV) assert.ok(PAGES.some((p) => p.path === l.href) || ['https://openvibe.chat/vibe', '/status', '/arena', '/rankings'].includes(l.href), `${l.href} is a page`);++assert.ok(!NAV.some(l => ['/chat','/arena','/sell','/ai'].includes(l.href)), 'moved products leave the navigation');console.log('nav: six places in the bar with icons, the rest under More, every page reachable');@@@ -12,37 +12,20 @@ const { createFakeAi, downAi } = require('./helpers/ai');const ai = createFakeAi();const t = await boot({ ai });try {-- await check('the home is the new Exchange, with live numbers', async () => {-- const r = await t.get('/');-- assert.strictEqual(r.status, 200, r.text);-- assert.match(r.headers.get('cache-control'), /^public, max-age=60/);-- assert.ok(r.text.includes('Every AI model, one account.'), 'the new promise');-- assert.ok(!r.text.includes('Trade what people make'), 'v1’s promise is gone');-- assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Llama 3 8B abliterated'));-- assert.ok(r.text.includes('Popular now') && r.text.includes('Uncensored (18+)') && r.text.includes('Run on people’s machines'));-- assert.ok(r.text.includes('models') && r.text.includes('providers') && r.text.includes('cheapest in, per million'), 'the live numbers');-- assert.ok(r.text.includes('messages free every day'), 'the free allowance');-- assert.ok(!r.text.includes('requests today'), 'fewer than 1,000 requests today is not shown');-- assert.ok(r.text.includes('A side-by-side answer') && r.text.includes('example words · live prices'), 'the preview is marked an example');-- assert.ok(r.text.includes('api.openvibe.exchange/v1/chat/completions'), 'the endpoint');-- const lead = (r.text.match(/<p class="ex-lead">([\s\S]*?)<\/p>/) || [])[1] || '';-- assert.ok(/DeepSeek/.test(lead) && /Qwen/.test(lead) && /Llama/.test(lead), 'the hero names the families on sale: ' + lead);-- assert.ok(!/Kimi|GLM|MiniMax/.test(lead), 'the hero never names a family nobody sells: ' + lead);-- const answers = [...r.text.matchAll(/<article class="ex-preview-cell">[\s\S]*?<p>([\s\S]*?)<\/p>/g)].map((m) => m[1]);-- assert.strictEqual(answers.length, 2, 'two preview answers');-- assert.notStrictEqual(answers[0], answers[1], 'the two example answers differ');-- const { displayName } = require('../server/ai.js');-- assert.deepStrictEqual(['gpt-5-nano', 'gpt-4.1-mini', 'gpt-4o-mini', 'gpt-5', 'gpt-oss-120b', 'DeepSeek V4 Pro'].map(displayName), ['GPT-5 nano', 'GPT-4.1 mini', 'GPT-4o mini', 'GPT-5', 'gpt-oss-120b', 'DeepSeek V4 Pro'], 'OpenAI models are named the way OpenAI says them');-- assert.ok(!/ · – · –|– · –/.test(r.text), 'an unmeasured stat is left out, not printed as a dash');-- assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');-- assert.ok(r.text.includes('<main id="main"') && r.text.includes('<footer id="ov-footer"'));++ await check('home puts a key and live model prices first', async () => {++ const r = await t.get('/'); assert.strictEqual(r.status,200);++ assert.ok(r.text.includes('One API for every model.') && r.text.includes('Pay what the model costs.'));++ assert.ok(r.text.includes('Get a key') && r.text.includes('api.openvibe.exchange/v1/chat/completions'));++ assert.ok(r.text.includes('tokens routed this week') && r.text.includes('median time to first token'));++ assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Try in Chat') && r.text.includes('How it ranks'));++ assert.strictEqual((r.text.match(/<h1[\s>]/g)||[]).length,1);});await check('models lists every model with its cheapest offer', async () => {const r = await t.get('/models');assert.strictEqual(r.status, 200);assert.ok(r.text.includes('DeepSeek Chat'));-- assert.ok(r.text.includes('$0.20 in'), 'the cheapest offer, not the dearest');++ assert.ok(r.text.includes('$0.2 / $0.3'), 'the cheapest offer, not the dearest');const filtered = await t.get('/models?q=llama');assert.ok(filtered.text.includes('Llama 3 8B abliterated'));assert.ok(!filtered.text.includes('DeepSeek Chat'), 'the search filters');@@@ -51,6 +34,17 @@ const { createFakeAi, downAi } = require('./helpers/ai');assert.ok(!family.text.includes('Llama 3 8B'));});++ await check('a model at /models/…: the same page, with first token, errors and How it ranks', async () => {++ const r = await t.get('/models/deepseek%2Fdeepseek-chat');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('DeepSeek') && r.text.includes('DeepInfra'));++ assert.ok(r.text.includes('First token') && r.text.includes('Errors'), 'the providers tab columns');++ assert.ok(r.text.includes('openvibe.chat/vibe?models=deepseek%2Fdeepseek-chat'));++ assert.ok(r.text.includes('openvibe.report/ai/models/deepseek%2Fdeepseek-chat'), 'How it ranks');++ assert.ok(r.text.includes('api.openvibe.exchange'));++ assert.strictEqual((await t.get('/models/nope%2Fnope')).status, 404);++ });++await check('one model: every offer, its price, speed, uptime, data policy, Try it, curl and SDK', async () => {const r = await t.get('/m/deepseek/deepseek-chat');assert.strictEqual(r.status, 200, r.text);@@@ -78,17 +72,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');assert.ok(r.text.includes('2026-11-05') && r.text.includes('Requests'), 'usage by day');});-- await check('sell: the offer flow, and what is not sold, said plainly', async () => {-- const r = await t.get('/sell');-- assert.strictEqual(r.status, 200);-- assert.ok(r.text.includes('Offer to the network'));-- assert.ok(r.text.includes('Personal subscription plans'));-- assert.ok(r.text.includes('Personal API keys'));-- assert.ok(r.text.includes('What is sold'));-- assert.ok(r.text.includes('Owner’s machine') || r.text.includes('No community capacity yet'));-- assert.ok(r.text.includes('Idle-only') && r.text.includes('Kudos') && r.text.includes('sees the prompt'), 'idle-only, kudos and the privacy sentence');-- assert.ok(r.text.includes('A community machine') && !r.text.includes('@name'), 'the seller card names the machine, never a placeholder or a person');-- assert.ok(r.text.includes('not verified yet'), 'an offer is said verified only when it is');++ await check('seller entry moves permanently to Link', async () => {++ const r = await t.get('/sell'); assert.strictEqual(r.status,301);++ assert.strictEqual(r.headers.get('location'),'https://openvibe.link');});await check('uncensored: sign-in first, then the 18+ gate and the floor', async () => {@@@ -102,12 +88,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');assert.ok(r.text.includes('Non-consensual sexual content (fiction included) and incest are refused, because payment providers and hosts ban them.'));});-- await check('AI in OpenVibe: which product runs which model, and the daily cost', async () => {-- const r = await t.get('/ai');-- assert.strictEqual(r.status, 200);-- assert.ok(r.text.includes('OpenVibe.Live'), 'the product’s full name, not “live”');-- assert.ok(r.text.includes('deepseek-chat'));-- assert.ok(r.text.includes('$0.0002'), 'the cost per day (0.006 over 30 days)');++ await check('product usage moves permanently to Report', async () => {++ const r = await t.get('/ai'); assert.strictEqual(r.status,301);++ assert.strictEqual(r.headers.get('location'),'https://openvibe.report/ai/exchange');});await check('Free to try is shown only where an offer says free_tier', async () => {@@@ -228,7 +211,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');const down = await boot({ ai: downAi });try {await check('AI unreachable: every page still renders and says the numbers are not live', async () => {-- for (const p of ['/', '/models', '/api', '/sell', '/ai', '/status']) {++ for (const p of ['/', '/models', '/api', '/status']) {const r = await down.get(p);assert.strictEqual(r.status, 200, p);assert.ok(!/NaN|undefined/.test(r.text.replace(/undefined"/g, '')), `${p} invents no number`);nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++const { createFakeAi } = require('./helpers/ai');++(async () => {++ const calls = [];++ const ai = {++ ...createFakeAi(),++ meRequest: async (token, path, options) => {++ calls.push({ path, options });++ return path.endsWith('key-usage')++ ? {++ rows: [++ {++ day: '2026-11-09',++ key_id: 'test',++ model: '=formula',++ requests: 1,++ tokens_in: 1,++ tokens_out: 2,++ cost_usd: 0,++ status: 'settled',++ },++ ],++ }++ : { keys: [] };++ },++ };++ const t = await boot({ ai });++ try {++ const user = t.network.addUser('builder');++ await check(++ 'dashboard needs sign in, private responses do not cache',++ async () => {++ assert.equal((await t.get('/dashboard')).status, 303);++ const r = await t.get('/dashboard/keys', { as: user });++ assert.equal(r.status, 200);++ assert.ok(r.headers.get('cache-control').includes('no-store'));++ assert.ok(r.text.includes('Monthly cap'));++ },++ );++ await check('foreign site cannot create keys', async () => {++ const n = calls.length;++ assert.equal(++ (++ await t.get('/dashboard/keys', {++ as: user,++ origin: 'https://evil.example',++ form: { name: 'Bad' },++ })++ ).status,++ 403,++ );++ assert.equal(calls.length, n);++ });++ await check('CSV escapes formula cells', async () => {++ const r = await t.get('/dashboard/usage.csv', { as: user });++ assert.equal(r.status, 200);++ assert.ok(r.text.includes("'=formula"));++ });++ await check(++ 'Arena moves permanently and its API refuses anonymous mutations',++ async () => {++ assert.equal((await t.get('/arena')).status, 301);++ assert.equal(++ (++ await t.get('/api/v1/arena/rounds', {++ json: { prompt: 'hello' },++ })++ ).status,++ 401,++ );++ },++ );++ } finally {++ await t.close();++ }++ done();++})().catch((e) => {++ console.error(e);++ process.exitCode = 1;++});@@@ -1,174 +1,40 @@'use strict';--/**-- * Pooled machines, part 4 (design 2026-11-06): "Pool your machines" on /sell and the pool's offer card. The page-- * reads the person's machines, pools, link checks and fit table from Network and AI (the mock Network here); it-- * measures nothing itself. Every number is one that was measured; the rest says "not measured yet".-- */--const assert = require('assert');--const { ids } = require('openvibe-contracts');++// Pool management moved to Link; its old page fixtures are retained in test/retired/pools.test.js.++const assert = require('node:assert/strict');const { boot, check, done } = require('./helpers/boot');const { createFakeAi } = require('./helpers/ai');----const dev = () => `dev_${ids.ulid()}`;--const poolOffer = (models) => ({-- offer_id: 'community_abc123', kind: 'pool', label: 'Community pool (3 machines)', models,-- pool: { machines: ['pseudonym-a', 'pseudonym-b', 'pseudonym-c'], head: 'pseudonym-a' },-- free_only: true, verified: false, hours: { start: '00:00', end: '00:00', tz: 'UTC' }, concurrency: 1,-- price: { in_per_million: 1, out_per_million: 2 }, audience: 'everyone', health: { status: 'up' },-- open_now: true, ready: true,--});--(async () => {-- const ai = createFakeAi({ community: [poolOffer(['llama-3-8b-abliterated'])] });-- const t = await boot({ ai });-- const user = t.network.addUser('alice');-- const desktop = dev(), laptop = dev(), rack = dev(), other = dev();-- const announce = (id, network, freeVram, hardware) => ({ id, free_vram: freeVram, free_ram: 32000, hardware, network });--++ const t = await boot({ ai: createFakeAi() });try {-- await check('signed out, /sell has no pool card (it needs a person’s machines)', async () => {-- const r = await t.get('/sell');-- assert.strictEqual(r.status, 200, r.text);-- assert.ok(!r.text.includes('Pool your machines'), 'no pool card when signed out');-- });---- await check('fewer than two machines: one line and the install link', async () => {-- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }];-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090')];-- t.network.state.pools = [];-- const r = await t.get('/sell', { as: user });-- assert.strictEqual(r.status, 200, r.text);-- assert.ok(r.text.includes('Pool your machines'), 'the card is there');-- assert.ok(r.text.includes('Pooling needs two or more of your machines on the same network. Add one with'), 'the one line');-- assert.ok(r.text.includes('https://openvibe.link'), 'the install link');-- assert.ok(!r.text.includes('1. Choose the machines'), 'no four steps with one machine');-- });---- await check('a machine on another network is greyed, with its reason', async () => {-- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'elsewhere', 8000, 'RTX 3060')];-- const r = await t.get('/sell', { as: user });-- assert.strictEqual(r.status, 200, r.text);-- assert.ok(r.text.includes('Not on the same network as Desktop: pools work on one home network, not across the internet'), r.text);-- assert.ok(/class="ex-pool-machine is-off"/.test(r.text), 'the machine is greyed, not hidden');-- assert.ok(r.text.includes('Desktop') && r.text.includes('Laptop'), 'both machines are listed');-- });---- await check('a failed link says the number and the limit; a good one says both numbers', async () => {-- t.network.state.machines = [-- { id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }, { id: rack, name: 'Rack', online: true },-- ];-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060'), announce(rack, 'home', 16000, 'RTX 4080')];-- t.network.state.pools = [{-- id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',-- machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.5 }, { id: laptop, approved: true, ready: true, vram_share: 0.17 }, { id: rack, approved: true, ready: true, vram_share: 0.33 }],-- links: [{ machine: laptop, rtt_ms: 2, mbps: 940 }, { machine: rack, rtt_ms: 40, mbps: 940 }],-- fit: [{ model: 'llama-3-8b-abliterated', fits: true, tokens_per_second: 9 }, { model: 'qwen3-32b', fits: true, tokens_per_second: null }],-- }];-- const r = await t.get('/sell', { as: user });-- assert.strictEqual(r.status, 200, r.text);-- assert.ok(r.text.includes('2 ms, 940 Mb/s: good'), 'the good link in words');-- assert.ok(r.text.includes('40 ms; a pool needs under 5 ms'), 'the failed link says the number and the limit');-- assert.ok(r.text.includes('about 9 tokens a second'), 'a measured speed');-- assert.ok(r.text.includes('not measured yet'), 'a model nobody measured says so');-- assert.ok(!/about 0 tokens a second/.test(r.text), 'an absent measurement is never a zero');-- assert.ok(r.text.includes('Head: <b>Desktop</b>'), 'the head is the machine with the most free VRAM');-- });---- await check('nothing is offered until every member approves', async () => {-- t.network.state.pools = [{-- id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',-- machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.75 }, { id: laptop, approved: false, ready: false, vram_share: 0.25 }],-- }];-- const waiting = await t.get('/sell', { as: user });-- assert.ok(waiting.text.includes('Waiting for approval on <b>Laptop</b>'), waiting.text);-- assert.ok(waiting.text.includes('Approved'), 'the machine that approved says so');-- assert.ok(waiting.text.includes('Nothing is offered until every member approves.'), 'nothing is offered yet');-- assert.ok(!waiting.text.includes('one price for the whole pool'), 'the offer controls stay closed');---- t.network.state.pools[0].machines = t.network.state.pools[0].machines.map((m) => ({ ...m, approved: true, ready: true }));-- const approved = await t.get('/sell', { as: user });-- assert.ok(approved.text.includes('one price for the whole pool'), 'the same controls as one machine, applied to the pool');-- assert.ok(approved.text.includes('everyone, your followers or a group'), 'who may use it');-- assert.ok(approved.text.includes('the whole pool pauses while any member is in use'), 'idle only');-- assert.ok(approved.text.includes('Prompts reach your head machine (Desktop); the others only see numbers in transit.'), 'the privacy sentence');-- assert.ok(!approved.text.includes('Nothing is offered until every member approves.'), 'it is offered now');-- });---- await check('the public card shows only the count, never a machine', async () => {-- t.network.state.machines = [{ id: desktop, name: 'Workstation Alpha', online: true }];-- const models = await t.get('/models');-- assert.strictEqual(models.status, 200, models.text);-- assert.ok(models.text.includes('Community pool (3 machines)'), 'the label');-- assert.ok(models.text.includes('Free tier only'), 'a new pool says it serves the free tier only');-- assert.ok(!models.text.includes('<span'), 'no badge markup escapes as text');-- assert.ok(!models.text.includes('Workstation Alpha'), 'no machine name in public');-- const model = await t.get('/m/meta-llama/llama-3-8b-abliterated');-- assert.ok(model.text.includes('Community pool (3 machines)'), 'the offer card carries the pool label');-- assert.ok(!model.text.includes('Workstation Alpha'), 'still no machine name');-- const owner = await t.get('/sell', { as: user });-- assert.ok(owner.text.includes('Workstation Alpha'), 'the owner sees the machine names');-- });---- await check('creating the pool goes through Network, and stopping it says what happens', async () => {-- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];-- t.network.state.pools = [];-- const made = await t.get('/sell/pool', {-- as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },-- body: `machines=${desktop}&machines=${laptop}&head=${desktop}`,-- });-- assert.strictEqual(made.status, 303, made.text);-- assert.strictEqual(t.network.state.poolProposals.length, 1);-- assert.deepStrictEqual(t.network.state.poolProposals[0], { machines: [desktop, laptop], head: desktop });---- const one = await t.get('/sell/pool', {-- as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `machines=${desktop}`,-- });-- assert.strictEqual(one.status, 200, 'one machine is refused before Network is called');-- assert.ok(one.text.includes('Choose two or more of your machines on the same network.'));---- t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: true, ready: true, vram_share: 0.4 }] }];-- const stop = await t.get('/sell/pool/pool_TEST/stop', {-- as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '',-- });-- assert.strictEqual(stop.status, 303, stop.text);-- assert.deepStrictEqual(t.network.state.poolRevoked, ['pool_TEST']);-- });---- await check('no measurements: the page says not measured yet, never a number', async () => {-- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];-- t.network.state.pools = [];-- const r = await t.get('/sell', { as: user });-- assert.ok(r.text.includes('link not measured yet'), 'an unmeasured link says so');-- assert.ok(r.text.includes('No model has been measured on these machines yet'), 'an empty fit table says so');-- assert.ok(!/\bMb\/s: good\b/.test(r.text), 'no invented speed');-- });---- await check('the pool card is gone for a stranger’s machines when Network cannot answer', async () => {-- t.network.setDown(true);-- const r = await t.get('/sell', { as: user });-- assert.strictEqual(r.status, 200, 'the page still renders');-- assert.ok(r.text.includes('Your machines could not be read right now. Reload in a moment.'), 'the read says why');-- const anon = await t.get('/sell');-- assert.ok(!anon.text.includes('Pool your machines'), 'signed out is unaffected');-- t.network.setDown(false);-- });---- await check('phone: the four steps stack, every control is 44 px, nothing is wider than the screen', async () => {-- const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'exchange.css'), 'utf8');-- assert.ok(/\.ex-pool \.ex-field select, \.ex-pool \.ex-btn, \.ex-pool \.ovk-btn \{ min-height: 44px; \}/.test(css), 'controls are 44 px');-- assert.ok(/@media \(max-width: 560px\)/.test(css), 'the phone layout is stacked');-- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];-- t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: false, ready: false, vram_share: 0.4 }] }];-- const r = await t.get('/sell', { as: user });-- assert.strictEqual((r.text.match(/class="ex-pool-step"/g) || []).length, 4, 'four stacked step cards');-- assert.ok(r.text.includes('class="ex-pool ex-pool-steps"'), 'the steps container');-- });++ const user = t.network.addUser('owner');++ await check(++ 'every seller page redirects permanently without exposing machine names',++ async () => {++ for (const path of ['/sell', '/sell/pool', '/sell/anything'])++ for (const options of [{}, { as: user }]) {++ const r = await t.get(path, options);++ assert.equal(r.status, 301);++ assert.equal(++ r.headers.get('location'),++ 'https://openvibe.link',++ );++ }++ },++ );++ await check(++ 'pool mutations still require same-origin sign-in',++ async () => {++ const r = await t.get('/sell/pool', {++ as: user,++ origin: 'https://evil.example',++ form: { machines: 'not-a-machine' },++ });++ assert.equal(r.status, 403);++ assert.equal(t.network.state.poolProposals.length, 0);++ },++ );} finally {await t.close();}done();--})().catch((err) => { console.error(err); process.exitCode = 1; });++})();nnew file mode 100644@@@ -0,0 +1,174 @@++'use strict';++/**++ * Pooled machines, part 4 (design 2026-11-06): "Pool your machines" on /sell and the pool's offer card. The page++ * reads the person's machines, pools, link checks and fit table from Network and AI (the mock Network here); it++ * measures nothing itself. Every number is one that was measured; the rest says "not measured yet".++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('../helpers/boot');++const { createFakeAi } = require('../helpers/ai');++++const dev = () => `dev_${ids.ulid()}`;++const poolOffer = (models) => ({++ offer_id: 'community_abc123', kind: 'pool', label: 'Community pool (3 machines)', models,++ pool: { machines: ['pseudonym-a', 'pseudonym-b', 'pseudonym-c'], head: 'pseudonym-a' },++ free_only: true, verified: false, hours: { start: '00:00', end: '00:00', tz: 'UTC' }, concurrency: 1,++ price: { in_per_million: 1, out_per_million: 2 }, audience: 'everyone', health: { status: 'up' },++ open_now: true, ready: true,++});++++(async () => {++ const ai = createFakeAi({ community: [poolOffer(['llama-3-8b-abliterated'])] });++ const t = await boot({ ai });++ const user = t.network.addUser('alice');++ const desktop = dev(), laptop = dev(), rack = dev(), other = dev();++ const announce = (id, network, freeVram, hardware) => ({ id, free_vram: freeVram, free_ram: 32000, hardware, network });++++ try {++ await check('signed out, /sell has no pool card (it needs a person’s machines)', async () => {++ const r = await t.get('/sell');++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!r.text.includes('Pool your machines'), 'no pool card when signed out');++ });++++ await check('fewer than two machines: one line and the install link', async () => {++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }];++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090')];++ t.network.state.pools = [];++ const r = await t.get('/sell', { as: user });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('Pool your machines'), 'the card is there');++ assert.ok(r.text.includes('Pooling needs two or more of your machines on the same network. Add one with'), 'the one line');++ assert.ok(r.text.includes('https://openvibe.link'), 'the install link');++ assert.ok(!r.text.includes('1. Choose the machines'), 'no four steps with one machine');++ });++++ await check('a machine on another network is greyed, with its reason', async () => {++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'elsewhere', 8000, 'RTX 3060')];++ const r = await t.get('/sell', { as: user });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('Not on the same network as Desktop: pools work on one home network, not across the internet'), r.text);++ assert.ok(/class="ex-pool-machine is-off"/.test(r.text), 'the machine is greyed, not hidden');++ assert.ok(r.text.includes('Desktop') && r.text.includes('Laptop'), 'both machines are listed');++ });++++ await check('a failed link says the number and the limit; a good one says both numbers', async () => {++ t.network.state.machines = [++ { id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }, { id: rack, name: 'Rack', online: true },++ ];++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060'), announce(rack, 'home', 16000, 'RTX 4080')];++ t.network.state.pools = [{++ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',++ machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.5 }, { id: laptop, approved: true, ready: true, vram_share: 0.17 }, { id: rack, approved: true, ready: true, vram_share: 0.33 }],++ links: [{ machine: laptop, rtt_ms: 2, mbps: 940 }, { machine: rack, rtt_ms: 40, mbps: 940 }],++ fit: [{ model: 'llama-3-8b-abliterated', fits: true, tokens_per_second: 9 }, { model: 'qwen3-32b', fits: true, tokens_per_second: null }],++ }];++ const r = await t.get('/sell', { as: user });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('2 ms, 940 Mb/s: good'), 'the good link in words');++ assert.ok(r.text.includes('40 ms; a pool needs under 5 ms'), 'the failed link says the number and the limit');++ assert.ok(r.text.includes('about 9 tokens a second'), 'a measured speed');++ assert.ok(r.text.includes('not measured yet'), 'a model nobody measured says so');++ assert.ok(!/about 0 tokens a second/.test(r.text), 'an absent measurement is never a zero');++ assert.ok(r.text.includes('Head: <b>Desktop</b>'), 'the head is the machine with the most free VRAM');++ });++++ await check('nothing is offered until every member approves', async () => {++ t.network.state.pools = [{++ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',++ machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.75 }, { id: laptop, approved: false, ready: false, vram_share: 0.25 }],++ }];++ const waiting = await t.get('/sell', { as: user });++ assert.ok(waiting.text.includes('Waiting for approval on <b>Laptop</b>'), waiting.text);++ assert.ok(waiting.text.includes('Approved'), 'the machine that approved says so');++ assert.ok(waiting.text.includes('Nothing is offered until every member approves.'), 'nothing is offered yet');++ assert.ok(!waiting.text.includes('one price for the whole pool'), 'the offer controls stay closed');++++ t.network.state.pools[0].machines = t.network.state.pools[0].machines.map((m) => ({ ...m, approved: true, ready: true }));++ const approved = await t.get('/sell', { as: user });++ assert.ok(approved.text.includes('one price for the whole pool'), 'the same controls as one machine, applied to the pool');++ assert.ok(approved.text.includes('everyone, your followers or a group'), 'who may use it');++ assert.ok(approved.text.includes('the whole pool pauses while any member is in use'), 'idle only');++ assert.ok(approved.text.includes('Prompts reach your head machine (Desktop); the others only see numbers in transit.'), 'the privacy sentence');++ assert.ok(!approved.text.includes('Nothing is offered until every member approves.'), 'it is offered now');++ });++++ await check('the public card shows only the count, never a machine', async () => {++ t.network.state.machines = [{ id: desktop, name: 'Workstation Alpha', online: true }];++ const models = await t.get('/models');++ assert.strictEqual(models.status, 200, models.text);++ assert.ok(models.text.includes('Community pool (3 machines)'), 'the label');++ assert.ok(models.text.includes('Free tier only'), 'a new pool says it serves the free tier only');++ assert.ok(!models.text.includes('<span'), 'no badge markup escapes as text');++ assert.ok(!models.text.includes('Workstation Alpha'), 'no machine name in public');++ const model = await t.get('/m/meta-llama/llama-3-8b-abliterated');++ assert.ok(model.text.includes('Community pool (3 machines)'), 'the offer card carries the pool label');++ assert.ok(!model.text.includes('Workstation Alpha'), 'still no machine name');++ const owner = await t.get('/sell', { as: user });++ assert.ok(owner.text.includes('Workstation Alpha'), 'the owner sees the machine names');++ });++++ await check('creating the pool goes through Network, and stopping it says what happens', async () => {++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];++ t.network.state.pools = [];++ const made = await t.get('/sell/pool', {++ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },++ body: `machines=${desktop}&machines=${laptop}&head=${desktop}`,++ });++ assert.strictEqual(made.status, 303, made.text);++ assert.strictEqual(t.network.state.poolProposals.length, 1);++ assert.deepStrictEqual(t.network.state.poolProposals[0], { machines: [desktop, laptop], head: desktop });++++ const one = await t.get('/sell/pool', {++ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `machines=${desktop}`,++ });++ assert.strictEqual(one.status, 200, 'one machine is refused before Network is called');++ assert.ok(one.text.includes('Choose two or more of your machines on the same network.'));++++ t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: true, ready: true, vram_share: 0.4 }] }];++ const stop = await t.get('/sell/pool/pool_TEST/stop', {++ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '',++ });++ assert.strictEqual(stop.status, 303, stop.text);++ assert.deepStrictEqual(t.network.state.poolRevoked, ['pool_TEST']);++ });++++ await check('no measurements: the page says not measured yet, never a number', async () => {++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];++ t.network.state.pools = [];++ const r = await t.get('/sell', { as: user });++ assert.ok(r.text.includes('link not measured yet'), 'an unmeasured link says so');++ assert.ok(r.text.includes('No model has been measured on these machines yet'), 'an empty fit table says so');++ assert.ok(!/\bMb\/s: good\b/.test(r.text), 'no invented speed');++ });++++ await check('the pool card is gone for a stranger’s machines when Network cannot answer', async () => {++ t.network.setDown(true);++ const r = await t.get('/sell', { as: user });++ assert.strictEqual(r.status, 200, 'the page still renders');++ assert.ok(r.text.includes('Your machines could not be read right now. Reload in a moment.'), 'the read says why');++ const anon = await t.get('/sell');++ assert.ok(!anon.text.includes('Pool your machines'), 'signed out is unaffected');++ t.network.setDown(false);++ });++++ await check('phone: the four steps stack, every control is 44 px, nothing is wider than the screen', async () => {++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'exchange.css'), 'utf8');++ assert.ok(/\.ex-pool \.ex-field select, \.ex-pool \.ex-btn, \.ex-pool \.ovk-btn \{ min-height: 44px; \}/.test(css), 'controls are 44 px');++ assert.ok(/@media \(max-width: 560px\)/.test(css), 'the phone layout is stacked');++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];++ t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: false, ready: false, vram_share: 0.4 }] }];++ const r = await t.get('/sell', { as: user });++ assert.strictEqual((r.text.match(/class="ex-pool-step"/g) || []).length, 4, 'four stacked step cards');++ assert.ok(r.text.includes('class="ex-pool ex-pool-steps"'), 'the steps container');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exitCode = 1; });@@@ -25,6 +25,17 @@ const { createFakeBench } = require('./helpers/bench');});const bench = createFakeBench();const t = await boot({ ai, bench });++ // Arena HTML now lives on Report; exercise the same private read through Exchange's JSON API.++ const get = t.get;++ t.get = async (path, options = {}) => {++ if (/^\/arena(?:\/|$)/.test(path) && !options.form && !options.json && !options.method) {++ const response = await get(path.replace(/^\/arena/, '/api/v1/arena/rounds'), options);++ try { response.text = response.json().html || response.text; } catch {}++ return response;++ }++ return get(path, options);++ };++const alice = t.network.addUser('alice');const bob = t.network.addUser('bob');const carol = t.network.addUser('carol');@@@ -104,7 +104,7 @@ const { sanitizeNext } = require('../server/auth/sso');});await check('public pages are complete without JavaScript', async () => {-- for (const p of ['/', '/models', '/api', '/sell', '/uncensored', '/ai']) {++ for (const p of ['/', '/models', '/api', '/pricing', '/search', '/uncensored']) {const r = await t.get(p);assert.strictEqual(r.status, 200, p);assert.match(r.text, /<noscript><nav/);
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+# API platform review
+
+Home, Models, model detail, Dashboard (keys, usage/CSV, credits, deployments and settings), Search and owner supply console use the AI platform APIs in the companion PR. Arena reads and writes use the authenticated Exchange bridge and Report hosts the page. Arena and supply navigation use permanent redirects.
+
+The Chat compare move (Chat #419 / Exchange #136) is not merged. Persona and private-chat migration and their redirects must follow it; they are not implemented here. These changes should remain in review until that dependency and the library releases are coordinated. No production changes or provider purchases were performed.
+
+## Screenshots
+
+The 16 images in `screenshots/` exercise home, Models, model detail and Dashboard Keys at 390 and 1280 pixels in light and dark themes. They use local fake catalogue/account data, not production measurements. `checks.json` records the actual painted mode, document width and browser errors. No horizontal overflow or uncaught page errors were observed. Shared Network theme/frame requests report localhost CORS failures (and some unavailable remote resources); these screenshots are layout checks, not evidence of live cross-service integration.
+
+## Validation
+
+`node --check` on changed JavaScript; `ov test` full suite once, followed by focused fixes for intended redirects, Arena fragments, legacy pools and Dashboard CSV escaping. Final focused Arena and Dashboard tests pass. API keys, search and supply backend validation is recorded in the AI PR.
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
+[
+ {
+ "name": "home",
+ "width": 390,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "models",
+ "width": 390,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "model",
+ "width": 390,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "dashboard",
+ "width": 390,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 6
+ },
+ {
+ "name": "home",
+ "width": 390,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "models",
+ "width": 390,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "model",
+ "width": 390,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "dashboard",
+ "width": 390,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 390,
+ "errors": [],
+ "consoleErrors": 6
+ },
+ {
+ "name": "home",
+ "width": 1280,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "models",
+ "width": 1280,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 6
+ },
+ {
+ "name": "model",
+ "width": 1280,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 6
+ },
+ {
+ "name": "dashboard",
+ "width": 1280,
+ "scheme": "dark",
+ "painted": "dark",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 6
+ },
+ {
+ "name": "home",
+ "width": 1280,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 4
+ },
+ {
+ "name": "models",
+ "width": 1280,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 6
+ },
+ {
+ "name": "model",
+ "width": 1280,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 6
+ },
+ {
+ "name": "dashboard",
+ "width": 1280,
+ "scheme": "light",
+ "painted": "light",
+ "docWidth": 1280,
+ "errors": [],
+ "consoleErrors": 6
+ }
+]
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/dashboard-1280-dark.png differ
Binary files /dev/null and b/docs/screenshots/dashboard-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/dashboard-1280-light.png differ
Binary files /dev/null and b/docs/screenshots/dashboard-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/dashboard-390-dark.png differ
Binary files /dev/null and b/docs/screenshots/dashboard-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/dashboard-390-light.png differ
Binary files /dev/null and b/docs/screenshots/dashboard-390-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/home-1280-dark.png differ
Binary files /dev/null and b/docs/screenshots/home-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/home-1280-light.png differ
Binary files /dev/null and b/docs/screenshots/home-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/home-390-dark.png differ
Binary files /dev/null and b/docs/screenshots/home-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/home-390-light.png differ
Binary files /dev/null and b/docs/screenshots/home-390-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/model-1280-dark.png differ
Binary files /dev/null and b/docs/screenshots/model-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/model-1280-light.png differ
Binary files /dev/null and b/docs/screenshots/model-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/model-390-dark.png differ
Binary files /dev/null and b/docs/screenshots/model-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/model-390-light.png differ
Binary files /dev/null and b/docs/screenshots/model-390-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/models-1280-dark.png differ
Binary files /dev/null and b/docs/screenshots/models-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/models-1280-light.png differ
Binary files /dev/null and b/docs/screenshots/models-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/models-390-dark.png differ
Binary files /dev/null and b/docs/screenshots/models-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/models-390-light.png differ
Binary files /dev/null and b/docs/screenshots/models-390-light.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,228 @@
@@ -0,0 +1,228 @@
+.xp {
+ padding-block: 40px;
+ display: grid;
+ gap: 28px;
+}
+.xp-hero {
+ max-width: 850px;
+}
+.xp h1 {
+ font-size: clamp(2rem, 5vw, 3.6rem);
+ line-height: 1.1;
+ letter-spacing: -0.04em;
+}
+.xp a,
+.xp button,
+.xp summary {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+}
+.xp input,
+.xp select {
+ min-height: 44px;
+ max-width: 100%;
+}
+.xp pre {
+ max-width: 100%;
+ overflow: auto;
+}
+.xp-models {
+ max-width: 100%;
+ overflow: auto;
+}
+.xp table {
+ border-collapse: collapse;
+ width: 100%;
+ text-align: left;
+}
+.xp th,
+.xp td {
+ padding: 16px 12px;
+ border-bottom: 1px solid var(--border, #8884);
+ vertical-align: middle;
+}
+.xp td small,
+.xp td:last-child a {
+ display: block;
+}
+.xp-reasons {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+}
+.xp-activity label {
+ display: flex;
+ gap: 16px;
+}
+.xp label {
+ display: grid;
+ gap: 6px;
+}
+.xp form:not(.ovk-find) {
+ display: grid;
+ gap: 16px;
+ max-width: 640px;
+}
+.xp nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.xp details {
+ border-bottom: 1px solid var(--border, #8884);
+}
+.xp code {
+ overflow-wrap: anywhere;
+}
+.xp .xp-tabs a {
+ padding: 8px 16px;
+}
+.xp-search-results {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 20px;
+}
+@media (max-width: 700px) {
+ .xp {
+ padding-block: 24px;
+ }
+ .xp-reasons,
+ .xp-search-results {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .xp table,
+ .xp tbody,
+ .xp tr,
+ .xp td {
+ display: block;
+ }
+ .xp thead {
+ display: none;
+ }
+ .xp tr {
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border, #8884);
+ }
+ .xp td {
+ border: 0;
+ padding: 4px 8px;
+ overflow-wrap: anywhere;
+ }
+ .xp td[data-label]:before {
+ content: attr(data-label) ': ';
+ opacity: 0.7;
+ }
+ .xp td:last-child a {
+ display: inline-flex;
+ margin-right: 12px;
+ }
+ .xp h1 {
+ font-size: 2.2rem;
+ }
+}
+
+.xp input:not([type='checkbox']),
+.xp select {
+ padding: 10px 12px;
+ border: 1px solid var(--ovk-line);
+ border-radius: 10px;
+ background: var(--ovk-panel);
+ color: var(--ovk-text);
+ font: inherit;
+}
+.xp .ovk-find {
+ max-width: 640px;
+}
+.xp .ovk-find input {
+ border: 0;
+ background: transparent;
+}
+.xp .ovk-find button {
+ flex-shrink: 0;
+}
+.xp .xp-tabs {
+ gap: 4px;
+}
+.xp .xp-tabs a[aria-current='page'] {
+ background: var(--ovk-panel-2);
+ border-radius: 10px;
+ text-decoration: none;
+}
+
+/* The Dashboard (Opus, 2026-11-09): its own scope, so the marketing pages' .xp rules (grid gaps, every link a 44 px
+ flex box) stay off it. Cards on the page surface, the kit's tab rail, two-line rows, numbers in tabular figures. */
+.xd { padding-block: 32px 48px; max-width: 880px; }
+.xd .ovk-tabs { margin-top: 4px; margin-bottom: 4px; }
+.xd-body { display: grid; gap: 16px; margin-top: 12px; }
+.xd-card { padding: 18px 18px 20px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }
+.xd-card > h2, .xd-title h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 1.12rem; letter-spacing: -.01em; }
+.xd-card > h2.xd-sub { font-size: 1rem; }
+.xd-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.xd-title h2 { margin: 0; }
+.xd-note { margin: 0 0 14px; color: var(--ovk-muted); font-size: .92rem; line-height: 1.5; max-width: 60ch; }
+.xd-actions { margin: 0 0 14px; display: flex; flex-wrap: wrap; gap: 8px; }
+.xd-chip { display: inline-flex; align-items: center; margin-left: 8px; padding: 1px 8px; border-radius: 999px; font-size: .74rem; color: var(--ovk-muted); background: var(--ovk-panel-2); vertical-align: 1px; }
+
+.xd-keys { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+.xd-key { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; align-items: center; padding: 12px 14px; border-radius: var(--ov-radius-1, 12px); background: var(--ovk-panel-2); }
+.xd-key-main { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; }
+.xd-key-main strong { font-size: 1rem; overflow-wrap: anywhere; }
+.xd-key-main code { font-size: .84rem; color: var(--ovk-muted); }
+.xd-key-meta { grid-column: 1; margin: 0; color: var(--ovk-muted); font-size: .86rem; }
+.xd-key-act { grid-column: 2; grid-row: 1 / span 2; margin: 0; }
+.xd-key.is-revoked { opacity: .62; }
+
+.xd-form { display: grid; gap: 16px; max-width: 520px; margin: 0; }
+.xd-form label { display: grid; gap: 6px; }
+.xd-form label > span:first-child { font-weight: 600; font-size: .92rem; }
+.xd-form small { color: var(--ovk-muted); font-size: .82rem; line-height: 1.45; }
+.xd-form input { min-height: 44px; box-sizing: border-box; width: 100%; padding: 10px 12px; border: 1px solid var(--ovk-line); border-radius: 10px; background: var(--ovk-bg, transparent); color: var(--ovk-text); font: inherit; }
+.xd-form input:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }
+.xd-money { position: relative; display: block; }
+.xd-money > span { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ovk-muted); pointer-events: none; }
+.xd-money input { padding-left: 26px; font-variant-numeric: tabular-nums; }
+.xd-form .ovk-btn { justify-self: start; min-height: 44px; }
+.xd-secret .ovk-snippet, .xd-secret pre { margin: 0 0 14px; }
+
+.xd-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0 0 16px; }
+.xd-stats > div { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border-radius: var(--ov-radius-1, 12px); background: var(--ovk-panel-2); }
+.xd-stats dd { margin: 0; font-size: 1.3rem; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
+.xd-stats dt { color: var(--ovk-muted); font-size: .8rem; }
+.xd-bars { display: flex; align-items: flex-end; gap: 4px; height: 96px; padding-bottom: 18px; position: relative; }
+.xd-bars > span { position: relative; flex: 1 1 0; height: 100%; display: flex; align-items: flex-end; }
+.xd-bars i { display: block; width: 100%; border-radius: 4px 4px 2px 2px; background: color-mix(in srgb, var(--ovk-accent) 70%, transparent); }
+.xd-bars b { position: absolute; left: 0; right: 0; bottom: -18px; text-align: center; font-weight: 400; font-size: .7rem; color: var(--ovk-muted); font-variant-numeric: tabular-nums; }
+.xd-day { margin: 14px 0 6px; font-size: .78rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--ovk-muted); }
+.xd-day:first-of-type { margin-top: 2px; }
+.xd-use { list-style: none; margin: 0; padding: 0; }
+.xd-use li { padding: 10px 0; border-top: 1px solid var(--ovk-line); }
+.xd-use li:first-child { border-top: 0; }
+.xd-use-top { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
+.xd-use-top code { font-size: .88rem; overflow-wrap: anywhere; min-width: 0; }
+.xd-use-top strong { font-variant-numeric: tabular-nums; }
+.xd-use p { margin: 2px 0 0; color: var(--ovk-muted); font-size: .84rem; }
+.xd .ovk-list--flush { margin: 0 -18px -20px; }
+@media (max-width: 520px) {
+ .xd { padding-block: 20px 40px; }
+ .xd-card { padding: 16px 14px 18px; }
+ .xd-stats dd { font-size: 1.12rem; }
+ .xd .ovk-list--flush { margin: 0 -14px -18px; }
+}
+/* Settings rows say what each page does in full: their line wraps instead of trailing off. */
+.xd .ovk-row-sub > span:first-child { white-space: normal; overflow: visible; }
+
+/* Code samples scroll sideways on a phone; a URL never breaks mid-word (.xp code wraps anywhere for inline code). */
+.xp pre code, .xp pre { overflow-wrap: normal; word-break: normal; white-space: pre; }
+/* Grid tracks never grow past the page for a long line (a pre scrolls inside its own box). */
+.xp > *, .xp-hero, .xp .ovk-snip { min-width: 0; max-width: 100%; }
+.xp .ovk-snip pre { overflow-x: auto; }
+
+/* A model's activity: requests a day as bars, the day of the month under each. */
+.ex-act { list-style: none; display: flex; align-items: flex-end; gap: 4px; height: 110px; margin: 0; padding: 0 0 18px; }
+.ex-act li { position: relative; flex: 1 1 0; height: 100%; display: flex; align-items: flex-end; }
+.ex-act-bar { display: block; width: 100%; border-radius: 4px 4px 2px 2px; background: color-mix(in srgb, var(--ovk-accent) 70%, transparent); }
+.ex-act-day { position: absolute; left: 0; right: 0; bottom: -18px; text-align: center; font-size: .7rem; color: var(--ovk-muted); font-variant-numeric: tabular-nums; }
+.ex-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+
+.ex-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * The design-review screenshots for the API platform (Opus, 2026-11-09): home, the Dashboard's tabs and Search, at 390
+ * and 1280, dark and light. It boots the real app on a temp database with a mock Network and a stand-in AI holding two
+ * keys and a week of usage, and drives headless Chrome. It checks the width (scrollWidth === viewport) and tap
+ * targets at 390, and says what it found. The shots go outside the repository (never committed).
+ *
+ * node scripts/shoot-platform.js [outDir] [paths, comma separated]
+ */
+const path = require('path');
+const os = require('os');
+const { boot } = require('../test/helpers/boot');
+const { createFakeAi } = require('../test/helpers/ai');
+const { chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');
+
+const OUT = path.resolve(process.argv[2] || path.join(os.tmpdir(), 'exchange-platform-shots'));
+const PATHS = (process.argv[3] || '/,/dashboard/keys,/dashboard/usage,/dashboard/credits,/dashboard/deployments,/dashboard/settings,/search').split(',');
+
+const KEYS = [
+ { id: 'xkey_1', name: 'My application', prefix: 'ovx_demo12', monthly_cap_usd: 10, allowed_models: [], created_at: '2026-11-08T10:00:00Z', revoked_at: null },
+ { id: 'xkey_2', name: 'Night batch jobs', prefix: 'ovx_n8Kq3z', monthly_cap_usd: 25, allowed_models: ['deepseek/deepseek-chat', 'qwen/qwen3-32b'], created_at: '2026-11-01T10:00:00Z', revoked_at: null },
+];
+const USAGE = [];
+for (let d = 1; d <= 7; d++) {
+ USAGE.push({ day: `2026-11-0${d}`, key_id: 'xkey_1', model: 'deepseek/deepseek-chat', status: 'settled', requests: 40 * d, tokens_in: 9000 * d, tokens_out: 4000 * d, cost_usd: 0.004 * d });
+ if (d % 2) USAGE.push({ day: `2026-11-0${d}`, key_id: 'xkey_2', model: 'qwen/qwen3-32b', status: 'settled', requests: 12 * d, tokens_in: 3000 * d, tokens_out: 1500 * d, cost_usd: 0.002 * d });
+}
+
+(async () => {
+ const ai = { ...createFakeAi(), meRequest: async (_token, p) => (p.endsWith('key-usage') ? { rows: USAGE } : { keys: KEYS }) };
+ const t = await boot({ ai });
+ const alice = t.network.addUser('alice');
+ const cookie = t.signIn(alice);
+ const url = new URL(t.base);
+ const b = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
+ require('fs').mkdirSync(OUT, { recursive: true });
+ try {
+ for (const [width, scheme] of [[390, 'dark'], [390, 'light'], [1280, 'dark']]) {
+ const ctx = await b.newContext({ viewport: { width, height: 900 }, colorScheme: scheme, isMobile: width < 700, hasTouch: width < 700 });
+ await ctx.addCookies([
+ { name: 'exchange_at', value: cookie.split('=')[1], domain: url.hostname, path: '/' },
+ { name: 'ov_token', value: cookie.split('=')[1], domain: url.hostname, path: '/' },
+ ]);
+ const pg = await ctx.newPage();
+ const errors = []; pg.on('pageerror', (e) => errors.push(e.message));
+ for (const p of PATHS) {
+ const r = await pg.goto(t.base + p, { waitUntil: 'load' }).catch(() => null);
+ await pg.waitForTimeout(400);
+ const info = await pg.evaluate(() => {
+ const small = [...document.querySelectorAll('main a, main button, main input, main select, main summary')]
+ .filter((el) => { const b = el.getBoundingClientRect(); return b.width > 0 && b.height > 0 && b.height < 44 && getComputedStyle(el).display !== 'inline'; })
+ .map((el) => `${el.tagName.toLowerCase()}:${(el.textContent || el.name || '').trim().slice(0, 18)}:${Math.round(el.getBoundingClientRect().height)}`);
+ return { over: document.documentElement.scrollWidth > innerWidth, small: small.slice(0, 6) };
+ });
+ const name = `${p === '/' ? 'home' : p.replace(/^\//, '').replace(/\//g, '-')}-${width}-${scheme}`;
+ // The app's boot helper captures console output: the findings go to a file beside the shots.
+ require('fs').appendFileSync(path.join(OUT, 'results.txt'), `${[name, r && r.status(), info.over ? 'OVERFLOW' : 'fits', width < 700 && info.small.length ? `small: ${info.small.join(' ')}` : ''].join(' ')}\n`);
+ await pg.screenshot({ path: path.join(OUT, `${name}.png`), fullPage: true });
+ }
+ if (errors.length) require('fs').appendFileSync(path.join(OUT, 'results.txt'), `errors ${errors.slice(0, 3).join(' | ')}\n`);
+ await ctx.close();
+ }
+ } finally {
+ await b.close();
+ await t.close();
+ }
+ console.log('shots in', OUT);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -214,6 +214,19 @@ async function createApp(opts = {}) {
@@ -214,6 +214,19 @@ async function createApp(opts = {}) {
app.locals.sharingTimer = timer;
app.locals.sharingTimer = timer;
}
}
+ const arenaApi = express.Router();
+ arenaApi.use(express.json({ limit:'64kb' }));
+ arenaApi.use(principal.middleware);
+ arenaApi.use((req,res,next) => {
+ if (req.method !== 'GET' && (!req.headers.authorization || req.principal?.kind !== 'user')) return res.sendStatus(401);
+ if (req.principal?.kind === 'user') req.viewer = req.principal;
+ req.arenaApi = true;
+ const path = req.path;
+ req.url = path === '/rounds' ? (req.method === 'POST' ? '/arena/ask' : '/arena') : path.replace(/^\/rounds\//, '/arena/').replace(/\/votes$/, '/vote');
+ next();
+ });
+ arenaApi.use(createArenaRoutes(ctx));
+ app.use('/api/v1/arena', arenaApi);
// Chat imports only the signed-in person's unsealed history. The service token must carry
// Chat imports only the signed-in person's unsealed history. The service token must carry
// the narrow export capability; Chat supplies the subject it just authenticated.
// the narrow export capability; Chat supplies the subject it just authenticated.
app.get('/internal/exchange/chat/export', principal.middleware, async (req, res) => {
app.get('/internal/exchange/chat/export', principal.middleware, async (req, res) => {
@@ -271,10 +284,15 @@ async function createApp(opts = {}) {
@@ -271,10 +284,15 @@ async function createApp(opts = {}) {
agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });
agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });
app.use(agent);
app.use(agent);
}
}
+ app.get(['/arena', '/arena/*'], (req,res) => res.redirect(301, 'https://openvibe.report/ai' + req.originalUrl));
+ app.get(['/sell', '/sell/*'], (_req,res) => res.redirect(301, 'https://openvibe.link'));
+ app.get('/ai', (_req,res) => res.redirect(301, 'https://openvibe.report/ai/exchange'));
// The companions routes come before the pages router: /companions, /c/... and /talk/...
// The companions routes come before the pages router: /companions, /c/... and /talk/...
app.use(createCompanionRoutes(ctx));
app.use(createCompanionRoutes(ctx));
// The Arena and the rankings: /arena, /arena/:id and /rankings.
// The Arena and the rankings: /arena, /arena/:id and /rankings.
app.use(createArenaRoutes(ctx));
app.use(createArenaRoutes(ctx));
+ app.use(require('./http/dashboard').createDashboardRoutes(ctx));
+ app.use(require('./http/search').createSearchRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use(createConsoleRoutes(ctx));
app.use(createConsoleRoutes(ctx));
@@ -19,7 +19,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -19,7 +19,7 @@ const ovServe = require('openvibe-shared/serve');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
-const { esc, raw } = require('../render/html');
+const { esc, raw, render } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const views = require('../views/arena');
const views = require('../views/arena');
const exchangeViews = require('../views/exchange');
const exchangeViews = require('../views/exchange');
@@ -71,7 +71,7 @@ function createArenaRoutes(ctx) {
@@ -71,7 +71,7 @@ function createArenaRoutes(ctx) {
const { config, ai, bench, arenaStore, sharing, log } = ctx;
const { config, ai, bench, arenaStore, sharing, log } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
- const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
+ const page = (req, res, o, status = 200) => req.arenaApi ? res.status(status).set('Cache-Control','no-store').json({ title:o.title, html:render(o.body) }) : send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const owner = (req) => req.viewer.subject;
const owner = (req) => req.viewer.subject;
const signInPage = (req, res, { title, text, next, status = 401 }) => page(req, res, {
const signInPage = (req, res, { title, text, next, status = 401 }) => page(req, res, {
@@ -121,7 +121,7 @@ function createArenaRoutes(ctx) {
@@ -121,7 +121,7 @@ function createArenaRoutes(ctx) {
const vote = await arenaStore.voteOf(owner(req), round.id);
const vote = await arenaStore.voteOf(owner(req), round.id);
return page(req, res, {
return page(req, res, {
title: 'The Arena', index: false, head: shareHead,
title: 'The Arena', index: false, head: shareHead,
- body: raw(views.arenaRound({ round, vote, revealed: Boolean(vote), problem: problem || notice, config, action: shareButton(round) })),
+ body: raw(views.arenaRound({ round, vote, revealed: Boolean(vote), problem: problem || notice, config, action: req.arenaApi ? '' : shareButton(round) })),
}, status);
}, status);
}
}
@@ -130,16 +130,16 @@ function createArenaRoutes(ctx) {
@@ -130,16 +130,16 @@ function createArenaRoutes(ctx) {
/** The person's default for new rounds ("New rounds: Only me"), and what to do with the existing ones. */
/** The person's default for new rounds ("New rounds: Only me"), and what to do with the existing ones. */
r.post('/arena/defaults', async (req, res) => {
r.post('/arena/defaults', async (req, res) => {
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Farena');
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Farena');
- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ if (!(req.arenaApi || sameOrigin(req, config.baseUrl))) return notFromHere(req, res);
const body = req.body || {};
const body = req.body || {};
const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';
const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';
await sharing.setDefault(owner(req), 'arena-round', audience, { applyAll: String(body.apply_all || '') === '1' }).catch((err) => log.warn('[Arena] the round default was not saved:', err.message));
await sharing.setDefault(owner(req), 'arena-round', audience, { applyAll: String(body.apply_all || '') === '1' }).catch((err) => log.warn('[Arena] the round default was not saved:', err.message));
- return res.redirect(303, '/arena');
+ return req.arenaApi ? res.json({ location: '/ai/arena' }) : res.redirect(303, '/arena');
});
});
r.post('/arena/ask', async (req, res) => {
r.post('/arena/ask', async (req, res) => {
if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to enter the Arena', text: 'Two models answer your prompt, blind.', next: '/arena' });
if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to enter the Arena', text: 'Two models answer your prompt, blind.', next: '/arena' });
- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ if (!(req.arenaApi || sameOrigin(req, config.baseUrl))) return notFromHere(req, res);
const prompt = String((req.body && req.body.prompt) || '').trim().slice(0, config.arena.maxChars);
const prompt = String((req.body && req.body.prompt) || '').trim().slice(0, config.arena.maxChars);
if (!prompt) return renderHome(req, res, { problem: 'Write a prompt first.', status: 422 });
if (!prompt) return renderHome(req, res, { problem: 'Write a prompt first.', status: 422 });
@@ -218,7 +218,7 @@ function createArenaRoutes(ctx) {
@@ -218,7 +218,7 @@ function createArenaRoutes(ctx) {
// The person's default for new rounds (Only me unless they changed it), then the sheet for this one.
// The person's default for new rounds (Only me unless they changed it), then the sheet for this one.
const audience = await sharing.defaultOf(owner(req), 'arena-round').catch(() => 'private');
const audience = await sharing.defaultOf(owner(req), 'arena-round').catch(() => 'private');
try { await sharing.register({ subject: owner(req) }, 'arena-round', round.id, audience); } catch (err) { log.warn('[Arena] the round was kept but not registered with the sharing system:', err.message); }
try { await sharing.register({ subject: owner(req) }, 'arena-round', round.id, audience); } catch (err) { log.warn('[Arena] the round was kept but not registered with the sharing system:', err.message); }
- return res.redirect(303, `/arena/${round.id}`);
+ return req.arenaApi ? res.status(201).json({ location: `/ai/arena/${round.id}` }) : res.redirect(303, `/arena/${round.id}`);
});
});
/**
/**
@@ -246,7 +246,7 @@ function createArenaRoutes(ctx) {
@@ -246,7 +246,7 @@ function createArenaRoutes(ctx) {
r.post('/arena/:id/vote', async (req, res) => {
r.post('/arena/:id/vote', async (req, res) => {
if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to vote', text: 'Your vote is a Bench judgement for your account.', next: `/arena/${encodeURIComponent(req.params.id)}` });
if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to vote', text: 'Your vote is a Bench judgement for your account.', next: `/arena/${encodeURIComponent(req.params.id)}` });
- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ if (!(req.arenaApi || sameOrigin(req, config.baseUrl))) return notFromHere(req, res);
const round = await arenaStore.get(owner(req), req.params.id);
const round = await arenaStore.get(owner(req), req.params.id);
if (!round) return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such round', text: 'It may belong to another account.', action: { href: '/arena', label: 'The Arena' } })}</div>`) }, 404);
if (!round) return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such round', text: 'It may belong to another account.', action: { href: '/arena', label: 'The Arena' } })}</div>`) }, 404);
@@ -269,7 +269,7 @@ function createArenaRoutes(ctx) {
@@ -269,7 +269,7 @@ function createArenaRoutes(ctx) {
owner: owner(req), roundId: round.id, outcome,
owner: owner(req), roundId: round.id, outcome,
benchJudgementId: out && out.judgement ? out.judgement.id : null,
benchJudgementId: out && out.judgement ? out.judgement.id : null,
});
});
- return res.redirect(303, `/arena/${round.id}`);
+ return req.arenaApi ? res.status(201).json({ location: `/ai/arena/${round.id}` }) : res.redirect(303, `/arena/${round.id}`);
} catch (err) {
} catch (err) {
if (err.code === 'bench.already_judged') {
if (err.code === 'bench.already_judged') {
return renderRound(req, res, round, { notice: 'This vote is already counted in OpenVibe.Bench.', status: 409 });
return renderRound(req, res, round, { notice: 'This vote is already counted in OpenVibe.Bench.', status: 409 });
@@ -6,6 +6,7 @@ const { send } = require('../render/layout');
@@ -6,6 +6,7 @@ const { send } = require('../render/layout');
const { consolePage } = require('../views/console');
const { consolePage } = require('../views/console');
const READS = Object.freeze({
const READS = Object.freeze({
+ supply: '/api/v1/admin/console/supply',
overview: '/api/v1/admin/console/overview', providers: '/api/v1/admin/console/providers', models: '/api/v1/models', routes: '/api/v1/routes',
overview: '/api/v1/admin/console/overview', providers: '/api/v1/admin/console/providers', models: '/api/v1/models', routes: '/api/v1/routes',
templates: '/api/v1/templates', workflows: '/api/v1/workflows', runs: '/api/v1/admin/console/runs', quotas: '/api/v1/admin/console/quotas',
templates: '/api/v1/templates', workflows: '/api/v1/workflows', runs: '/api/v1/admin/console/runs', quotas: '/api/v1/admin/console/quotas',
usage: '/api/v1/admin/console/usage', cache: '/api/v1/admin/console/cache', audit: '/api/v1/admin/console/audit',
usage: '/api/v1/admin/console/usage', cache: '/api/v1/admin/console/cache', audit: '/api/v1/admin/console/audit',
@@ -56,6 +57,13 @@ function createConsoleRoutes({ config, ai }) {
@@ -56,6 +57,13 @@ function createConsoleRoutes({ config, ai }) {
if (!origin) return false;
if (!origin) return false;
try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
};
};
+ r.post('/console/supply', async (req,res) => {
+ if (!sameOrigin(req)) return res.sendStatus(403);
+ const body = { model: req.body.model, runtime: req.body.runtime };
+ for (const k of ['input_per_million','output_per_million','hourly_ceiling_usd','monthly_ceiling_usd','required_vram_mb','idle_minutes']) body[k] = Number(req.body[k]);
+ try { await ai.adminRequest('/api/v1/admin/console/supply', { method:'POST', subject:req.viewer.subject, body }); return res.redirect(303,'/console/supply'); }
+ catch { return page(req,res,'supply',{},'Supply settings could not be saved.',502); }
+ });
r.post('/console/providers', async (req, res) => {
r.post('/console/providers', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
if (!sameOrigin(req)) return res.sendStatus(403);
try {
try {
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+const express = require('express');
+const { raw } = require('../render/html');
+const { send } = require('../render/layout');
+const { sameOrigin } = require('./principal');
+const views = require('../views/dashboard');
+function createDashboardRoutes({ config, ai }) {
+ const r = express.Router();
+ r.use(express.urlencoded({ extended: false, limit: '32kb' }));
+ const render = (req, res, tab, html, status = 200) =>
+ send(res, status, {
+ viewer: req.viewer,
+ config,
+ path: req.originalUrl,
+ index: false,
+ title: 'Dashboard · ' + tab,
+ body: raw(html),
+ });
+ r.use('/dashboard', (req, res, next) => {
+ res.set('Cache-Control', 'no-store');
+ if (req.viewer?.kind !== 'user')
+ return res.redirect(
+ 303,
+ '/auth/login?next=' +
+ encodeURIComponent('/dashboard' + req.url),
+ );
+ if (req.method !== 'GET' && !sameOrigin(req, config.baseUrl))
+ return res.sendStatus(403);
+ next();
+ });
+ const call = (req, path, options) =>
+ ai.meRequest(req.viewer.token, path, options);
+ const safe = (fn) => async (req, res) => {
+ try {
+ await fn(req, res);
+ } catch {
+ render(req, res, 'Unavailable', views.unavailablePage(), 502);
+ }
+ };
+ r.get(
+ ['/dashboard', '/dashboard/keys'],
+ safe(async (req, res) => {
+ const data = await call(req, '/api/v1/exchange/keys');
+ render(req, res, 'Keys', views.keysPage(data.keys || []));
+ }),
+ );
+ r.post(
+ '/dashboard/keys',
+ safe(async (req, res) => {
+ const data = await call(req, '/api/v1/exchange/keys', {
+ method: 'POST',
+ body: {
+ name: req.body.name,
+ monthly_cap_usd: Number(req.body.monthly_cap_usd),
+ allowed_models: String(req.body.allowed_models || '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean),
+ },
+ });
+ render(req, res, 'Keys', views.newKeyPage(data.secret, data.key));
+ }),
+ );
+ r.post(
+ '/dashboard/keys/:id/revoke',
+ safe(async (req, res) => {
+ await call(
+ req,
+ '/api/v1/exchange/keys/' + encodeURIComponent(req.params.id),
+ { method: 'DELETE' },
+ );
+ res.redirect(303, '/dashboard/keys');
+ }),
+ );
+ r.get(
+ ['/dashboard/usage', '/dashboard/usage.csv'],
+ safe(async (req, res) => {
+ const data = await call(req, '/api/v1/exchange/key-usage');
+ const rows = data.rows || [];
+ const keyList = req.path.endsWith('.csv') ? null : await call(req, '/api/v1/exchange/keys').catch(() => ({ keys: [] }));
+ const columns = [
+ 'day',
+ 'key_id',
+ 'model',
+ 'requests',
+ 'tokens_in',
+ 'tokens_out',
+ 'cost_usd',
+ 'status',
+ ];
+ if (req.path.endsWith('.csv')) {
+ const cell = (v) =>
+ '"' +
+ String(v ?? '')
+ .replace(/^([=+@-])/, "'$1")
+ .replace(/"/g, '""') +
+ '"';
+ return res
+ .type('text/csv')
+ .set(
+ 'Content-Disposition',
+ 'attachment; filename="exchange-usage.csv"',
+ )
+ .send(
+ [
+ columns.join(','),
+ ...rows.map((r) =>
+ columns.map((k) => cell(r[k])).join(','),
+ ),
+ ].join('\n'),
+ );
+ }
+ render(req, res, 'Usage', views.usagePage(rows, (keyList && keyList.keys) || []));
+ }),
+ );
+ r.get('/dashboard/credits', (req, res) => render(req, res, 'Credits', views.creditsPage()));
+ r.get(
+ '/dashboard/deployments',
+ safe(async (req, res) => {
+ const data = await ai.ownList(req.viewer.token);
+ render(req, res, 'Deployments', views.deploymentsPage(data.endpoints || []));
+ }),
+ );
+ r.get('/dashboard/settings', (req, res) => render(req, res, 'Settings', views.settingsPage()));
+ return r;
+}
+module.exports = { createDashboardRoutes };
@@ -27,6 +27,7 @@ const { esc, raw } = require('../render/html');
@@ -27,6 +27,7 @@ const { esc, raw } = require('../render/html');
const marks = require('../views/marks');
const marks = require('../views/marks');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const views = require('../views/exchange');
const views = require('../views/exchange');
+const platform = require('../views/platform');
const credentialViews = require('../views/credentials');
const credentialViews = require('../views/credentials');
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
@@ -92,16 +93,19 @@ function createPageRoutes(ctx) {
@@ -92,16 +93,19 @@ function createPageRoutes(ctx) {
.send(JSON.stringify(doc));
.send(JSON.stringify(doc));
});
});
+ r.get('/pricing', (req,res) => page(req,res,{title:'Pricing',body:raw('<div class="ex-wrap xp">'+platform.pricing()+'</div>')}));
+ r.get('/docs', (req,res) => res.redirect(301,'/api'));
+
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
- const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);
+ const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(7)]);
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
return page(req, res, {
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: null,
title: null,
description: 'Every AI model, one account: live prices, speed and uptime of every offer, from the families on sale today and more as their providers join. Chat, or call one OpenAI-compatible endpoint.',
description: 'Every AI model, one account: live prices, speed and uptime of every offer, from the families on sale today and more as their providers join. Chat, or call one OpenAI-compatible endpoint.',
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
- body: raw(views.home({ catalog, usage, viewer: req.viewer, q, config })),
+ body: raw(platform.home({ catalog, usage, viewer: req.viewer, q, config })),
});
});
});
});
@@ -115,7 +119,7 @@ function createPageRoutes(ctx) {
@@ -115,7 +119,7 @@ function createPageRoutes(ctx) {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: q ? `Models: ${q}` : 'Models',
title: q ? `Models: ${q}` : 'Models',
description: 'Every model on OpenVibe.Exchange with its live offers: price in and out per million tokens, measured speed and uptime, context length and data policy.',
description: 'Every model on OpenVibe.Exchange with its live offers: price in and out per million tokens, measured speed and uptime, context length and data policy.',
- body: raw(views.modelsPage({ catalog, q, family, sort, community })),
+ body: raw(platform.modelsPage({ catalog, query: req.query })),
});
});
});
});
@@ -147,6 +151,8 @@ function createPageRoutes(ctx) {
@@ -147,6 +151,8 @@ function createPageRoutes(ctx) {
}, status);
}, status);
}
}
+ r.get('/models/:id', (req, res) => { const parts = req.params.id.split('/'); req.params.org = parts.shift(); req.params.model = parts.join('/'); return renderModel(req, res); });
+ r.get('/models/:org/:model', (req, res) => renderModel(req, res));
r.get('/m/:org/:model', (req, res) => renderModel(req, res));
r.get('/m/:org/:model', (req, res) => renderModel(req, res));
r.post('/m/:org/:model/try', (req, res) => res.redirect(301,
r.post('/m/:org/:model/try', (req, res) => res.redirect(301,
`https://openvibe.chat/vibe?models=${encodeURIComponent(`${req.params.org}/${req.params.model}`)}`));
`https://openvibe.chat/vibe?models=${encodeURIComponent(`${req.params.org}/${req.params.model}`)}`));
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+const express = require('express');
+const kit = require('openvibe-shared/kit');
+const { esc, raw } = require('../render/html');
+const { send } = require('../render/layout');
+const { sameOrigin } = require('./principal');
+function createSearchRoutes({ config, ai }) {
+ const r = express.Router();
+ r.use(express.urlencoded({ extended: false, limit: '8kb' }));
+ const engines = [
+ ['openvibe', 'OpenVibe Search', 'web'],
+ ['searxng', 'SearXNG', 'web · requires an operator instance'],
+ ['openverse', 'Openverse', 'images · CC licences'],
+ ['commons', 'Wikimedia Commons', 'images · attribution'],
+ ['iconify', 'Iconify', 'icons · SVG'],
+ ];
+ const render = (req, res, results = [], status = 200) =>
+ send(res, status, {
+ viewer: req.viewer,
+ config,
+ path: req.originalUrl,
+ title: 'Search',
+ body: raw(
+ `<div class="ex-wrap xp">${kit.head({ title: 'One search API, many engines', sub: 'One key for models, web search, images and icons.' })}<div class="xp-reasons">${engines.map(([id, name, kind]) => kit.section({ title: name, body: `<p>${esc(kind)}</p><p>$0 per 1,000 · no account required</p><p>Freshness: index dependent · BYOK: not required</p>` })).join('')}</div><h2>Try two engines</h2><form method="post" action="/search"><label>Query<input name="q" required maxlength="500"></label><label>Kind<select name="kind"><option>web</option><option>images</option><option>icons</option></select></label>${['first', 'second'].map((k, i) => `<label>${i ? 'Second' : 'First'} engine<select name="${k}">${engines.map(([id, name]) => `<option value="${id}">${name}</option>`).join('')}</select></label>`).join('')}${kit.button({ label: 'Compare results', type: 'submit' })}</form><div class="xp-search-results">${results.map((x) => `<section><h3>${esc(x.engine)}</h3>${x.error ? `<p>${esc(x.error)}</p>` : `<p>$${Number(x.receipt.cost_usd)} · ${Number(x.receipt.time_ms)} ms</p><ol>${x.results.map((r) => `<li><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">${esc(r.title)}</a><p>${esc(r.snippet || r.attribution || '')}</p></li>`).join('')}</ol>`}</section>`).join('')}</div><p>Image search powered by Openverse and Wikimedia Commons. Keep the returned attribution with each image.</p><p>Paid engines and BYOK providers will become available after their owner accounts and prices are configured.</p></div>`,
+ ),
+ });
+ r.get('/search', (req, res) => render(req, res));
+ r.post('/search', async (req, res) => {
+ if (req.viewer?.kind !== 'user')
+ return res.redirect(303, '/auth/login?next=%2Fsearch');
+ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);
+ const kind = ['web', 'images', 'icons'].includes(req.body.kind)
+ ? req.body.kind
+ : 'web';
+ const results = await Promise.all(
+ [req.body.first, req.body.second].map(async (engine) => {
+ try {
+ return {
+ engine,
+ ...(await ai.meRequest(
+ req.viewer.token,
+ '/v1/search' + (kind === 'web' ? '' : '/' + kind),
+ {
+ method: 'POST',
+ body: { q: req.body.q, engine, limit: 6 },
+ },
+ )),
+ };
+ } catch {
+ return {
+ engine,
+ error: 'This engine is unavailable or does not support this search kind.',
+ };
+ }
+ }),
+ );
+ render(req, res, results);
+ });
+ return r;
+}
+module.exports = { createSearchRoutes };
@@ -22,19 +22,11 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -22,19 +22,11 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// "OpenVibe..."). Icons are the navbar's own glyphs; the brand is the way home.
// "OpenVibe..."). Icons are the navbar's own glyphs; the brand is the way home.
const NAV = [
const NAV = [
{ label: 'Models', href: '/models', icon: 'fa-microchip' },
{ label: 'Models', href: '/models', icon: 'fa-microchip' },
- { label: 'Chat', href: 'https://openvibe.chat/vibe', icon: 'fa-comments' },
- { label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },
- { label: 'Rankings', href: '/rankings', icon: 'fa-chart-line' },
- { label: 'Start building', href: '/start', icon: 'fa-bolt' },
- { label: 'API', href: '/api', icon: 'fa-code' },
- { label: 'Your keys and accounts', href: '/privacy/keys', icon: 'fa-key', more: true },
- { label: 'Why this model', href: '/why', icon: 'fa-diagram-project', more: true },
- { label: 'Your own endpoint', href: '/own', icon: 'fa-server', more: true },
- { label: 'Sell compute', href: '/sell', icon: 'fa-microchip', more: true },
- { label: 'Uncensored (18+)', href: '/uncensored', icon: 'fa-lock', more: true },
- { label: 'AI in OpenVibe', href: '/ai', icon: 'fa-circle-nodes', more: true },
- { label: 'Questions', href: '/faq', icon: 'fa-circle-info', more: true },
- { label: 'Status', href: '/status', icon: 'fa-gauge', more: true },
+ { label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },
+ { label: 'Pricing', href: '/pricing', icon: 'fa-tag' },
+ { label: 'Docs', href: '/docs', icon: 'fa-code' },
+ { label: 'Status', href: '/status', icon: 'fa-gauge' },
+ { label: 'Rankings', href: 'https://openvibe.report/ai', icon: 'fa-chart-line', more: true },
];
];
const hashes = new Map();
const hashes = new Map();
@@ -66,6 +58,7 @@ function renderPage(o) {
@@ -66,6 +58,7 @@ function renderPage(o) {
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) })),
...NAV.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') }] : []),
...(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 || SITE_NAME },
@@ -103,6 +96,7 @@ ${appIcon.headTags({ site: 'exchange' })}
@@ -103,6 +96,7 @@ ${appIcon.headTags({ site: 'exchange' })}
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${asset('css/exchange.css')}">
<link rel="stylesheet" href="${asset('css/exchange.css')}">
+<link rel="stylesheet" href="${asset('css/platform.css')}">
<script src="${ovServe.url('motion.js')}" defer></script>
<script src="${ovServe.url('motion.js')}" defer></script>
<script src="${ovServe.url('kit-web.js')}" defer></script>
<script src="${ovServe.url('kit-web.js')}" defer></script>
<script src="${asset('js/exchange.js')}" defer></script>
<script src="${asset('js/exchange.js')}" defer></script>
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
const { esc } = require('../render/html');
const { esc } = require('../render/html');
const SECTIONS = [
const SECTIONS = [
- ['Overview', '/console'], ['Providers', '/console/providers'], ['Models', '/console/models'],
+ ['Overview', '/console'], ['GPU supply (dry run)', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],
['Routes', '/console/routes'], ['Templates', '/console/templates'], ['Workflows', '/console/workflows'],
['Routes', '/console/routes'], ['Templates', '/console/templates'], ['Workflows', '/console/workflows'],
['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],
['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],
['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],
['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],
@@ -21,6 +21,8 @@ function consolePage({ section = 'overview', data = {}, error = null }) {
@@ -21,6 +21,8 @@ function consolePage({ section = 'overview', data = {}, error = null }) {
const nav = SECTIONS.map(([label, href]) => `<a class="ex-console-tab${active === href ? ' is-active' : ''}" href="${href}">${label}</a>`).join('');
const nav = SECTIONS.map(([label, href]) => `<a class="ex-console-tab${active === href ? ' is-active' : ''}" href="${href}">${label}</a>`).join('');
const json = esc(JSON.stringify(safe(data), null, 2));
const json = esc(JSON.stringify(safe(data), null, 2));
let actions = '';
let actions = '';
+ if (section === 'supply') actions = `<p>Dry run only. No GPU is rented or stopped. Missing measurements are shown as missing.</p><form method="post" action="/console/supply" class="ovk-form"><label>Model<input name="model" required></label><label>Runtime<select name="runtime"><option value="vllm">vLLM</option><option value="sglang">SGLang</option></select></label>${[['input_per_million','Input USD / million'],['output_per_million','Output USD / million'],['hourly_ceiling_usd','Hourly spend ceiling USD'],['monthly_ceiling_usd','Monthly spend ceiling USD'],['required_vram_mb','Required VRAM MB'],['idle_minutes','Stop after idle minutes']].map(([key,label])=>`<label>${label}<input name="${key}" type="number" min="0" step="any" required></label>`).join('')}<button class="ovk-button" type="submit">Save prices and ceilings</button></form>`;
+
if (section === 'providers' && Array.isArray(data.providers)) actions = data.providers.map((p) => {
if (section === 'providers' && Array.isArray(data.providers)) actions = data.providers.map((p) => {
const key = encodeURIComponent(p.key || '');
const key = encodeURIComponent(p.key || '');
const next = p.status === 'disabled' ? 'active' : 'disabled';
const next = p.status === 'disabled' ? 'active' : 'disabled';
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * The Dashboard (Opus's design, 2026-11-09): one page per tab, server-rendered, with the kit's tab rail. Keys are cards
+ * with their prefix, cap and models; a new key's secret shows once on its own page; Usage starts with the totals and
+ * a day-by-day bar, then one two-line row per day, key and model (names, not ids), with the CSV beside the title.
+ * Credits, Deployments and Settings each say in one line what lives there and link to it.
+ */
+const kit = require('openvibe-shared/kit');
+const { esc } = require('../render/html');
+
+const TABS = [['keys', 'Keys'], ['usage', 'Usage'], ['credits', 'Credits'], ['deployments', 'Deployments'], ['settings', 'Settings']];
+const money = (v) => {
+ const n = Number(v) || 0;
+ if (n === 0) return '$0';
+ if (n < 0.01) return `$${Number(n.toPrecision(2))}`;
+ return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+};
+const count = (v) => {
+ const n = Number(v) || 0;
+ if (n >= 1e9) return `${(n / 1e9).toFixed(n >= 1e10 ? 0 : 1)}B`;
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;
+ if (n >= 1e3) return `${Number((n / 1e3).toFixed(n >= 1e4 ? 0 : 1))}k`;
+ return n.toLocaleString('en-US');
+};
+const day = (iso) => {
+ const d = new Date(`${String(iso).slice(0, 10)}T12:00:00Z`);
+ return Number.isNaN(d.getTime()) ? String(iso || '') : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
+};
+const plural = (n, one, many = `${one}s`) => `${Number(n).toLocaleString('en-US')} ${Number(n) === 1 ? one : many}`;
+
+/** The frame every tab shares: the page head, the tab rail, the tab's body. */
+function shell(tab, body) {
+ return `<div class="ex-wrap xd">${kit.head({ title: 'Dashboard', sub: 'Keys, usage and endpoints for the Exchange API' })}${kit.tabs({
+ label: 'Dashboard', active: tab, items: TABS.map(([key, label]) => ({ key, label, href: `/dashboard/${key}` })),
+ })}<div class="xd-body">${body}</div></div>`;
+}
+
+function keyCard(k) {
+ const models = Array.isArray(k.allowed_models) && k.allowed_models.length ? plural(k.allowed_models.length, 'model') : 'every model';
+ const made = k.created_at ? `made ${day(k.created_at)}` : '';
+ const revoked = !!k.revoked_at;
+ return `<li class="xd-key${revoked ? ' is-revoked' : ''}"><div class="xd-key-main"><strong>${esc(k.name)}</strong><code>${esc(k.prefix)}…</code></div>`
+ + `<p class="xd-key-meta">${esc([`${money(k.monthly_cap_usd)} a month`, models, revoked ? `revoked ${day(k.revoked_at)}` : made].filter(Boolean).join(' · '))}</p>`
+ + (revoked ? '' : `<form method="post" action="/dashboard/keys/${encodeURIComponent(k.id)}/revoke" class="xd-key-act">${kit.button({ label: 'Revoke', type: 'submit', kind: 'danger', small: true })}</form>`)
+ + '</li>';
+}
+
+function keysPage(keys = []) {
+ const live = keys.filter((k) => !k.revoked_at);
+ const list = keys.length
+ ? `<ul class="xd-keys">${keys.map(keyCard).join('')}</ul>`
+ : kit.empty({ title: 'No keys yet', text: 'Make one below. Its secret shows once, when you make it.' });
+ return shell('keys', `<section class="xd-card"><h2>Your keys${live.length ? `<span class="ovk-count">${live.length}</span>` : ''}</h2>${list}</section>`
+ + `<section class="xd-card" id="new"><h2>New key</h2><p class="xd-note">The secret shows once, when you make it. Calls stop when the month's spend reaches the cap.</p>`
+ + '<form class="xd-form" method="post" action="/dashboard/keys">'
+ + '<label><span>Name</span><input name="name" required maxlength="80" placeholder="My application" autocomplete="off"></label>'
+ + '<label><span>Monthly cap</span><span class="xd-money"><span aria-hidden="true">$</span><input name="monthly_cap_usd" type="number" min="1" step="0.01" value="5" required inputmode="decimal" aria-describedby="cap-hint"></span><small id="cap-hint">Each call holds up to $1 (or its max_cost_usd) until it finishes; the unused part comes back.</small></label>'
+ + '<label><span>Models it may call</span><input name="allowed_models" placeholder="Every model" autocomplete="off" aria-describedby="models-hint"><small id="models-hint">Model ids, comma separated, e.g. deepseek/deepseek-chat. Empty allows every model.</small></label>'
+ + `${kit.button({ label: 'Make the key', type: 'submit' })}</form></section>`);
+}
+
+function newKeyPage(secret, key = null) {
+ return shell('keys', `<section class="xd-card xd-secret"><h2>Copy your key now</h2><p class="xd-note">${key && key.name ? `<strong>${esc(key.name)}</strong>: ` : ''}this is the only time it shows. Keep it in your app's secrets; anyone with it can spend up to its cap.</p>`
+ + `${kit.snippet({ code: secret, id: 'new-key' })}<p class="xd-actions">${kit.button({ href: '/dashboard/keys', label: 'Done' })}</p></section>`);
+}
+
+/** Usage rows from the API (by day, key, model and status) with key names; the newest day first. */
+function usagePage(rows = [], keys = []) {
+ const names = new Map(keys.map((k) => [k.id, k.name]));
+ const totals = rows.reduce((t, r) => ({
+ cost: t.cost + (Number(r.cost_usd) || 0), requests: t.requests + (Number(r.requests) || 0),
+ tokens: t.tokens + (Number(r.tokens_in) || 0) + (Number(r.tokens_out) || 0),
+ }), { cost: 0, requests: 0, tokens: 0 });
+ const days = [...new Set(rows.map((r) => String(r.day).slice(0, 10)))].sort();
+ const head = `<div class="xd-title"><h2>Usage</h2>${rows.length ? `<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/dashboard/usage.csv" download><span>CSV</span></a>` : ''}</div>`;
+ if (!rows.length) return shell('usage', `<section class="xd-card">${head}${kit.empty({ title: 'No usage yet', text: 'Calls made with your keys show here by day, key and model, with their tokens and cost.' })}</section>`);
+ const span = days.length > 1 ? `${day(days[0])} – ${day(days[days.length - 1])}` : day(days[0]);
+ const stat = (v, label) => `<div><dd>${esc(v)}</dd><dt>${esc(label)}</dt></div>`;
+ // Requests per day, the last 14 days that have any: bars scaled to the busiest one (the rows below hold the numbers).
+ const shown = days.slice(-14);
+ const perDay = shown.map((d) => [d, rows.filter((r) => String(r.day).slice(0, 10) === d).reduce((n, r) => n + (Number(r.requests) || 0), 0)]);
+ const peak = Math.max(1, ...perDay.map(([, n]) => n));
+ const bars = `<div class="xd-bars" aria-hidden="true">${perDay.map(([d, n]) => `<span title="${esc(`${day(d)}: ${plural(n, 'request')}`)}"><i style="height:${Math.max(4, Math.round((n / peak) * 100))}%"></i><b>${esc(day(d).replace(/^[A-Za-z]+ /, ''))}</b></span>`).join('')}</div>`;
+ const byDay = [...days].reverse().map((d) => {
+ const items = rows.filter((r) => String(r.day).slice(0, 10) === d)
+ .sort((a, b) => (Number(b.cost_usd) || 0) - (Number(a.cost_usd) || 0))
+ .map((r) => {
+ const key = names.get(r.key_id) || r.key_id;
+ const state = r.status && r.status !== 'settled' ? `<span class="xd-chip">${esc(r.status)}</span>` : '';
+ return `<li><div class="xd-use-top"><code>${esc(r.model)}</code><strong>${esc(money(r.cost_usd))}</strong></div>`
+ + `<p>${esc([key, plural(r.requests, 'request'), `${count(r.tokens_in)} in · ${count(r.tokens_out)} out`].join(' · '))}${state}</p></li>`;
+ }).join('');
+ return `<h3 class="xd-day">${esc(day(d))}</h3><ul class="xd-use">${items}</ul>`;
+ }).join('');
+ return shell('usage', `<section class="xd-card">${head}<p class="xd-note">${esc(span)}</p>`
+ + `<dl class="xd-stats">${stat(money(totals.cost), 'spent')}${stat(count(totals.requests), 'requests')}${stat(count(totals.tokens), 'tokens')}</dl>`
+ + `${bars}</section><section class="xd-card"><h2 class="xd-sub">By day, key and model</h2>${byDay}</section>`);
+}
+
+function creditsPage() {
+ return shell('credits', '<section class="xd-card"><h2>Credits</h2><p class="xd-note">Credits pay for managed models. Your balance, top-ups and auto top-up live in your OpenVibe wallet, the same one on every OpenVibe site.</p>'
+ + `<p class="xd-actions">${kit.button({ href: 'https://openvibe.network/my#wallet', label: 'Open your wallet' })}</p>`
+ + '<p class="xd-note">With your own provider key, calls cost the provider\'s price and nothing more: 0% fee, no cap.</p></section>');
+}
+
+function deploymentsPage(endpoints = []) {
+ const rows = endpoints.map((e) => `<li><div class="xd-use-top"><code>${esc(e.model || '')}</code><span class="xd-chip">${esc(e.status || '')}</span></div><p>${esc(e.id || '')}${e.price_per_hour != null ? ` · ${esc(money(e.price_per_hour))} an hour` : ''}</p></li>`).join('');
+ return shell('deployments', '<section class="xd-card"><h2>Deployments</h2><p class="xd-note">Your own endpoint: a model on a GPU of its own, private to you. The hourly price shows before it starts, and it stops when idle.</p>'
+ + `<p class="xd-actions">${kit.button({ href: '/own', label: 'Price an endpoint' })}</p>`
+ + (rows ? `<ul class="xd-use">${rows}</ul>` : kit.empty({ title: 'No deployments yet', text: 'An endpoint you start shows here with its status and price.' }))
+ + '</section>');
+}
+
+function settingsPage() {
+ const item = (href, title, sub) => kit.row({ href, title, sub, trail: kit.icon('chevron') });
+ return shell('settings', `<section class="xd-card"><h2>Settings</h2>${kit.list([
+ item('/privacy/keys', 'Your provider keys', 'Bring your own key at a 0% fee, and choose the data policy for your calls.'),
+ item('/api#privacy', 'Routing and strict privacy', 'Prefer the cheapest or the fastest route; keep prompts out of every log.'),
+ item('/uncensored', 'Adult models (18+)', 'Opt in to uncensored models. The content floor applies on every request.'),
+ ], { flush: true })}</section>`);
+}
+
+function unavailablePage() {
+ return shell('', '<section class="xd-card">' + kit.empty({ title: 'The account API did not answer', text: 'Nothing was changed. Try again in a moment.' }) + '</section>');
+}
+
+module.exports = { keysPage, newKeyPage, usagePage, creditsPage, deploymentsPage, settingsPage, unavailablePage, TABS };
@@ -318,7 +318,9 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
@@ -318,7 +318,9 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
{ key: 'provider', label: 'Offer', primary: true, html: true },
{ key: 'provider', label: 'Offer', primary: true, html: true },
{ key: 'price', label: 'In / out per M', html: true },
{ key: 'price', label: 'In / out per M', html: true },
{ key: 'speed', label: 'Speed', html: true },
{ key: 'speed', label: 'Speed', html: true },
+ { key: 'first', label: 'First token' },
{ key: 'uptime', label: 'Uptime' },
{ key: 'uptime', label: 'Uptime' },
+ { key: 'errors', label: 'Errors' },
{ key: 'privacy', label: 'Privacy', html: true },
{ key: 'privacy', label: 'Privacy', html: true },
{ key: 'policy', label: 'Data policy', html: true },
{ key: 'policy', label: 'Data policy', html: true },
{ key: 'try', label: 'Try', html: true },
{ key: 'try', label: 'Try', html: true },
@@ -333,9 +335,11 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
@@ -333,9 +335,11 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
const refuses = refusalText(o);
const refuses = refusalText(o);
return {
return {
provider: `<a class="ex-offer-name" href="#try">${esc(providerLabel(o))}</a> ${communityBadge}${freeTier(o) ? ` ${kit.badge('Free to try')}` : (freePaused(o) ? ` ${kit.badge('Free paused')}` : '')}`,
provider: `<a class="ex-offer-name" href="#try">${esc(providerLabel(o))}</a> ${communityBadge}${freeTier(o) ? ` ${kit.badge('Free to try')}` : (freePaused(o) ? ` ${kit.badge('Free paused')}` : '')}`,
- price: o.price ? `<b>${money(o.price.in)}</b> / ${money(o.price.out)}` : '<span class="ex-muted">free capacity</span>',
+ price: o.price ? `<b>${money(o.price.in)}</b> / ${money(o.price.out)}${o.fixed_price ? ` ${kit.badge('Fixed price')}` : ''}` : '<span class="ex-muted">free capacity</span>',
speed: `${ms(o.speed.p50_ms)} <small>p50</small> · ${ms(o.speed.p95_ms)} <small>p95</small>`,
speed: `${ms(o.speed.p50_ms)} <small>p50</small> · ${ms(o.speed.p95_ms)} <small>p95</small>`,
+ first: o.speed && o.speed.ttft_ms != null ? ms(o.speed.ttft_ms) : '–',
uptime: o.uptime == null ? '–' : pct(o.uptime),
uptime: o.uptime == null ? '–' : pct(o.uptime),
+ errors: o.error_rate == null ? '–' : pct(o.error_rate),
privacy: `${p ? kit.badge(p.label, p.kind) : ''}${refuses ? `<br><small>${esc(refuses)}</small>` : ''}` || '–',
privacy: `${p ? kit.badge(p.label, p.kind) : ''}${refuses ? `<br><small>${esc(refuses)}</small>` : ''}` || '–',
policy: policyText(o.data_policy),
policy: policyText(o.data_policy),
try: `<a class="ex-mini" href="${chatModel(model)}">Try it</a>`,
try: `<a class="ex-mini" href="${chatModel(model)}">Try it</a>`,
@@ -352,14 +356,27 @@ ${kit.head({ title: model.name, sub: `${familyLabel(model)}${model.variant ? `
@@ -352,14 +356,27 @@ ${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.' }) })}
+${activityPanel(model.activity)}
${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() })}
-${panel({ title: 'Try it', id: 'try', body: kit.button({ href: chatModel(model), label: `Chat with ${model.name}` }) })}
+${panel({ title: 'Try it', id: 'try', body: `<p class="ex-actions">${kit.button({ href: chatModel(model), label: `Chat with ${model.name}` })} ${kit.button({ href: `https://openvibe.report/ai/models/${encodeURIComponent(model.slug || model.canonical)}`, label: 'How it ranks', kind: 'quiet' })}</p>` })}
${panel({ title: 'Use it from code', id: 'code', body: `${kit.snippet({ code, id: 'model-curl' })}
${panel({ title: 'Use it from code', id: 'code', body: `${kit.snippet({ code, id: 'model-curl' })}
${kit.snippet({ code: sdk, id: 'model-sdk' })}
${kit.snippet({ code: sdk, id: 'model-sdk' })}
<p class="ex-more">The endpoint, keys and usage: <a href="/api">the API</a>.</p>` })}
<p class="ex-more">The endpoint, keys and usage: <a href="/api">the API</a>.</p>` })}
</div>`;
</div>`;
}
}
+/** Requests a day across the model's offers, the last 14 days that have any: bars, with the numbers as their labels. */
+function activityPanel(activity) {
+ const days = Array.isArray(activity) ? activity.slice(-14) : [];
+ if (!days.length) return '';
+ const peak = Math.max(1, ...days.map((d) => Number(d.requests) || 0));
+ const bars = days.map((d) => {
+ const n = Number(d.requests) || 0;
+ return `<li><span class="ex-act-bar" style="height:${Math.max(4, Math.round((n / peak) * 100))}%"></span><span class="ex-act-day">${esc(String(d.day).slice(8, 10))}</span><span class="ex-sr">${esc(String(d.day))}: ${n.toLocaleString('en-US')} requests</span></li>`;
+ }).join('');
+ return panel({ title: 'Activity', note: 'Requests a day across every offer.', id: 'activity', body: `<ol class="ex-act">${bars}</ol>` });
+}
+
const PRESETS = [
const PRESETS = [
['openvibe/economy', 'Cheapest', 'The lowest cost per successful answer.'],
['openvibe/economy', 'Cheapest', 'The lowest cost per successful answer.'],
['openvibe/fast', 'Fastest', 'The quickest measured offer.'],
['openvibe/fast', 'Fastest', 'The quickest measured offer.'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+'use strict';
+const kit = require('openvibe-shared/kit');
+const legacy = require('./exchange');
+const listed = (catalog) => legacy.chatModels(catalog);
+const unavailable = (catalog) =>
+ catalog.unavailable
+ ? '<p role="status">The live catalogue is not reachable right now.</p>'
+ : '';
+const { esc } = require('../render/html');
+const free = (o) =>
+ o.free_now === undefined ? o.free_tier === true : o.free_now === true;
+const paused = (o) => o.free_tier === true && o.free_now === false;
+const id = (m) => m.slug || m.canonical;
+const price = (v) =>
+ v == null
+ ? 'Not priced'
+ : `$${Number(v).toLocaleString('en-US', { maximumFractionDigits: 4 })}`;
+const metric = (v, unit = '') =>
+ v == null
+ ? 'Not measured'
+ : `${Number(v).toLocaleString('en-US', { maximumFractionDigits: 2 })}${unit}`;
+const best = (m) =>
+ [...(m.offers || [])]
+ .filter((o) => o.price)
+ .sort(
+ (a, b) => a.price.in + a.price.out - b.price.in - b.price.out,
+ )[0] ||
+ (m.offers || [])[0] ||
+ {};
+const chat = (m) =>
+ `https://openvibe.chat/vibe?models=${encodeURIComponent(id(m))}`;
+const rank = (m) =>
+ `https://openvibe.report/ai/models/${encodeURIComponent(id(m))}`;
+const section = (title, body) => kit.section({ title, body });
+const code = (m) =>
+ kit.snippet({
+ id: 'platform-curl',
+ code: `curl https://api.openvibe.exchange/v1/chat/completions \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '${JSON.stringify({ model: m || 'openvibe/auto', messages: [{ role: 'user', content: 'Hello' }] })}'`,
+ });
+function table(models) {
+ return `<div class="xp-models"><table><thead><tr><th>Model</th><th>Input / output per million</th><th>Tokens/s</th><th>Uptime</th><th>Explore</th></tr></thead><tbody>${models
+ .map((m) => {
+ const o = best(m);
+ return `<tr><td><a href="/models/${encodeURIComponent(id(m))}"><strong>${esc(m.name)}</strong></a><small>${esc(legacy.familyLabel(m))}${(m.offers || []).some(free) ? ' · Free to try' : (m.offers || []).some(paused) ? ' · Free paused' : ''}${m.uncensored ? ' · 18+' : ''}</small></td><td data-label="Input / output per million">${price(o.price?.in)} / ${price(o.price?.out)}${o.fixed_price ? ' · Fixed' : ''}</td><td data-label="Tokens/s">${metric(o.speed?.tokens_per_second)}</td><td data-label="Uptime">${metric(o.uptime == null ? null : o.uptime * 100, '%')}</td><td><a href="${chat(m)}">Try in Chat</a><a href="${rank(m)}">How it ranks →</a></td></tr>`;
+ })
+ .join(
+ '',
+ )}</tbody></table>${!models.length ? '<p>No models match these filters.</p>' : ''}</div>`;
+}
+function pricing() {
+ return section(
+ 'Pricing',
+ `<div class="xp-models"><table><thead><tr><th>Route</th><th>What you pay</th></tr></thead><tbody><tr><td>Your provider key</td><td>Provider price + 0% fee, forever, no cap</td></tr><tr><td>Managed models</td><td>The input and output rates listed on each model</td></tr><tr><td>Our GPUs</td><td>Fixed input and output prices set by the owner</td></tr><tr><td>Your deployment</td><td>The quoted hourly price, shown before starting</td></tr><tr><td>Search</td><td>Engine price per 1,000 searches; free engines first</td></tr></tbody></table></div><p>Paid models open soon. Every price shows before you call, and nothing is charged until then.</p>`,
+ );
+}
+function home({ catalog, usage, viewer }) {
+ const models = listed(catalog);
+ return `<div class="ex-wrap xp">${unavailable(catalog)}<section class="xp-hero"><p class="ex-kicker">OpenVibe.Exchange</p><h1>One API for every model.<br>Pay what the model costs.</h1>${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model', icon: 'search' })}<p>${kit.button({ href: viewer?.kind === 'user' ? '/dashboard/keys#new' : '/auth/login?next=%2Fdashboard%2Fkeys%23new', label: 'Get a key' })} ${kit.button({ href: '/docs', label: 'Read the docs', kind: 'quiet' })}</p>${code()}</section>${kit.stats(
+ [
+ [models.length, 'models'],
+ [
+ metric(
+ usage?.totals
+ ? Number(usage.totals.tokens_in || 0) +
+ Number(usage.totals.tokens_out || 0)
+ : null,
+ ),
+ 'tokens routed this week',
+ ],
+ [
+ metric(catalog.median_ttft_ms, ' ms'),
+ 'median time to first token',
+ ],
+ ],
+ )}${section('Popular models', table([...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 10)))}<div class="xp-reasons">${section('0% fee on your own key', '<p>Bring your provider key and pay the provider\'s price, nothing more. No cap, no end date.</p>')}${section('The cheapest route', '<p>Every call goes to the cheapest provider that is up. Open models on OpenVibe\'s own GPUs, at a fixed price per million tokens, are coming next.</p>')}${section('Uncensored, 18+', '<p>Uncensored models for adults who opt in. The content floor applies on every request.</p><a href="/uncensored">What the floor covers</a>')}</div>${pricing()}${section('Questions', '<details><summary>Can I use my existing client?</summary><p>Yes. Use the OpenAI-compatible endpoint with your Exchange key.</p></details><details><summary>Where can I talk to a model?</summary><p><a href="https://openvibe.chat/vibe">Open Chat</a> to talk or compare models.</p></details><details><summary>Where do the numbers come from?</summary><p>Prices come from the live catalogue. Speed and reliability come from measured requests. Missing measurements stay unknown.</p></details>')}</div>`;
+}
+function modelsPage({ catalog, query = {} }) {
+ const all = listed(catalog);
+ let rows = all;
+ if (query.family && !query.maker) query = { ...query, maker: query.family };
+ const tests = {
+ maker: (m, v) => m.family === v,
+ weights: (m) => m.open_weights === true,
+ price: (m, v) =>
+ v === 'free'
+ ? best(m).price?.in === 0 && best(m).price?.out === 0
+ : best(m).price && best(m).price.in + best(m).price.out <= 2,
+ context: (m) => m.context >= 128000,
+ uncensored: (m) => m.uncensored,
+ gpu: (m) => (m.offers || []).some((o) => o.on_our_gpus === true),
+ };
+ const chip = (label, key, value, count) => {
+ const q = new URLSearchParams(
+ Object.entries(query).filter(([, v]) => typeof v === 'string'),
+ );
+ if (q.get(key) === value) q.delete(key);
+ else q.set(key, value);
+ return `<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(q.toString())}">${esc(label)} (${count})</a>`;
+ };
+ for (const [key, test] of Object.entries(tests))
+ if (query[key]) rows = rows.filter((m) => test(m, query[key]));
+ if (query.q)
+ rows = rows.filter((m) =>
+ `${m.name} ${id(m)} ${m.family}`
+ .toLowerCase()
+ .includes(String(query.q).toLowerCase()),
+ );
+ const chips = [...new Set(all.map((m) => m.family).filter(Boolean))].map(
+ (v) => chip(v, 'maker', v, all.filter((m) => tests.maker(m, v)).length),
+ );
+ for (const [key, label, value] of [
+ ['weights', 'Open weights', 'open'],
+ ['price', 'Free', 'free'],
+ ['price', 'Under $1/M blended', 'low'],
+ ['context', '128K+ context', '128k'],
+ ['uncensored', 'Uncensored 18+', '1'],
+ ['gpu', 'On our GPUs', '1'],
+ ])
+ chips.push(
+ chip(
+ label,
+ key,
+ value,
+ all.filter((m) => tests[key](m, value)).length,
+ ),
+ );
+ return `<div class="ex-wrap xp">${kit.head({ title: 'Models', sub: `${rows.length} models · live prices and measured performance` })}${unavailable(catalog)}${kit.find({ action: '/models', name: 'q', value: query.q || '', placeholder: 'Find a model', icon: 'search' })}<nav class="ex-chips" aria-label="Model filters">${chips.join('')}</nav>${table(rows)}</div>`;
+}
+module.exports = { home, modelsPage, pricing, table };
@@ -20,6 +20,17 @@ const { classify } = require('../server/arena/category');
@@ -20,6 +20,17 @@ const { classify } = require('../server/arena/category');
});
});
const bench = createFakeBench();
const bench = createFakeBench();
const t = await boot({ ai, bench });
const t = await boot({ ai, bench });
+ // Arena HTML now lives on Report; exercise the same private read through Exchange's JSON API.
+ const get = t.get;
+ t.get = async (path, options = {}) => {
+ if (/^\/arena(?:\/|$)/.test(path) && !options.form && !options.json && !options.method) {
+ const response = await get(path.replace(/^\/arena/, '/api/v1/arena/rounds'), options);
+ try { response.text = response.json().html || response.text; } catch {}
+ return response;
+ }
+ return get(path, options);
+ };
+
const alice = t.network.addUser('alice');
const alice = t.network.addUser('alice');
const bob = t.network.addUser('bob');
const bob = t.network.addUser('bob');
@@ -69,7 +80,7 @@ const { classify } = require('../server/arena/category');
@@ -69,7 +80,7 @@ const { classify } = require('../server/arena/category');
assert.ok(!r.text.includes('Qwen3 32B'), 'no model name before the vote');
assert.ok(!r.text.includes('Qwen3 32B'), 'no model name before the vote');
assert.ok(!r.text.includes('Receipt'), 'no receipt before the vote');
assert.ok(!r.text.includes('Receipt'), 'no receipt before the vote');
assert.ok(r.text.includes('A is better') && r.text.includes('Both bad'));
assert.ok(r.text.includes('A is better') && r.text.includes('Both bad'));
- assert.ok(r.text.includes('code'), 'the task category is said');
+ assert.match(r.text, /Code|code/, 'the task category is said');
});
});
await check('a vote is the person’s own Bench judgement, once', async () => {
await check('a vote is the person’s own Bench judgement, once', async () => {
@@ -10,8 +10,8 @@ assert.ok(inBar.length <= 6, `at most six places in the bar, not ${inBar.length}
@@ -10,8 +10,8 @@ assert.ok(inBar.length <= 6, `at most six places in the bar, not ${inBar.length}
assert.ok(NAV.every((l) => /^fa-[a-z0-9-]+$/.test(l.icon || '')), 'every place has an icon');
assert.ok(NAV.every((l) => /^fa-[a-z0-9-]+$/.test(l.icon || '')), 'every place has an icon');
assert.ok(!NAV.some((l) => l.href === '/'), 'the brand is the way home');
assert.ok(!NAV.some((l) => l.href === '/'), 'the brand is the way home');
assert.deepStrictEqual(new Set(NAV.map((l) => l.href)).size, NAV.length, 'no place twice');
assert.deepStrictEqual(new Set(NAV.map((l) => l.href)).size, NAV.length, 'no place twice');
-for (const want of ['/models', 'https://openvibe.chat/vibe', '/arena', '/start', '/api', '/why', '/own', '/sell', '/uncensored', '/ai', '/faq', '/status']) {
+for (const want of ['/models', '/search', '/pricing', '/docs', '/status', 'https://openvibe.report/ai']) {
assert.ok(NAV.some((l) => l.href === want), `${want} is reachable from the bar or More`);
assert.ok(NAV.some((l) => l.href === want), `${want} is reachable from the bar or More`);
}
}
-if (Array.isArray(PAGES)) for (const l of NAV) assert.ok(PAGES.some((p) => p.path === l.href) || ['https://openvibe.chat/vibe', '/status', '/arena', '/rankings'].includes(l.href), `${l.href} is a page`);
+assert.ok(!NAV.some(l => ['/chat','/arena','/sell','/ai'].includes(l.href)), 'moved products leave the navigation');
console.log('nav: six places in the bar with icons, the rest under More, every page reachable');
console.log('nav: six places in the bar with icons, the rest under More, every page reachable');
@@ -12,37 +12,20 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -12,37 +12,20 @@ const { createFakeAi, downAi } = require('./helpers/ai');
const ai = createFakeAi();
const ai = createFakeAi();
const t = await boot({ ai });
const t = await boot({ ai });
try {
try {
- await check('the home is the new Exchange, with live numbers', async () => {
- const r = await t.get('/');
- assert.strictEqual(r.status, 200, r.text);
- assert.match(r.headers.get('cache-control'), /^public, max-age=60/);
- assert.ok(r.text.includes('Every AI model, one account.'), 'the new promise');
- assert.ok(!r.text.includes('Trade what people make'), 'v1’s promise is gone');
- assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Llama 3 8B abliterated'));
- assert.ok(r.text.includes('Popular now') && r.text.includes('Uncensored (18+)') && r.text.includes('Run on people’s machines'));
- assert.ok(r.text.includes('models') && r.text.includes('providers') && r.text.includes('cheapest in, per million'), 'the live numbers');
- assert.ok(r.text.includes('messages free every day'), 'the free allowance');
- assert.ok(!r.text.includes('requests today'), 'fewer than 1,000 requests today is not shown');
- assert.ok(r.text.includes('A side-by-side answer') && r.text.includes('example words · live prices'), 'the preview is marked an example');
- assert.ok(r.text.includes('api.openvibe.exchange/v1/chat/completions'), 'the endpoint');
- const lead = (r.text.match(/<p class="ex-lead">([\s\S]*?)<\/p>/) || [])[1] || '';
- assert.ok(/DeepSeek/.test(lead) && /Qwen/.test(lead) && /Llama/.test(lead), 'the hero names the families on sale: ' + lead);
- assert.ok(!/Kimi|GLM|MiniMax/.test(lead), 'the hero never names a family nobody sells: ' + lead);
- const answers = [...r.text.matchAll(/<article class="ex-preview-cell">[\s\S]*?<p>([\s\S]*?)<\/p>/g)].map((m) => m[1]);
- assert.strictEqual(answers.length, 2, 'two preview answers');
- assert.notStrictEqual(answers[0], answers[1], 'the two example answers differ');
- const { displayName } = require('../server/ai.js');
- assert.deepStrictEqual(['gpt-5-nano', 'gpt-4.1-mini', 'gpt-4o-mini', 'gpt-5', 'gpt-oss-120b', 'DeepSeek V4 Pro'].map(displayName), ['GPT-5 nano', 'GPT-4.1 mini', 'GPT-4o mini', 'GPT-5', 'gpt-oss-120b', 'DeepSeek V4 Pro'], 'OpenAI models are named the way OpenAI says them');
- assert.ok(!/ · – · –|– · –/.test(r.text), 'an unmeasured stat is left out, not printed as a dash');
- assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
- assert.ok(r.text.includes('<main id="main"') && r.text.includes('<footer id="ov-footer"'));
+ await check('home puts a key and live model prices first', async () => {
+ const r = await t.get('/'); assert.strictEqual(r.status,200);
+ assert.ok(r.text.includes('One API for every model.') && r.text.includes('Pay what the model costs.'));
+ assert.ok(r.text.includes('Get a key') && r.text.includes('api.openvibe.exchange/v1/chat/completions'));
+ assert.ok(r.text.includes('tokens routed this week') && r.text.includes('median time to first token'));
+ assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Try in Chat') && r.text.includes('How it ranks'));
+ assert.strictEqual((r.text.match(/<h1[\s>]/g)||[]).length,1);
});
});
await check('models lists every model with its cheapest offer', async () => {
await check('models lists every model with its cheapest offer', async () => {
const r = await t.get('/models');
const r = await t.get('/models');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('DeepSeek Chat'));
assert.ok(r.text.includes('DeepSeek Chat'));
- assert.ok(r.text.includes('$0.20 in'), 'the cheapest offer, not the dearest');
+ assert.ok(r.text.includes('$0.2 / $0.3'), 'the cheapest offer, not the dearest');
const filtered = await t.get('/models?q=llama');
const filtered = await t.get('/models?q=llama');
assert.ok(filtered.text.includes('Llama 3 8B abliterated'));
assert.ok(filtered.text.includes('Llama 3 8B abliterated'));
assert.ok(!filtered.text.includes('DeepSeek Chat'), 'the search filters');
assert.ok(!filtered.text.includes('DeepSeek Chat'), 'the search filters');
@@ -51,6 +34,17 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -51,6 +34,17 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(!family.text.includes('Llama 3 8B'));
assert.ok(!family.text.includes('Llama 3 8B'));
});
});
+ await check('a model at /models/…: the same page, with first token, errors and How it ranks', async () => {
+ const r = await t.get('/models/deepseek%2Fdeepseek-chat');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('DeepSeek') && r.text.includes('DeepInfra'));
+ assert.ok(r.text.includes('First token') && r.text.includes('Errors'), 'the providers tab columns');
+ assert.ok(r.text.includes('openvibe.chat/vibe?models=deepseek%2Fdeepseek-chat'));
+ assert.ok(r.text.includes('openvibe.report/ai/models/deepseek%2Fdeepseek-chat'), 'How it ranks');
+ assert.ok(r.text.includes('api.openvibe.exchange'));
+ assert.strictEqual((await t.get('/models/nope%2Fnope')).status, 404);
+ });
+
await check('one model: every offer, its price, speed, uptime, data policy, Try it, curl and SDK', async () => {
await check('one model: every offer, its price, speed, uptime, data policy, Try it, curl and SDK', async () => {
const r = await t.get('/m/deepseek/deepseek-chat');
const r = await t.get('/m/deepseek/deepseek-chat');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
@@ -78,17 +72,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -78,17 +72,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(r.text.includes('2026-11-05') && r.text.includes('Requests'), 'usage by day');
assert.ok(r.text.includes('2026-11-05') && r.text.includes('Requests'), 'usage by day');
});
});
- await check('sell: the offer flow, and what is not sold, said plainly', async () => {
- const r = await t.get('/sell');
- assert.strictEqual(r.status, 200);
- assert.ok(r.text.includes('Offer to the network'));
- assert.ok(r.text.includes('Personal subscription plans'));
- assert.ok(r.text.includes('Personal API keys'));
- assert.ok(r.text.includes('What is sold'));
- assert.ok(r.text.includes('Owner’s machine') || r.text.includes('No community capacity yet'));
- assert.ok(r.text.includes('Idle-only') && r.text.includes('Kudos') && r.text.includes('sees the prompt'), 'idle-only, kudos and the privacy sentence');
- assert.ok(r.text.includes('A community machine') && !r.text.includes('@name'), 'the seller card names the machine, never a placeholder or a person');
- assert.ok(r.text.includes('not verified yet'), 'an offer is said verified only when it is');
+ await check('seller entry moves permanently to Link', async () => {
+ const r = await t.get('/sell'); assert.strictEqual(r.status,301);
+ assert.strictEqual(r.headers.get('location'),'https://openvibe.link');
});
});
await check('uncensored: sign-in first, then the 18+ gate and the floor', async () => {
await check('uncensored: sign-in first, then the 18+ gate and the floor', async () => {
@@ -102,12 +88,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -102,12 +88,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(r.text.includes('Non-consensual sexual content (fiction included) and incest are refused, because payment providers and hosts ban them.'));
assert.ok(r.text.includes('Non-consensual sexual content (fiction included) and incest are refused, because payment providers and hosts ban them.'));
});
});
- await check('AI in OpenVibe: which product runs which model, and the daily cost', async () => {
- const r = await t.get('/ai');
- assert.strictEqual(r.status, 200);
- assert.ok(r.text.includes('OpenVibe.Live'), 'the product’s full name, not “live”');
- assert.ok(r.text.includes('deepseek-chat'));
- assert.ok(r.text.includes('$0.0002'), 'the cost per day (0.006 over 30 days)');
+ await check('product usage moves permanently to Report', async () => {
+ const r = await t.get('/ai'); assert.strictEqual(r.status,301);
+ assert.strictEqual(r.headers.get('location'),'https://openvibe.report/ai/exchange');
});
});
await check('Free to try is shown only where an offer says free_tier', async () => {
await check('Free to try is shown only where an offer says free_tier', async () => {
@@ -228,7 +211,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -228,7 +211,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');
const down = await boot({ ai: downAi });
const down = await boot({ ai: downAi });
try {
try {
await check('AI unreachable: every page still renders and says the numbers are not live', async () => {
await check('AI unreachable: every page still renders and says the numbers are not live', async () => {
- for (const p of ['/', '/models', '/api', '/sell', '/ai', '/status']) {
+ for (const p of ['/', '/models', '/api', '/status']) {
const r = await down.get(p);
const r = await down.get(p);
assert.strictEqual(r.status, 200, p);
assert.strictEqual(r.status, 200, p);
assert.ok(!/NaN|undefined/.test(r.text.replace(/undefined"/g, '')), `${p} invents no number`);
assert.ok(!/NaN|undefined/.test(r.text.replace(/undefined"/g, '')), `${p} invents no number`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+const { createFakeAi } = require('./helpers/ai');
+(async () => {
+ const calls = [];
+ const ai = {
+ ...createFakeAi(),
+ meRequest: async (token, path, options) => {
+ calls.push({ path, options });
+ return path.endsWith('key-usage')
+ ? {
+ rows: [
+ {
+ day: '2026-11-09',
+ key_id: 'test',
+ model: '=formula',
+ requests: 1,
+ tokens_in: 1,
+ tokens_out: 2,
+ cost_usd: 0,
+ status: 'settled',
+ },
+ ],
+ }
+ : { keys: [] };
+ },
+ };
+ const t = await boot({ ai });
+ try {
+ const user = t.network.addUser('builder');
+ await check(
+ 'dashboard needs sign in, private responses do not cache',
+ async () => {
+ assert.equal((await t.get('/dashboard')).status, 303);
+ const r = await t.get('/dashboard/keys', { as: user });
+ assert.equal(r.status, 200);
+ assert.ok(r.headers.get('cache-control').includes('no-store'));
+ assert.ok(r.text.includes('Monthly cap'));
+ },
+ );
+ await check('foreign site cannot create keys', async () => {
+ const n = calls.length;
+ assert.equal(
+ (
+ await t.get('/dashboard/keys', {
+ as: user,
+ origin: 'https://evil.example',
+ form: { name: 'Bad' },
+ })
+ ).status,
+ 403,
+ );
+ assert.equal(calls.length, n);
+ });
+ await check('CSV escapes formula cells', async () => {
+ const r = await t.get('/dashboard/usage.csv', { as: user });
+ assert.equal(r.status, 200);
+ assert.ok(r.text.includes("'=formula"));
+ });
+ await check(
+ 'Arena moves permanently and its API refuses anonymous mutations',
+ async () => {
+ assert.equal((await t.get('/arena')).status, 301);
+ assert.equal(
+ (
+ await t.get('/api/v1/arena/rounds', {
+ json: { prompt: 'hello' },
+ })
+ ).status,
+ 401,
+ );
+ },
+ );
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+});
@@ -1,174 +1,40 @@
@@ -1,174 +1,40 @@
'use strict';
'use strict';
-/**
- * Pooled machines, part 4 (design 2026-11-06): "Pool your machines" on /sell and the pool's offer card. The page
- * reads the person's machines, pools, link checks and fit table from Network and AI (the mock Network here); it
- * measures nothing itself. Every number is one that was measured; the rest says "not measured yet".
- */
-const assert = require('assert');
-const { ids } = require('openvibe-contracts');
+// Pool management moved to Link; its old page fixtures are retained in test/retired/pools.test.js.
+const assert = require('node:assert/strict');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { createFakeAi } = require('./helpers/ai');
const { createFakeAi } = require('./helpers/ai');
-
-const dev = () => `dev_${ids.ulid()}`;
-const poolOffer = (models) => ({
- offer_id: 'community_abc123', kind: 'pool', label: 'Community pool (3 machines)', models,
- pool: { machines: ['pseudonym-a', 'pseudonym-b', 'pseudonym-c'], head: 'pseudonym-a' },
- free_only: true, verified: false, hours: { start: '00:00', end: '00:00', tz: 'UTC' }, concurrency: 1,
- price: { in_per_million: 1, out_per_million: 2 }, audience: 'everyone', health: { status: 'up' },
- open_now: true, ready: true,
-});
-
(async () => {
(async () => {
- const ai = createFakeAi({ community: [poolOffer(['llama-3-8b-abliterated'])] });
- const t = await boot({ ai });
- const user = t.network.addUser('alice');
- const desktop = dev(), laptop = dev(), rack = dev(), other = dev();
- const announce = (id, network, freeVram, hardware) => ({ id, free_vram: freeVram, free_ram: 32000, hardware, network });
-
+ const t = await boot({ ai: createFakeAi() });
try {
try {
- await check('signed out, /sell has no pool card (it needs a person’s machines)', async () => {
- const r = await t.get('/sell');
- assert.strictEqual(r.status, 200, r.text);
- assert.ok(!r.text.includes('Pool your machines'), 'no pool card when signed out');
- });
-
- await check('fewer than two machines: one line and the install link', async () => {
- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }];
- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090')];
- t.network.state.pools = [];
- const r = await t.get('/sell', { as: user });
- assert.strictEqual(r.status, 200, r.text);
- assert.ok(r.text.includes('Pool your machines'), 'the card is there');
- assert.ok(r.text.includes('Pooling needs two or more of your machines on the same network. Add one with'), 'the one line');
- assert.ok(r.text.includes('https://openvibe.link'), 'the install link');
- assert.ok(!r.text.includes('1. Choose the machines'), 'no four steps with one machine');
- });
-
- await check('a machine on another network is greyed, with its reason', async () => {
- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'elsewhere', 8000, 'RTX 3060')];
- const r = await t.get('/sell', { as: user });
- assert.strictEqual(r.status, 200, r.text);
- assert.ok(r.text.includes('Not on the same network as Desktop: pools work on one home network, not across the internet'), r.text);
- assert.ok(/class="ex-pool-machine is-off"/.test(r.text), 'the machine is greyed, not hidden');
- assert.ok(r.text.includes('Desktop') && r.text.includes('Laptop'), 'both machines are listed');
- });
-
- await check('a failed link says the number and the limit; a good one says both numbers', async () => {
- t.network.state.machines = [
- { id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }, { id: rack, name: 'Rack', online: true },
- ];
- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060'), announce(rack, 'home', 16000, 'RTX 4080')];
- t.network.state.pools = [{
- id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',
- machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.5 }, { id: laptop, approved: true, ready: true, vram_share: 0.17 }, { id: rack, approved: true, ready: true, vram_share: 0.33 }],
- links: [{ machine: laptop, rtt_ms: 2, mbps: 940 }, { machine: rack, rtt_ms: 40, mbps: 940 }],
- fit: [{ model: 'llama-3-8b-abliterated', fits: true, tokens_per_second: 9 }, { model: 'qwen3-32b', fits: true, tokens_per_second: null }],
- }];
- const r = await t.get('/sell', { as: user });
- assert.strictEqual(r.status, 200, r.text);
- assert.ok(r.text.includes('2 ms, 940 Mb/s: good'), 'the good link in words');
- assert.ok(r.text.includes('40 ms; a pool needs under 5 ms'), 'the failed link says the number and the limit');
- assert.ok(r.text.includes('about 9 tokens a second'), 'a measured speed');
- assert.ok(r.text.includes('not measured yet'), 'a model nobody measured says so');
- assert.ok(!/about 0 tokens a second/.test(r.text), 'an absent measurement is never a zero');
- assert.ok(r.text.includes('Head: <b>Desktop</b>'), 'the head is the machine with the most free VRAM');
- });
-
- await check('nothing is offered until every member approves', async () => {
- t.network.state.pools = [{
- id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',
- machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.75 }, { id: laptop, approved: false, ready: false, vram_share: 0.25 }],
- }];
- const waiting = await t.get('/sell', { as: user });
- assert.ok(waiting.text.includes('Waiting for approval on <b>Laptop</b>'), waiting.text);
- assert.ok(waiting.text.includes('Approved'), 'the machine that approved says so');
- assert.ok(waiting.text.includes('Nothing is offered until every member approves.'), 'nothing is offered yet');
- assert.ok(!waiting.text.includes('one price for the whole pool'), 'the offer controls stay closed');
-
- t.network.state.pools[0].machines = t.network.state.pools[0].machines.map((m) => ({ ...m, approved: true, ready: true }));
- const approved = await t.get('/sell', { as: user });
- assert.ok(approved.text.includes('one price for the whole pool'), 'the same controls as one machine, applied to the pool');
- assert.ok(approved.text.includes('everyone, your followers or a group'), 'who may use it');
- assert.ok(approved.text.includes('the whole pool pauses while any member is in use'), 'idle only');
- assert.ok(approved.text.includes('Prompts reach your head machine (Desktop); the others only see numbers in transit.'), 'the privacy sentence');
- assert.ok(!approved.text.includes('Nothing is offered until every member approves.'), 'it is offered now');
- });
-
- await check('the public card shows only the count, never a machine', async () => {
- t.network.state.machines = [{ id: desktop, name: 'Workstation Alpha', online: true }];
- const models = await t.get('/models');
- assert.strictEqual(models.status, 200, models.text);
- assert.ok(models.text.includes('Community pool (3 machines)'), 'the label');
- assert.ok(models.text.includes('Free tier only'), 'a new pool says it serves the free tier only');
- assert.ok(!models.text.includes('<span'), 'no badge markup escapes as text');
- assert.ok(!models.text.includes('Workstation Alpha'), 'no machine name in public');
- const model = await t.get('/m/meta-llama/llama-3-8b-abliterated');
- assert.ok(model.text.includes('Community pool (3 machines)'), 'the offer card carries the pool label');
- assert.ok(!model.text.includes('Workstation Alpha'), 'still no machine name');
- const owner = await t.get('/sell', { as: user });
- assert.ok(owner.text.includes('Workstation Alpha'), 'the owner sees the machine names');
- });
-
- await check('creating the pool goes through Network, and stopping it says what happens', async () => {
- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
- t.network.state.pools = [];
- const made = await t.get('/sell/pool', {
- as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
- body: `machines=${desktop}&machines=${laptop}&head=${desktop}`,
- });
- assert.strictEqual(made.status, 303, made.text);
- assert.strictEqual(t.network.state.poolProposals.length, 1);
- assert.deepStrictEqual(t.network.state.poolProposals[0], { machines: [desktop, laptop], head: desktop });
-
- const one = await t.get('/sell/pool', {
- as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `machines=${desktop}`,
- });
- assert.strictEqual(one.status, 200, 'one machine is refused before Network is called');
- assert.ok(one.text.includes('Choose two or more of your machines on the same network.'));
-
- t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: true, ready: true, vram_share: 0.4 }] }];
- const stop = await t.get('/sell/pool/pool_TEST/stop', {
- as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '',
- });
- assert.strictEqual(stop.status, 303, stop.text);
- assert.deepStrictEqual(t.network.state.poolRevoked, ['pool_TEST']);
- });
-
- await check('no measurements: the page says not measured yet, never a number', async () => {
- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
- t.network.state.pools = [];
- const r = await t.get('/sell', { as: user });
- assert.ok(r.text.includes('link not measured yet'), 'an unmeasured link says so');
- assert.ok(r.text.includes('No model has been measured on these machines yet'), 'an empty fit table says so');
- assert.ok(!/\bMb\/s: good\b/.test(r.text), 'no invented speed');
- });
-
- await check('the pool card is gone for a stranger’s machines when Network cannot answer', async () => {
- t.network.setDown(true);
- const r = await t.get('/sell', { as: user });
- assert.strictEqual(r.status, 200, 'the page still renders');
- assert.ok(r.text.includes('Your machines could not be read right now. Reload in a moment.'), 'the read says why');
- const anon = await t.get('/sell');
- assert.ok(!anon.text.includes('Pool your machines'), 'signed out is unaffected');
- t.network.setDown(false);
- });
-
- await check('phone: the four steps stack, every control is 44 px, nothing is wider than the screen', async () => {
- const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'exchange.css'), 'utf8');
- assert.ok(/\.ex-pool \.ex-field select, \.ex-pool \.ex-btn, \.ex-pool \.ovk-btn \{ min-height: 44px; \}/.test(css), 'controls are 44 px');
- assert.ok(/@media \(max-width: 560px\)/.test(css), 'the phone layout is stacked');
- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
- t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: false, ready: false, vram_share: 0.4 }] }];
- const r = await t.get('/sell', { as: user });
- assert.strictEqual((r.text.match(/class="ex-pool-step"/g) || []).length, 4, 'four stacked step cards');
- assert.ok(r.text.includes('class="ex-pool ex-pool-steps"'), 'the steps container');
- });
+ const user = t.network.addUser('owner');
+ await check(
+ 'every seller page redirects permanently without exposing machine names',
+ async () => {
+ for (const path of ['/sell', '/sell/pool', '/sell/anything'])
+ for (const options of [{}, { as: user }]) {
+ const r = await t.get(path, options);
+ assert.equal(r.status, 301);
+ assert.equal(
+ r.headers.get('location'),
+ 'https://openvibe.link',
+ );
+ }
+ },
+ );
+ await check(
+ 'pool mutations still require same-origin sign-in',
+ async () => {
+ const r = await t.get('/sell/pool', {
+ as: user,
+ origin: 'https://evil.example',
+ form: { machines: 'not-a-machine' },
+ });
+ assert.equal(r.status, 403);
+ assert.equal(t.network.state.poolProposals.length, 0);
+ },
+ );
} finally {
} finally {
await t.close();
await t.close();
}
}
done();
done();
-})().catch((err) => { console.error(err); process.exitCode = 1; });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+'use strict';
+/**
+ * Pooled machines, part 4 (design 2026-11-06): "Pool your machines" on /sell and the pool's offer card. The page
+ * reads the person's machines, pools, link checks and fit table from Network and AI (the mock Network here); it
+ * measures nothing itself. Every number is one that was measured; the rest says "not measured yet".
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('../helpers/boot');
+const { createFakeAi } = require('../helpers/ai');
+
+const dev = () => `dev_${ids.ulid()}`;
+const poolOffer = (models) => ({
+ offer_id: 'community_abc123', kind: 'pool', label: 'Community pool (3 machines)', models,
+ pool: { machines: ['pseudonym-a', 'pseudonym-b', 'pseudonym-c'], head: 'pseudonym-a' },
+ free_only: true, verified: false, hours: { start: '00:00', end: '00:00', tz: 'UTC' }, concurrency: 1,
+ price: { in_per_million: 1, out_per_million: 2 }, audience: 'everyone', health: { status: 'up' },
+ open_now: true, ready: true,
+});
+
+(async () => {
+ const ai = createFakeAi({ community: [poolOffer(['llama-3-8b-abliterated'])] });
+ const t = await boot({ ai });
+ const user = t.network.addUser('alice');
+ const desktop = dev(), laptop = dev(), rack = dev(), other = dev();
+ const announce = (id, network, freeVram, hardware) => ({ id, free_vram: freeVram, free_ram: 32000, hardware, network });
+
+ try {
+ await check('signed out, /sell has no pool card (it needs a person’s machines)', async () => {
+ const r = await t.get('/sell');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!r.text.includes('Pool your machines'), 'no pool card when signed out');
+ });
+
+ await check('fewer than two machines: one line and the install link', async () => {
+ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }];
+ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090')];
+ t.network.state.pools = [];
+ const r = await t.get('/sell', { as: user });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('Pool your machines'), 'the card is there');
+ assert.ok(r.text.includes('Pooling needs two or more of your machines on the same network. Add one with'), 'the one line');
+ assert.ok(r.text.includes('https://openvibe.link'), 'the install link');
+ assert.ok(!r.text.includes('1. Choose the machines'), 'no four steps with one machine');
+ });
+
+ await check('a machine on another network is greyed, with its reason', async () => {
+ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
+ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'elsewhere', 8000, 'RTX 3060')];
+ const r = await t.get('/sell', { as: user });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('Not on the same network as Desktop: pools work on one home network, not across the internet'), r.text);
+ assert.ok(/class="ex-pool-machine is-off"/.test(r.text), 'the machine is greyed, not hidden');
+ assert.ok(r.text.includes('Desktop') && r.text.includes('Laptop'), 'both machines are listed');
+ });
+
+ await check('a failed link says the number and the limit; a good one says both numbers', async () => {
+ t.network.state.machines = [
+ { id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }, { id: rack, name: 'Rack', online: true },
+ ];
+ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060'), announce(rack, 'home', 16000, 'RTX 4080')];
+ t.network.state.pools = [{
+ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',
+ machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.5 }, { id: laptop, approved: true, ready: true, vram_share: 0.17 }, { id: rack, approved: true, ready: true, vram_share: 0.33 }],
+ links: [{ machine: laptop, rtt_ms: 2, mbps: 940 }, { machine: rack, rtt_ms: 40, mbps: 940 }],
+ fit: [{ model: 'llama-3-8b-abliterated', fits: true, tokens_per_second: 9 }, { model: 'qwen3-32b', fits: true, tokens_per_second: null }],
+ }];
+ const r = await t.get('/sell', { as: user });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('2 ms, 940 Mb/s: good'), 'the good link in words');
+ assert.ok(r.text.includes('40 ms; a pool needs under 5 ms'), 'the failed link says the number and the limit');
+ assert.ok(r.text.includes('about 9 tokens a second'), 'a measured speed');
+ assert.ok(r.text.includes('not measured yet'), 'a model nobody measured says so');
+ assert.ok(!/about 0 tokens a second/.test(r.text), 'an absent measurement is never a zero');
+ assert.ok(r.text.includes('Head: <b>Desktop</b>'), 'the head is the machine with the most free VRAM');
+ });
+
+ await check('nothing is offered until every member approves', async () => {
+ t.network.state.pools = [{
+ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',
+ machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.75 }, { id: laptop, approved: false, ready: false, vram_share: 0.25 }],
+ }];
+ const waiting = await t.get('/sell', { as: user });
+ assert.ok(waiting.text.includes('Waiting for approval on <b>Laptop</b>'), waiting.text);
+ assert.ok(waiting.text.includes('Approved'), 'the machine that approved says so');
+ assert.ok(waiting.text.includes('Nothing is offered until every member approves.'), 'nothing is offered yet');
+ assert.ok(!waiting.text.includes('one price for the whole pool'), 'the offer controls stay closed');
+
+ t.network.state.pools[0].machines = t.network.state.pools[0].machines.map((m) => ({ ...m, approved: true, ready: true }));
+ const approved = await t.get('/sell', { as: user });
+ assert.ok(approved.text.includes('one price for the whole pool'), 'the same controls as one machine, applied to the pool');
+ assert.ok(approved.text.includes('everyone, your followers or a group'), 'who may use it');
+ assert.ok(approved.text.includes('the whole pool pauses while any member is in use'), 'idle only');
+ assert.ok(approved.text.includes('Prompts reach your head machine (Desktop); the others only see numbers in transit.'), 'the privacy sentence');
+ assert.ok(!approved.text.includes('Nothing is offered until every member approves.'), 'it is offered now');
+ });
+
+ await check('the public card shows only the count, never a machine', async () => {
+ t.network.state.machines = [{ id: desktop, name: 'Workstation Alpha', online: true }];
+ const models = await t.get('/models');
+ assert.strictEqual(models.status, 200, models.text);
+ assert.ok(models.text.includes('Community pool (3 machines)'), 'the label');
+ assert.ok(models.text.includes('Free tier only'), 'a new pool says it serves the free tier only');
+ assert.ok(!models.text.includes('<span'), 'no badge markup escapes as text');
+ assert.ok(!models.text.includes('Workstation Alpha'), 'no machine name in public');
+ const model = await t.get('/m/meta-llama/llama-3-8b-abliterated');
+ assert.ok(model.text.includes('Community pool (3 machines)'), 'the offer card carries the pool label');
+ assert.ok(!model.text.includes('Workstation Alpha'), 'still no machine name');
+ const owner = await t.get('/sell', { as: user });
+ assert.ok(owner.text.includes('Workstation Alpha'), 'the owner sees the machine names');
+ });
+
+ await check('creating the pool goes through Network, and stopping it says what happens', async () => {
+ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
+ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
+ t.network.state.pools = [];
+ const made = await t.get('/sell/pool', {
+ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: `machines=${desktop}&machines=${laptop}&head=${desktop}`,
+ });
+ assert.strictEqual(made.status, 303, made.text);
+ assert.strictEqual(t.network.state.poolProposals.length, 1);
+ assert.deepStrictEqual(t.network.state.poolProposals[0], { machines: [desktop, laptop], head: desktop });
+
+ const one = await t.get('/sell/pool', {
+ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `machines=${desktop}`,
+ });
+ assert.strictEqual(one.status, 200, 'one machine is refused before Network is called');
+ assert.ok(one.text.includes('Choose two or more of your machines on the same network.'));
+
+ t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: true, ready: true, vram_share: 0.4 }] }];
+ const stop = await t.get('/sell/pool/pool_TEST/stop', {
+ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '',
+ });
+ assert.strictEqual(stop.status, 303, stop.text);
+ assert.deepStrictEqual(t.network.state.poolRevoked, ['pool_TEST']);
+ });
+
+ await check('no measurements: the page says not measured yet, never a number', async () => {
+ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
+ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
+ t.network.state.pools = [];
+ const r = await t.get('/sell', { as: user });
+ assert.ok(r.text.includes('link not measured yet'), 'an unmeasured link says so');
+ assert.ok(r.text.includes('No model has been measured on these machines yet'), 'an empty fit table says so');
+ assert.ok(!/\bMb\/s: good\b/.test(r.text), 'no invented speed');
+ });
+
+ await check('the pool card is gone for a stranger’s machines when Network cannot answer', async () => {
+ t.network.setDown(true);
+ const r = await t.get('/sell', { as: user });
+ assert.strictEqual(r.status, 200, 'the page still renders');
+ assert.ok(r.text.includes('Your machines could not be read right now. Reload in a moment.'), 'the read says why');
+ const anon = await t.get('/sell');
+ assert.ok(!anon.text.includes('Pool your machines'), 'signed out is unaffected');
+ t.network.setDown(false);
+ });
+
+ await check('phone: the four steps stack, every control is 44 px, nothing is wider than the screen', async () => {
+ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'exchange.css'), 'utf8');
+ assert.ok(/\.ex-pool \.ex-field select, \.ex-pool \.ex-btn, \.ex-pool \.ovk-btn \{ min-height: 44px; \}/.test(css), 'controls are 44 px');
+ assert.ok(/@media \(max-width: 560px\)/.test(css), 'the phone layout is stacked');
+ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
+ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
+ t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: false, ready: false, vram_share: 0.4 }] }];
+ const r = await t.get('/sell', { as: user });
+ assert.strictEqual((r.text.match(/class="ex-pool-step"/g) || []).length, 4, 'four stacked step cards');
+ assert.ok(r.text.includes('class="ex-pool ex-pool-steps"'), 'the steps container');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -25,6 +25,17 @@ const { createFakeBench } = require('./helpers/bench');
@@ -25,6 +25,17 @@ const { createFakeBench } = require('./helpers/bench');
});
});
const bench = createFakeBench();
const bench = createFakeBench();
const t = await boot({ ai, bench });
const t = await boot({ ai, bench });
+ // Arena HTML now lives on Report; exercise the same private read through Exchange's JSON API.
+ const get = t.get;
+ t.get = async (path, options = {}) => {
+ if (/^\/arena(?:\/|$)/.test(path) && !options.form && !options.json && !options.method) {
+ const response = await get(path.replace(/^\/arena/, '/api/v1/arena/rounds'), options);
+ try { response.text = response.json().html || response.text; } catch {}
+ return response;
+ }
+ return get(path, options);
+ };
+
const alice = t.network.addUser('alice');
const alice = t.network.addUser('alice');
const bob = t.network.addUser('bob');
const bob = t.network.addUser('bob');
const carol = t.network.addUser('carol');
const carol = t.network.addUser('carol');
@@ -104,7 +104,7 @@ const { sanitizeNext } = require('../server/auth/sso');
@@ -104,7 +104,7 @@ const { sanitizeNext } = require('../server/auth/sso');
});
});
await check('public pages are complete without JavaScript', async () => {
await check('public pages are complete without JavaScript', async () => {
- for (const p of ['/', '/models', '/api', '/sell', '/uncensored', '/ai']) {
+ for (const p of ['/', '/models', '/api', '/pricing', '/search', '/uncensored']) {
const r = await t.get(p);
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.strictEqual(r.status, 200, p);
assert.match(r.text, /<noscript><nav/);
assert.match(r.text, /<noscript><nav/);