openvibers/openvibe.report Commits
Add public AI comparison API (#140)
Changes
@@@ -26,6 +26,9 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000# Bench's public read API; set to an internal origin if Report and Bench share one.OV_BENCH_INTERNAL_URL=https://bench.openvibe.services++# OpenVibe.AI public Exchange catalogue and measured provider offers.++OV_AI_INTERNAL_URL=https://ai.openvibe.services++# OpenVibe.Billing's read API. Report quotes Billing and never computes money. The service needs the grants# billing.stats.read (GET /api/v1/stats) and billing.balance.read (GET /api/v1/transactions?subject=…).OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600@@@ -37,6 +37,7 @@ OpenVibe.Live in both modes.| **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` || **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` || **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/benchmarks`, `/api/v1/rankings`, `/api/v1/runs`, `/api/v1/overview`, `/api/v1/models/:model`, `/api/v1/models/:model/summary` | `/ai`, `/ai/:kind`, `/ai/<benchmark>`, `/ai/models/:model`, `/ai/compare`, `/ai/method`, `/ai/feed.xml`, `GET /api/v1/ai/results` |++| **OpenVibe.AI** | `GET /api/v1/exchange/catalog` | `GET /api/v1/ai/leaderboard`, `/api/v1/ai/models/:id`, `/api/v1/ai/providers`, `/api/v1/ai/compare?models=`, `/api/v1/ai/picks` || **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts || **OpenVibe.Billing** — settlements, through Events | `billing.credit.settled@1` on `POST /internal/events` (only with `REPORT_AI_EXCHANGE=1`) | the AI Exchange aggregates and the staff page/API |@@@ -173,6 +174,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).| `OV_NETWORK_URL` | `https://openvibe.network` | the OAuth token endpoint's origin || `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's loopback origin || `OV_BENCH_INTERNAL_URL` | `https://bench.openvibe.services` | Bench's public read origin, optionally an internal origin |++| `OV_AI_INTERNAL_URL` | `https://ai.openvibe.services` | AI's public Exchange catalogue and measured provider offers || `OV_BILLING_INTERNAL_URL` | `http://127.0.0.1:4600` | Billing's loopback origin || `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` | `report` / unset | Report's client-credentials token for `openvibe.billing` || `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run |@@@ -31,6 +31,7 @@ const { createNetworkClient } = require('./data/network');const { createLiveClient } = require('./data/live');const { createBillingClient } = require('./data/billing');const { createBenchClient } = require('./data/bench');++const { createAiCatalogue } = require('./data/ai-catalogue');const { createAnalytics } = require('./analytics');const { createAiUsage } = require('./analytics/ai-usage');const { createAiExchange } = require('./analytics/ai-exchange');@@@ -88,6 +89,7 @@ async function createApp(opts = {}) {const live = opts.live || createLiveClient({ config, fetchImpl, log, now });const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });const bench = opts.bench || createBenchClient({ config, fetchImpl, log, now });++ const aiCatalogue = opts.aiCatalogue || createAiCatalogue({ config, fetchImpl, log, now });// D11 owner settings (report.ai_exchange): the switch is read through the store, env pins winning. Report can// run without a database (a network-only front): then there is no store and the env value is all there is.const exchangeConfig = store ? await createAiExchangeConfig({ db: store.db, config, env: opts.env || process.env, now, log, onActivate: opts.onConfigActivate }) : null;@@@ -116,7 +118,7 @@ async function createApp(opts = {}) {secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,});-- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports };++ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports };const app = express();if (exchangeConfig) exchangeConfig.mount(app);// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts@@@ -287,7 +289,7 @@ async function createApp(opts = {}) {legacyHeaders: false,handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),}));-- app.use('/api/v1', createApi({ network, live, bench, analytics, aiUsage, aiExchange, sessionReports, config, now, log }));++ app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, config, now, log }));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));// ── Crawl artifacts, then the pages ─────────────────────@@@ -50,6 +50,7 @@ function load(env = process.env) {// OpenVibe.Bench's public reads (kinds, boards, models, datasets) — the AI boards under /ai. Public HTML// boards render here; Bench stays the benchmark authority that owns the numbers.benchUrl: trim(env.OV_BENCH_INTERNAL_URL || 'https://bench.openvibe.services'),++ aiUrl: trim(env.OV_AI_INTERNAL_URL || 'https://ai.openvibe.services'),// Report's own database (ADR-035). DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner// role); development without DATABASE_URL uses an embedded PGlite database under REPORT_PGLITE_DIR.nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++++const { requestJson, trim } = require('./source');++++const TTL_MS = 120_000;++function createAiCatalogue({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs } = {}) {++ const baseUrl = trim(config.aiUrl || 'https://ai.openvibe.services');++ let hit = null;++ let pending = null;++ async function catalog() {++ if (hit && hit.expires > now()) return hit.value;++ if (!pending) pending = (async () => {++ const data = await requestJson(`${baseUrl}/api/v1/exchange/catalog`, { fetchImpl, log, label: 'ai exchange catalog', timeoutMs });++ if (data && Array.isArray(data.models)) hit = { value: data, expires: now() + TTL_MS };++ pending = null;++ return data && Array.isArray(data.models) ? data : null;++ })();++ return pending;++ }++ return { catalog, baseUrl, ttlMs: TTL_MS };++}++++module.exports = { createAiCatalogue, TTL_MS };@@@ -30,7 +30,7 @@ const sharing = require('../sharing');const CACHE = 'public, max-age=120';--function createApi({ network, live, bench = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, config = {}, now = () => Date.now(), log = console }) {++function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, config = {}, now = () => Date.now(), log = console }) {const r = express.Router();const serviceAuth = createServiceAuth({ config, now });@@@ -82,6 +82,92 @@ function createApi({ network, live, bench = null, analytics = null, aiUsage = nu// Every site's design health (plan L45 step 6): the snapshot scripts/design-health.js wrote from each site's code.r.get('/design-health', (req, res) => res.json(designHealth.all()));++++ const aiEnvelope = (schema, data) => ({ '$schema': schema, generated_at: new Date(now()).toISOString(), ...data });++ const aiRows = async () => {++ const [kindsResult, catalogueResult] = await Promise.all([bench && bench.kinds(), aiCatalogue && aiCatalogue.catalog()]);++ if (!kindsResult) return null;++ const kinds = kindsResult.data && kindsResult.data.kinds || [];++ const catalogModels = catalogueResult && catalogueResult.models || [];++ const boards = await Promise.all(kinds.map((kind) => bench.board(kind.id, 'elo')));++ const rows = [];++ for (let i = 0; i < boards.length; i += 1) {++ const board = boards[i];++ const list = board && board.data && board.data.board && board.data.board.rows || [];++ for (const entry of list) {++ const id = entry.subject || entry.model || entry.name;++ const catalogue = catalogModels.find((m) => m.slug === id || m.canonical === id || m.id === id) || null;++ const elo = entry.elo != null && Number.isFinite(Number(entry.elo)) ? Number(entry.elo) : null;++ rows.push({ id, kind: kinds[i].id, maker: catalogue && (catalogue.maker || (String(catalogue.slug || '').includes('/') ? String(catalogue.slug).split('/')[0] : null)) || null, weights: catalogue && (catalogue.weights || (catalogue.open_weights === true ? 'open' : null)) || null,++ status: catalogue && catalogue.status || 'current', uncensored: catalogue && catalogue.uncensored === true,++ community: Boolean(catalogue && catalogue.community), rating: elo == null ? { value: null, reason: 'Bench has no rating.' } : { value: elo, interval: [entry.ci_lower ?? null, entry.ci_upper ?? null], source: 'OpenVibe.Bench', date: board.at, sample_size: entry.games ?? null },++ benchmark: entry.score == null ? { value: null, reason: 'No benchmark score.' } : { value: entry.score, source: 'OpenVibe.Bench', date: board.at, sample_size: entry.runs ?? null },++ provider_offers: catalogue ? catalogue.offers.map((offer) => ({ ...offer, source: { name: 'OpenVibe.AI', date: catalogueResult.generated_at || null, sample_size: offer.requests_7d ?? null } })) : [], source: { name: 'OpenVibe.Bench', date: board.at, sample_size: entry.games ?? null } });++ }++ }++ rows.sort((a, b) => (b.rating.value ?? -Infinity) - (a.rating.value ?? -Infinity) || String(a.id).localeCompare(String(b.id)));++ rows.forEach((row, index) => {++ const lo = row.rating.interval && row.rating.interval[0]; const hi = row.rating.interval && row.rating.interval[1];++ if (lo == null || hi == null) row.rank = { value: null, reason: 'Not enough data to establish a rank band.' };++ else { const overlapping = rows.filter((other) => other.rating.interval && other.rating.interval[0] != null && other.rating.interval[1] != null && other.rating.interval[0] <= hi && other.rating.interval[1] >= lo); row.rank = { min: Math.min(...overlapping.map((x) => rows.indexOf(x) + 1)), max: Math.max(...overlapping.map((x) => rows.indexOf(x) + 1)), position: index + 1, source: 'OpenVibe.Bench', date: row.source.date, sample_size: row.source.sample_size }; }++ });++ return rows;++ };++++ r.get('/ai/leaderboard', async (req, res) => {++ const all = await aiRows();++ if (!all) return problem(req, res, 502, 'report.ai_source_unavailable', 'OpenVibe.Bench did not answer.');++ let rows = all;++ const q = req.query;++ if (q.kind) rows = rows.filter((x) => x.kind === q.kind);++ if (q.maker) rows = rows.filter((x) => String(x.maker || '').toLowerCase() === String(q.maker).toLowerCase());++ if (q.weights === 'open') rows = rows.filter((x) => x.weights === 'open' || x.weights === 'open_weights');++ if (q.weights === 'closed') rows = rows.filter((x) => x.weights === 'closed');++ if (q.status === 'current') rows = rows.filter((x) => x.status === 'current');++ if (q.uncensored === 'true') rows = rows.filter((x) => x.uncensored);++ if (q.community === 'true') rows = rows.filter((x) => x.community);++ if (q.price) rows = rows.filter((x) => x.provider_offers.some((o) => o.price && (q.price === 'free' ? o.free_tier : q.price === 'low' ? Number(o.price.out) <= 1 : ['mid', 'medium'].includes(q.price) ? Number(o.price.out) > 1 && Number(o.price.out) <= 10 : q.price === 'high' ? Number(o.price.out) > 10 : false)));++ if (q.sort === 'name') rows.sort((a, b) => String(a.id).localeCompare(String(b.id)));++ if (q.sort === 'price') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => Number(o.price && o.price.out)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => Number(o.price && o.price.out)).filter(Number.isFinite), Infinity));++ if (q.sort === 'speed') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity));++ const limit = Math.max(1, Math.min(100, Number(q.limit) || 25)); const offset = Math.max(0, Number(q.cursor) || 0);++ return res.json(aiEnvelope('report.ai-leaderboard@1', { count: rows.length, rows: rows.slice(offset, offset + limit), next_cursor: offset + limit < rows.length ? String(offset + limit) : null }));++ });++ r.get('/ai/models/:id', async (req, res) => {++ const result = bench && await bench.modelDetail(req.params.id);++ if (!result) return notFound(req, res, 'report.ai_model_not_found', 'No public Bench model by that id.');++ const rows = await aiRows(); const ranking = rows && rows.filter((x) => x.id === req.params.id);++ return res.json(aiEnvelope('report.ai-model@1', { ...result.data, ratings: ranking || [], source: { name: 'OpenVibe.Bench', date: result.at, sample_size: result.data.runs ?? null } }));++ });++ r.get('/ai/providers', async (_req, res) => {++ const data = aiCatalogue && await aiCatalogue.catalog();++ if (!data) return problem(_req, res, 502, 'report.ai_source_unavailable', 'OpenVibe.AI did not answer with provider metrics.');++ const providers = data.models.flatMap((model) => (model.offers || []).map((offer) => {++ const evidence = { name: 'OpenVibe.AI', date: offer.observed_at || data.generated_at, sample_size: offer.requests_7d ?? null };++ return { model: model.slug, ...offer, error_rate: offer.uptime == null ? { value: null, reason: 'Not enough provider attempts to calculate an error rate.' } : { value: Math.max(0, 1 - Number(offer.uptime)), source: evidence, derivation: '1 - uptime' },++ tokens_per_second: { value: null, reason: 'OpenVibe.AI public catalogue does not report generated tokens per second.' },++ ttft_ms: { value: null, reason: 'OpenVibe.AI public catalogue p50 latency may be total response latency; it does not expose TTFT separately.' }, source: evidence };++ }));++ return res.json(aiEnvelope('report.ai-providers@1', { generated_at: data.generated_at, providers }));++ });++ r.get('/ai/compare', async (req, res) => {++ const ids = String(req.query.models || '').split(',').filter(Boolean).slice(0, 4);++ const models = await Promise.all(ids.map(async (id) => { const value = bench && await bench.modelDetail(id); return value ? { ...value.data, source: { name: 'OpenVibe.Bench', date: value.at, sample_size: value.data.runs ?? null } } : { id, model: null, reason: 'No public Bench data for this model.' }; }));++ return res.json(aiEnvelope('report.ai-compare@1', { models }));++ });++ r.get('/ai/picks', async (_req, res) => {++ const rows = await aiRows(); if (!rows) return problem(_req, res, 502, 'report.ai_source_unavailable', 'OpenVibe.Bench did not answer.');++ const best = rows.find((x) => x.rating.value != null) || null;++ const cheapest = rows.filter((x) => x.provider_offers.some((o) => o.price)).sort((a, b) => Number(a.provider_offers[0].price.out) - Number(b.provider_offers[0].price.out))[0] || null;++ const value = rows.filter((x) => x.rating.value != null && x.provider_offers.some((o) => o.price && Number(o.price.out) > 0)).sort((a, b) => Number(a.provider_offers.find((o) => o.price && Number(o.price.out) > 0).price.out) / a.rating.value - Number(b.provider_offers.find((o) => o.price && Number(o.price.out) > 0).price.out) / b.rating.value)[0] || null;++ const fastest = rows.filter((x) => x.provider_offers.some((o) => Number.isFinite(Number(o.speed && o.speed.p50_ms)))).sort((a, b) => Math.min(...a.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite)) - Math.min(...b.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite)))[0] || null;++ const picks = [{ id: 'overall', model: best, why: best ? 'Highest Bench rating.' : 'Not enough data.' }, { id: 'value', model: value, why: value ? 'Bench rating relative to listed provider output price.' : 'Not enough paired rating and price data.' }, { id: 'fastest', model: fastest, why: fastest ? 'Lowest measured p50 response time.' : 'Not enough measured speed data.' }];++ for (const id of ['open', 'code', 'writing', 'uncensored', 'free']) {++ const model = rows.find((x) => id === 'open' ? x.weights === 'open' : id === 'uncensored' ? x.uncensored : id === 'free' ? x.provider_offers.some((o) => o.free_tier) : x.kind === id) || null;++ picks.push({ id, model, why: model ? 'Based on currently available public source data.' : `Not enough source data to establish a ${id} pick.` });++ }++ return res.json(aiEnvelope('report.ai-picks@1', { picks }));++ });r.get('/design-health/:site', (req, res) => {const site = designHealth.site(req.params.site);if (!site) return notFound(req, res, 'report.design_site_not_found', 'No site by that name in the design health snapshot.');nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++const assert = require('assert');++const { boot, check } = require('./helpers/boot');++++(async () => {++ const bench = {++ kinds: async () => ({ at: '2026-11-08T00:00:00.000Z', data: { kinds: [{ id: 'chat.arena' }] } }),++ board: async () => ({ at: '2026-11-08T00:00:00.000Z', data: { board: { kind_id: 'chat.arena', rows: [{ subject: 'm/a', elo: 1200, ci_lower: 1180, ci_upper: 1220, games: 40 }, { subject: 'vendor/new', elo: null, games: 0 }] } } }),++ modelDetail: async (id) => id === 'm/a' ? ({ at: '2026-11-08T00:00:00.000Z', data: { model: id, runs: 40 } }) : null,++ };++ const aiCatalogue = { catalog: async () => ({ generated_at: '2026-11-08T00:00:00.000Z', models: [{ slug: 'm/a', uncensored: true, offers: [{ provider: 'p', free_tier: true, price: { out: 0 }, requests_7d: 40, speed: { p50_ms: 90 }, uptime: 1 }] }] }) };++ const t = await boot({ bench, aiCatalogue });++ try {++ await check('AI comparison endpoints use public source stand-ins and CORS without credentials', async () => {++ for (const path of ['/api/v1/ai/leaderboard?kind=chat.arena', '/api/v1/ai/models/m%2Fa', '/api/v1/ai/providers', '/api/v1/ai/compare?models=m%2Fa', '/api/v1/ai/picks']) {++ const response = await t.get(path);++ assert.strictEqual(response.status, 200, path);++ assert.match(response.headers.get('access-control-allow-origin') || '', /^\*$/);++ assert.strictEqual(response.headers.get('access-control-allow-credentials'), null);++ assert.match(response.headers.get('cache-control') || '', /max-age=120/);++ assert.match(response.headers.get('content-type') || '', /^application\/json/);++ }++ const result = await t.get('/api/v1/ai/leaderboard?uncensored=true');++ assert.strictEqual(result.json().rows.length, 1);++ assert.deepStrictEqual(result.json().rows[0].rank, { min: 1, max: 1, position: 1, source: 'OpenVibe.Bench', date: '2026-11-08T00:00:00.000Z', sample_size: 40 });++ const filtered = await t.get('/api/v1/ai/leaderboard?maker=m&price=free&kind=chat.arena');++ assert.strictEqual(filtered.json().rows.length, 1);++ const notEnough = await t.get('/api/v1/ai/leaderboard?maker=vendor');++ assert.strictEqual(notEnough.json().rows[0].rating.value, null);++ assert.match(notEnough.json().rows[0].rating.reason, /no rating/i);++ const picks = await t.get('/api/v1/ai/picks');++ assert.match(picks.json().picks.find((x) => x.id === 'open').why, /not enough source data/i);++ assert.strictEqual((await t.get('/api/v1/ai/leaderboard?limit=1&cursor=1')).json().rows.length, 1);++ });++ } finally { await t.close(); }++})().catch((error) => { console.error(error); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,18 @@++'use strict';++const assert = require('assert');++const { createAiCatalogue } = require('../server/data/ai-catalogue');++(async () => {++ let now = 1000; let calls = 0;++ const client = createAiCatalogue({ config: { aiUrl: 'https://ai.test/' }, now: () => now, fetchImpl: async (url) => {++ calls += 1; assert.strictEqual(url, 'https://ai.test/api/v1/exchange/catalog');++ return new Response(JSON.stringify({ models: [{ slug: 'vendor/model', offers: [] }] }), { status: 200, headers: { 'content-type': 'application/json' } });++ } });++ const first = await client.catalog();++ await client.catalog();++ assert.strictEqual(first.models[0].slug, 'vendor/model');++ assert.strictEqual(calls, 1, 'second read uses the 2-minute cache');++ now += 120_001;++ await client.catalog();++ assert.strictEqual(calls, 2, 'expired catalogue refreshes');++ console.log('AI catalogue source tests passed');++})().catch((error) => { console.error(error); process.exitCode = 1; });@@@ -178,6 +178,8 @@ async function boot(opts = {}) {sso: opts.sso,shadow: opts.shadow,...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),++ ...(opts.bench ? { bench: opts.bench } : {}),++ ...(opts.aiCatalogue ? { aiCatalogue: opts.aiCatalogue } : {}),...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),...(opts.shareNetwork ? { shareNetwork: opts.shareNetwork } : {}),releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },
@@ -26,6 +26,9 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
@@ -26,6 +26,9 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
# Bench's public read API; set to an internal origin if Report and Bench share one.
# Bench's public read API; set to an internal origin if Report and Bench share one.
OV_BENCH_INTERNAL_URL=https://bench.openvibe.services
OV_BENCH_INTERNAL_URL=https://bench.openvibe.services
+# OpenVibe.AI public Exchange catalogue and measured provider offers.
+OV_AI_INTERNAL_URL=https://ai.openvibe.services
+
# OpenVibe.Billing's read API. Report quotes Billing and never computes money. The service needs the grants
# OpenVibe.Billing's read API. Report quotes Billing and never computes money. The service needs the grants
# billing.stats.read (GET /api/v1/stats) and billing.balance.read (GET /api/v1/transactions?subject=…).
# billing.stats.read (GET /api/v1/stats) and billing.balance.read (GET /api/v1/transactions?subject=…).
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
@@ -37,6 +37,7 @@ OpenVibe.Live in both modes.
@@ -37,6 +37,7 @@ OpenVibe.Live in both modes.
| **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` |
| **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` |
| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |
| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |
| **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/benchmarks`, `/api/v1/rankings`, `/api/v1/runs`, `/api/v1/overview`, `/api/v1/models/:model`, `/api/v1/models/:model/summary` | `/ai`, `/ai/:kind`, `/ai/<benchmark>`, `/ai/models/:model`, `/ai/compare`, `/ai/method`, `/ai/feed.xml`, `GET /api/v1/ai/results` |
| **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/benchmarks`, `/api/v1/rankings`, `/api/v1/runs`, `/api/v1/overview`, `/api/v1/models/:model`, `/api/v1/models/:model/summary` | `/ai`, `/ai/:kind`, `/ai/<benchmark>`, `/ai/models/:model`, `/ai/compare`, `/ai/method`, `/ai/feed.xml`, `GET /api/v1/ai/results` |
+| **OpenVibe.AI** | `GET /api/v1/exchange/catalog` | `GET /api/v1/ai/leaderboard`, `/api/v1/ai/models/:id`, `/api/v1/ai/providers`, `/api/v1/ai/compare?models=`, `/api/v1/ai/picks` |
| **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts |
| **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts |
| **OpenVibe.Billing** — settlements, through Events | `billing.credit.settled@1` on `POST /internal/events` (only with `REPORT_AI_EXCHANGE=1`) | the AI Exchange aggregates and the staff page/API |
| **OpenVibe.Billing** — settlements, through Events | `billing.credit.settled@1` on `POST /internal/events` (only with `REPORT_AI_EXCHANGE=1`) | the AI Exchange aggregates and the staff page/API |
@@ -173,6 +174,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).
@@ -173,6 +174,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).
| `OV_NETWORK_URL` | `https://openvibe.network` | the OAuth token endpoint's origin |
| `OV_NETWORK_URL` | `https://openvibe.network` | the OAuth token endpoint's origin |
| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's loopback origin |
| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's loopback origin |
| `OV_BENCH_INTERNAL_URL` | `https://bench.openvibe.services` | Bench's public read origin, optionally an internal origin |
| `OV_BENCH_INTERNAL_URL` | `https://bench.openvibe.services` | Bench's public read origin, optionally an internal origin |
+| `OV_AI_INTERNAL_URL` | `https://ai.openvibe.services` | AI's public Exchange catalogue and measured provider offers |
| `OV_BILLING_INTERNAL_URL` | `http://127.0.0.1:4600` | Billing's loopback origin |
| `OV_BILLING_INTERNAL_URL` | `http://127.0.0.1:4600` | Billing's loopback origin |
| `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` | `report` / unset | Report's client-credentials token for `openvibe.billing` |
| `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` | `report` / unset | Report's client-credentials token for `openvibe.billing` |
| `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run |
| `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run |
@@ -31,6 +31,7 @@ const { createNetworkClient } = require('./data/network');
@@ -31,6 +31,7 @@ const { createNetworkClient } = require('./data/network');
const { createLiveClient } = require('./data/live');
const { createLiveClient } = require('./data/live');
const { createBillingClient } = require('./data/billing');
const { createBillingClient } = require('./data/billing');
const { createBenchClient } = require('./data/bench');
const { createBenchClient } = require('./data/bench');
+const { createAiCatalogue } = require('./data/ai-catalogue');
const { createAnalytics } = require('./analytics');
const { createAnalytics } = require('./analytics');
const { createAiUsage } = require('./analytics/ai-usage');
const { createAiUsage } = require('./analytics/ai-usage');
const { createAiExchange } = require('./analytics/ai-exchange');
const { createAiExchange } = require('./analytics/ai-exchange');
@@ -88,6 +89,7 @@ async function createApp(opts = {}) {
@@ -88,6 +89,7 @@ async function createApp(opts = {}) {
const live = opts.live || createLiveClient({ config, fetchImpl, log, now });
const live = opts.live || createLiveClient({ config, fetchImpl, log, now });
const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });
const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });
const bench = opts.bench || createBenchClient({ config, fetchImpl, log, now });
const bench = opts.bench || createBenchClient({ config, fetchImpl, log, now });
+ const aiCatalogue = opts.aiCatalogue || createAiCatalogue({ config, fetchImpl, log, now });
// D11 owner settings (report.ai_exchange): the switch is read through the store, env pins winning. Report can
// D11 owner settings (report.ai_exchange): the switch is read through the store, env pins winning. Report can
// run without a database (a network-only front): then there is no store and the env value is all there is.
// run without a database (a network-only front): then there is no store and the env value is all there is.
const exchangeConfig = store ? await createAiExchangeConfig({ db: store.db, config, env: opts.env || process.env, now, log, onActivate: opts.onConfigActivate }) : null;
const exchangeConfig = store ? await createAiExchangeConfig({ db: store.db, config, env: opts.env || process.env, now, log, onActivate: opts.onConfigActivate }) : null;
@@ -116,7 +118,7 @@ async function createApp(opts = {}) {
@@ -116,7 +118,7 @@ async function createApp(opts = {}) {
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
});
});
- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports };
+ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports };
const app = express();
const app = express();
if (exchangeConfig) exchangeConfig.mount(app);
if (exchangeConfig) exchangeConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@ -287,7 +289,7 @@ async function createApp(opts = {}) {
@@ -287,7 +289,7 @@ async function createApp(opts = {}) {
legacyHeaders: false,
legacyHeaders: false,
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
}));
}));
- app.use('/api/v1', createApi({ network, live, bench, analytics, aiUsage, aiExchange, sessionReports, config, now, log }));
+ app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, config, now, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Crawl artifacts, then the pages ─────────────────────
// ── Crawl artifacts, then the pages ─────────────────────
@@ -50,6 +50,7 @@ function load(env = process.env) {
@@ -50,6 +50,7 @@ function load(env = process.env) {
// OpenVibe.Bench's public reads (kinds, boards, models, datasets) — the AI boards under /ai. Public HTML
// OpenVibe.Bench's public reads (kinds, boards, models, datasets) — the AI boards under /ai. Public HTML
// boards render here; Bench stays the benchmark authority that owns the numbers.
// boards render here; Bench stays the benchmark authority that owns the numbers.
benchUrl: trim(env.OV_BENCH_INTERNAL_URL || 'https://bench.openvibe.services'),
benchUrl: trim(env.OV_BENCH_INTERNAL_URL || 'https://bench.openvibe.services'),
+ aiUrl: trim(env.OV_AI_INTERNAL_URL || 'https://ai.openvibe.services'),
// Report's own database (ADR-035). DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner
// Report's own database (ADR-035). DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner
// role); development without DATABASE_URL uses an embedded PGlite database under REPORT_PGLITE_DIR.
// role); development without DATABASE_URL uses an embedded PGlite database under REPORT_PGLITE_DIR.
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+
+const { requestJson, trim } = require('./source');
+
+const TTL_MS = 120_000;
+function createAiCatalogue({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs } = {}) {
+ const baseUrl = trim(config.aiUrl || 'https://ai.openvibe.services');
+ let hit = null;
+ let pending = null;
+ async function catalog() {
+ if (hit && hit.expires > now()) return hit.value;
+ if (!pending) pending = (async () => {
+ const data = await requestJson(`${baseUrl}/api/v1/exchange/catalog`, { fetchImpl, log, label: 'ai exchange catalog', timeoutMs });
+ if (data && Array.isArray(data.models)) hit = { value: data, expires: now() + TTL_MS };
+ pending = null;
+ return data && Array.isArray(data.models) ? data : null;
+ })();
+ return pending;
+ }
+ return { catalog, baseUrl, ttlMs: TTL_MS };
+}
+
+module.exports = { createAiCatalogue, TTL_MS };
@@ -30,7 +30,7 @@ const sharing = require('../sharing');
@@ -30,7 +30,7 @@ const sharing = require('../sharing');
const CACHE = 'public, max-age=120';
const CACHE = 'public, max-age=120';
-function createApi({ network, live, bench = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, config = {}, now = () => Date.now(), log = console }) {
+function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, config = {}, now = () => Date.now(), log = console }) {
const r = express.Router();
const r = express.Router();
const serviceAuth = createServiceAuth({ config, now });
const serviceAuth = createServiceAuth({ config, now });
@@ -82,6 +82,92 @@ function createApi({ network, live, bench = null, analytics = null, aiUsage = nu
@@ -82,6 +82,92 @@ function createApi({ network, live, bench = null, analytics = null, aiUsage = nu
// Every site's design health (plan L45 step 6): the snapshot scripts/design-health.js wrote from each site's code.
// Every site's design health (plan L45 step 6): the snapshot scripts/design-health.js wrote from each site's code.
r.get('/design-health', (req, res) => res.json(designHealth.all()));
r.get('/design-health', (req, res) => res.json(designHealth.all()));
+
+ const aiEnvelope = (schema, data) => ({ '$schema': schema, generated_at: new Date(now()).toISOString(), ...data });
+ const aiRows = async () => {
+ const [kindsResult, catalogueResult] = await Promise.all([bench && bench.kinds(), aiCatalogue && aiCatalogue.catalog()]);
+ if (!kindsResult) return null;
+ const kinds = kindsResult.data && kindsResult.data.kinds || [];
+ const catalogModels = catalogueResult && catalogueResult.models || [];
+ const boards = await Promise.all(kinds.map((kind) => bench.board(kind.id, 'elo')));
+ const rows = [];
+ for (let i = 0; i < boards.length; i += 1) {
+ const board = boards[i];
+ const list = board && board.data && board.data.board && board.data.board.rows || [];
+ for (const entry of list) {
+ const id = entry.subject || entry.model || entry.name;
+ const catalogue = catalogModels.find((m) => m.slug === id || m.canonical === id || m.id === id) || null;
+ const elo = entry.elo != null && Number.isFinite(Number(entry.elo)) ? Number(entry.elo) : null;
+ rows.push({ id, kind: kinds[i].id, maker: catalogue && (catalogue.maker || (String(catalogue.slug || '').includes('/') ? String(catalogue.slug).split('/')[0] : null)) || null, weights: catalogue && (catalogue.weights || (catalogue.open_weights === true ? 'open' : null)) || null,
+ status: catalogue && catalogue.status || 'current', uncensored: catalogue && catalogue.uncensored === true,
+ community: Boolean(catalogue && catalogue.community), rating: elo == null ? { value: null, reason: 'Bench has no rating.' } : { value: elo, interval: [entry.ci_lower ?? null, entry.ci_upper ?? null], source: 'OpenVibe.Bench', date: board.at, sample_size: entry.games ?? null },
+ benchmark: entry.score == null ? { value: null, reason: 'No benchmark score.' } : { value: entry.score, source: 'OpenVibe.Bench', date: board.at, sample_size: entry.runs ?? null },
+ provider_offers: catalogue ? catalogue.offers.map((offer) => ({ ...offer, source: { name: 'OpenVibe.AI', date: catalogueResult.generated_at || null, sample_size: offer.requests_7d ?? null } })) : [], source: { name: 'OpenVibe.Bench', date: board.at, sample_size: entry.games ?? null } });
+ }
+ }
+ rows.sort((a, b) => (b.rating.value ?? -Infinity) - (a.rating.value ?? -Infinity) || String(a.id).localeCompare(String(b.id)));
+ rows.forEach((row, index) => {
+ const lo = row.rating.interval && row.rating.interval[0]; const hi = row.rating.interval && row.rating.interval[1];
+ if (lo == null || hi == null) row.rank = { value: null, reason: 'Not enough data to establish a rank band.' };
+ else { const overlapping = rows.filter((other) => other.rating.interval && other.rating.interval[0] != null && other.rating.interval[1] != null && other.rating.interval[0] <= hi && other.rating.interval[1] >= lo); row.rank = { min: Math.min(...overlapping.map((x) => rows.indexOf(x) + 1)), max: Math.max(...overlapping.map((x) => rows.indexOf(x) + 1)), position: index + 1, source: 'OpenVibe.Bench', date: row.source.date, sample_size: row.source.sample_size }; }
+ });
+ return rows;
+ };
+
+ r.get('/ai/leaderboard', async (req, res) => {
+ const all = await aiRows();
+ if (!all) return problem(req, res, 502, 'report.ai_source_unavailable', 'OpenVibe.Bench did not answer.');
+ let rows = all;
+ const q = req.query;
+ if (q.kind) rows = rows.filter((x) => x.kind === q.kind);
+ if (q.maker) rows = rows.filter((x) => String(x.maker || '').toLowerCase() === String(q.maker).toLowerCase());
+ if (q.weights === 'open') rows = rows.filter((x) => x.weights === 'open' || x.weights === 'open_weights');
+ if (q.weights === 'closed') rows = rows.filter((x) => x.weights === 'closed');
+ if (q.status === 'current') rows = rows.filter((x) => x.status === 'current');
+ if (q.uncensored === 'true') rows = rows.filter((x) => x.uncensored);
+ if (q.community === 'true') rows = rows.filter((x) => x.community);
+ if (q.price) rows = rows.filter((x) => x.provider_offers.some((o) => o.price && (q.price === 'free' ? o.free_tier : q.price === 'low' ? Number(o.price.out) <= 1 : ['mid', 'medium'].includes(q.price) ? Number(o.price.out) > 1 && Number(o.price.out) <= 10 : q.price === 'high' ? Number(o.price.out) > 10 : false)));
+ if (q.sort === 'name') rows.sort((a, b) => String(a.id).localeCompare(String(b.id)));
+ if (q.sort === 'price') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => Number(o.price && o.price.out)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => Number(o.price && o.price.out)).filter(Number.isFinite), Infinity));
+ if (q.sort === 'speed') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity));
+ const limit = Math.max(1, Math.min(100, Number(q.limit) || 25)); const offset = Math.max(0, Number(q.cursor) || 0);
+ return res.json(aiEnvelope('report.ai-leaderboard@1', { count: rows.length, rows: rows.slice(offset, offset + limit), next_cursor: offset + limit < rows.length ? String(offset + limit) : null }));
+ });
+ r.get('/ai/models/:id', async (req, res) => {
+ const result = bench && await bench.modelDetail(req.params.id);
+ if (!result) return notFound(req, res, 'report.ai_model_not_found', 'No public Bench model by that id.');
+ const rows = await aiRows(); const ranking = rows && rows.filter((x) => x.id === req.params.id);
+ return res.json(aiEnvelope('report.ai-model@1', { ...result.data, ratings: ranking || [], source: { name: 'OpenVibe.Bench', date: result.at, sample_size: result.data.runs ?? null } }));
+ });
+ r.get('/ai/providers', async (_req, res) => {
+ const data = aiCatalogue && await aiCatalogue.catalog();
+ if (!data) return problem(_req, res, 502, 'report.ai_source_unavailable', 'OpenVibe.AI did not answer with provider metrics.');
+ const providers = data.models.flatMap((model) => (model.offers || []).map((offer) => {
+ const evidence = { name: 'OpenVibe.AI', date: offer.observed_at || data.generated_at, sample_size: offer.requests_7d ?? null };
+ return { model: model.slug, ...offer, error_rate: offer.uptime == null ? { value: null, reason: 'Not enough provider attempts to calculate an error rate.' } : { value: Math.max(0, 1 - Number(offer.uptime)), source: evidence, derivation: '1 - uptime' },
+ tokens_per_second: { value: null, reason: 'OpenVibe.AI public catalogue does not report generated tokens per second.' },
+ ttft_ms: { value: null, reason: 'OpenVibe.AI public catalogue p50 latency may be total response latency; it does not expose TTFT separately.' }, source: evidence };
+ }));
+ return res.json(aiEnvelope('report.ai-providers@1', { generated_at: data.generated_at, providers }));
+ });
+ r.get('/ai/compare', async (req, res) => {
+ const ids = String(req.query.models || '').split(',').filter(Boolean).slice(0, 4);
+ const models = await Promise.all(ids.map(async (id) => { const value = bench && await bench.modelDetail(id); return value ? { ...value.data, source: { name: 'OpenVibe.Bench', date: value.at, sample_size: value.data.runs ?? null } } : { id, model: null, reason: 'No public Bench data for this model.' }; }));
+ return res.json(aiEnvelope('report.ai-compare@1', { models }));
+ });
+ r.get('/ai/picks', async (_req, res) => {
+ const rows = await aiRows(); if (!rows) return problem(_req, res, 502, 'report.ai_source_unavailable', 'OpenVibe.Bench did not answer.');
+ const best = rows.find((x) => x.rating.value != null) || null;
+ const cheapest = rows.filter((x) => x.provider_offers.some((o) => o.price)).sort((a, b) => Number(a.provider_offers[0].price.out) - Number(b.provider_offers[0].price.out))[0] || null;
+ const value = rows.filter((x) => x.rating.value != null && x.provider_offers.some((o) => o.price && Number(o.price.out) > 0)).sort((a, b) => Number(a.provider_offers.find((o) => o.price && Number(o.price.out) > 0).price.out) / a.rating.value - Number(b.provider_offers.find((o) => o.price && Number(o.price.out) > 0).price.out) / b.rating.value)[0] || null;
+ const fastest = rows.filter((x) => x.provider_offers.some((o) => Number.isFinite(Number(o.speed && o.speed.p50_ms)))).sort((a, b) => Math.min(...a.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite)) - Math.min(...b.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite)))[0] || null;
+ const picks = [{ id: 'overall', model: best, why: best ? 'Highest Bench rating.' : 'Not enough data.' }, { id: 'value', model: value, why: value ? 'Bench rating relative to listed provider output price.' : 'Not enough paired rating and price data.' }, { id: 'fastest', model: fastest, why: fastest ? 'Lowest measured p50 response time.' : 'Not enough measured speed data.' }];
+ for (const id of ['open', 'code', 'writing', 'uncensored', 'free']) {
+ const model = rows.find((x) => id === 'open' ? x.weights === 'open' : id === 'uncensored' ? x.uncensored : id === 'free' ? x.provider_offers.some((o) => o.free_tier) : x.kind === id) || null;
+ picks.push({ id, model, why: model ? 'Based on currently available public source data.' : `Not enough source data to establish a ${id} pick.` });
+ }
+ return res.json(aiEnvelope('report.ai-picks@1', { picks }));
+ });
r.get('/design-health/:site', (req, res) => {
r.get('/design-health/:site', (req, res) => {
const site = designHealth.site(req.params.site);
const site = designHealth.site(req.params.site);
if (!site) return notFound(req, res, 'report.design_site_not_found', 'No site by that name in the design health snapshot.');
if (!site) return notFound(req, res, 'report.design_site_not_found', 'No site by that name in the design health snapshot.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+const assert = require('assert');
+const { boot, check } = require('./helpers/boot');
+
+(async () => {
+ const bench = {
+ kinds: async () => ({ at: '2026-11-08T00:00:00.000Z', data: { kinds: [{ id: 'chat.arena' }] } }),
+ board: async () => ({ at: '2026-11-08T00:00:00.000Z', data: { board: { kind_id: 'chat.arena', rows: [{ subject: 'm/a', elo: 1200, ci_lower: 1180, ci_upper: 1220, games: 40 }, { subject: 'vendor/new', elo: null, games: 0 }] } } }),
+ modelDetail: async (id) => id === 'm/a' ? ({ at: '2026-11-08T00:00:00.000Z', data: { model: id, runs: 40 } }) : null,
+ };
+ const aiCatalogue = { catalog: async () => ({ generated_at: '2026-11-08T00:00:00.000Z', models: [{ slug: 'm/a', uncensored: true, offers: [{ provider: 'p', free_tier: true, price: { out: 0 }, requests_7d: 40, speed: { p50_ms: 90 }, uptime: 1 }] }] }) };
+ const t = await boot({ bench, aiCatalogue });
+ try {
+ await check('AI comparison endpoints use public source stand-ins and CORS without credentials', async () => {
+ for (const path of ['/api/v1/ai/leaderboard?kind=chat.arena', '/api/v1/ai/models/m%2Fa', '/api/v1/ai/providers', '/api/v1/ai/compare?models=m%2Fa', '/api/v1/ai/picks']) {
+ const response = await t.get(path);
+ assert.strictEqual(response.status, 200, path);
+ assert.match(response.headers.get('access-control-allow-origin') || '', /^\*$/);
+ assert.strictEqual(response.headers.get('access-control-allow-credentials'), null);
+ assert.match(response.headers.get('cache-control') || '', /max-age=120/);
+ assert.match(response.headers.get('content-type') || '', /^application\/json/);
+ }
+ const result = await t.get('/api/v1/ai/leaderboard?uncensored=true');
+ assert.strictEqual(result.json().rows.length, 1);
+ assert.deepStrictEqual(result.json().rows[0].rank, { min: 1, max: 1, position: 1, source: 'OpenVibe.Bench', date: '2026-11-08T00:00:00.000Z', sample_size: 40 });
+ const filtered = await t.get('/api/v1/ai/leaderboard?maker=m&price=free&kind=chat.arena');
+ assert.strictEqual(filtered.json().rows.length, 1);
+ const notEnough = await t.get('/api/v1/ai/leaderboard?maker=vendor');
+ assert.strictEqual(notEnough.json().rows[0].rating.value, null);
+ assert.match(notEnough.json().rows[0].rating.reason, /no rating/i);
+ const picks = await t.get('/api/v1/ai/picks');
+ assert.match(picks.json().picks.find((x) => x.id === 'open').why, /not enough source data/i);
+ assert.strictEqual((await t.get('/api/v1/ai/leaderboard?limit=1&cursor=1')).json().rows.length, 1);
+ });
+ } finally { await t.close(); }
+})().catch((error) => { console.error(error); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+'use strict';
+const assert = require('assert');
+const { createAiCatalogue } = require('../server/data/ai-catalogue');
+(async () => {
+ let now = 1000; let calls = 0;
+ const client = createAiCatalogue({ config: { aiUrl: 'https://ai.test/' }, now: () => now, fetchImpl: async (url) => {
+ calls += 1; assert.strictEqual(url, 'https://ai.test/api/v1/exchange/catalog');
+ return new Response(JSON.stringify({ models: [{ slug: 'vendor/model', offers: [] }] }), { status: 200, headers: { 'content-type': 'application/json' } });
+ } });
+ const first = await client.catalog();
+ await client.catalog();
+ assert.strictEqual(first.models[0].slug, 'vendor/model');
+ assert.strictEqual(calls, 1, 'second read uses the 2-minute cache');
+ now += 120_001;
+ await client.catalog();
+ assert.strictEqual(calls, 2, 'expired catalogue refreshes');
+ console.log('AI catalogue source tests passed');
+})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -178,6 +178,8 @@ async function boot(opts = {}) {
@@ -178,6 +178,8 @@ async function boot(opts = {}) {
sso: opts.sso,
sso: opts.sso,
shadow: opts.shadow,
shadow: opts.shadow,
...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),
...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),
+ ...(opts.bench ? { bench: opts.bench } : {}),
+ ...(opts.aiCatalogue ? { aiCatalogue: opts.aiCatalogue } : {}),
...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),
...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),
...(opts.shareNetwork ? { shareNetwork: opts.shareNetwork } : {}),
...(opts.shareNetwork ? { shareNetwork: opts.shareNetwork } : {}),
releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },
releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },