openvibers/openvibe.report Commits
List market models and provider offers in AI comparisons (#162)
Changes
@@@ -262,3 +262,32 @@ HMAC. See [SECURITY.md](SECURITY.md).- openvibe-shared: v4.177.0- openvibe-publishing: v1.5.0<!-- versions:end -->++++### AI market data and provenance++++The AI pages and `GET /api/v1/ai/leaderboard` and `/api/v1/ai/providers` read++`https://api.openvibe.exchange/api/v1/exchange/market` alongside the routed catalog.++Report follows every market cursor and caches the complete result for 120 seconds.++An upstream failure retains the last complete market snapshot with `stale: true`;++without a snapshot, Report falls back to the routed catalog.++++The market is public (`exchange.market@1`, cached 60 seconds). It accepts `limit`++(1–500, default 100), opaque `cursor`, `maker`, `open_weights=true|false`, and++`kind=chat|embedding|stt|image`. Responses contain `offers`, `total_models`,++`total_offers`, `next_cursor`, `generated_at`, and the models.dev metadata snapshot's++`source`, `checked_at`, and `stale` state. Each offer identifies `model`, `provider`,++`provider_model`, `routed`, `provider_access`, and the preset's `uncensored` flag.++Prices are USD per 1M tokens (`in`, `out`, `cached`), with `price_source` pointing++to that provider's own page and `price_checked_at`. Unknown prices remain null.++A latest observed price can differ from the price currently approved for routing.++++Model fields include maker, context, max output, open weights, licence, release++date, knowledge cutoff and modalities. `field_sources` names the provider page or++`https://models.dev/api.json` for each known field. Metadata refreshes daily on++read; a failed refresh preserves the previous copy and marks it stale.++++Comparison rows include every market model, rated rows first, then unrated rows.++Their first provider offer has the lowest known own-page output price, breaking++ties by input price; input/output/cached prices always come from that same offer.++Provider measurements join only routed offers. Other offers display “Not routed++here”; missing measurements on routed offers display “Not measured yet”.@@@ -17,7 +17,28 @@ function createAiCatalogue({ config = {}, fetchImpl = globalThis.fetch, log = co})();return pending;}-- return { catalog, baseUrl, ttlMs: TTL_MS };++ let marketHit = null; let marketPending = null;++ async function market() {++ if (marketHit && marketHit.expires > now()) return marketHit.value;++ if (!marketPending) marketPending = (async () => {++ const offers = []; const seen = new Set(); let cursor = null; let first = null;++ do {++ const suffix = cursor == null ? '' : `&cursor=${encodeURIComponent(cursor)}`;++ const data = await requestJson(`${baseUrl}/api/v1/exchange/market?limit=500${suffix}`, { fetchImpl, log, label: 'ai exchange market', timeoutMs });++ if (!data || !Array.isArray(data.offers) || !('next_cursor' in data) || (data.next_cursor != null && typeof data.next_cursor !== 'string') || (first && first.generated_at !== data.generated_at) || seen.has(data.next_cursor) || seen.size > 1000) {++ const value = marketHit ? { ...marketHit.value, stale: true } : null;++ marketHit = { value, expires: now() + TTL_MS };++ return value;++ }++ first ||= data; offers.push(...data.offers); cursor = data.next_cursor;++ if (cursor != null) seen.add(cursor);++ } while (cursor != null);++ const value = { ...first, offers, next_cursor: null, stale: false };++ marketHit = { value, expires: now() + TTL_MS }; return value;++ })().finally(() => { marketPending = null; });++ return marketPending;++ }++ return { catalog, market, baseUrl, ttlMs: TTL_MS };}module.exports = { createAiCatalogue, TTL_MS };nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++const { bare, findCatalogModel } = require('./ai-join');++// The first offer supplies the displayed prices: lowest known own-page output price,++// then input price. Unknown prices sort last; never turn null into a free price.++const amount = v => v == null || !Number.isFinite(Number(v)) ? Infinity : Number(v);++function mergeMarket(catalogue, market) {++ if (!market || !Array.isArray(market.offers)) return catalogue;++ const models = new Map();++ for (const offer of market.offers) {++ const key = bare(offer.model);++ const cat = findCatalogModel(catalogue?.models, offer.model);++ let model = models.get(key);++ if (!model) {++ model = { ...cat, slug: cat?.slug || offer.model, canonical: cat?.canonical || offer.model,++ name: offer.name, kind: offer.kind, maker: offer.maker, open_weights: offer.open_weights,++ context: offer.context, max_output: offer.max_output, licence: offer.licence,++ field_sources: offer.field_sources, routed: false, uncensored: false, offers: [] };++ models.set(key, model);++ }++ const measured = offer.routed && cat?.offers?.find(o => o.provider === offer.provider && bare(o.model) === bare(offer.provider_model));++ model.offers.push({ ...(measured || {}), ...offer, model: offer.provider_model, license: offer.licence,++ source: { name: 'Provider price page', url: offer.price_source, date: offer.price_checked_at, sample_size: measured?.requests_7d ?? null } });++ model.routed ||= offer.routed; model.uncensored ||= offer.uncensored;++ }++ // Routed models with no own-page pricebook entry still remain discoverable.++ for (const cat of catalogue?.models || []) {++ const key = bare(cat.slug || cat.canonical);++ const model = models.get(key);++ if (!model) {++ models.set(key, { ...cat, routed: true, offers: (cat.offers || []).map(o => ({ ...o, routed: true })) });++ continue;++ }++ for (const offer of cat.offers || []) {++ model.routed = true;++ const matched = model.offers.find(o => o.provider === offer.provider && bare(o.model) === bare(offer.model));++ if (matched) { Object.assign(matched, { ...offer, ...matched, routed: true }); continue; }++ // A routed endpoint may not have a pricebook observation yet. Keep its measurements,++ // but only a sourced page price may compete with the market's advertised prices.++ model.offers.push({ ...offer, routed: true,++ price: /^https:\/\//.test(offer.price_source || '') ? offer.price : null,++ source: offer.source || { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: offer.requests_7d ?? null } });++ }++ }++ for (const model of models.values()) model.offers.sort((a,b) => amount(a.price?.out) - amount(b.price?.out) || amount(a.price?.in) - amount(b.price?.in));++ return { ...catalogue, generated_at: market.generated_at, metadata: market.metadata, stale: market.stale || false, models: [...models.values()] };++}++async function comparisonCatalogue(client) {++ if (!client) return null;++ const [catalogue, market] = await Promise.all([client.catalog(), client.market?.()]);++ return mergeMarket(catalogue, market);++}++module.exports = { mergeMarket, comparisonCatalogue };@@@ -121,7 +121,7 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analyticsconst 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()]);++ const [kindsResult, catalogueResult] = await Promise.all([bench && bench.kinds(), require('../data/ai-market').comparisonCatalogue(aiCatalogue)]);if (!kindsResult) return null;const kinds = kindsResult.data && kindsResult.data.kinds || [];const catalogModels = catalogueResult && catalogueResult.models || [];@@@ -133,15 +133,23 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analyticsfor (const entry of list) {const id = entry.subject || entry.model || entry.name;const catalogue = findCatalogModel(catalogModels, id);-- // Bench starts every entrant at its base rating; with no games that number is not a rating.const elo = entry.elo != null && Number.isFinite(Number(entry.elo)) && Number(entry.games) !== 0 ? Number(entry.elo) : null;-- rows.push({ id, kind: kinds[i].id, maker: makerOf(catalogue, id), weights: catalogue && (catalogue.weights || (catalogue.open_weights === true ? 'open' : null)) || null,++ rows.push({ id, routed: catalogue ? catalogue.routed !== false : false, kind: kinds[i].id, maker: makerOf(catalogue, id), weights: catalogue && (catalogue.weights || (catalogue.open_weights == null ? null : catalogue.open_weights ? 'open' : 'closed')) || 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 } });}}++ const rated = new Set(rows.map(row => findCatalogModel(catalogModels, row.id)).filter(Boolean));++ for (const model of catalogModels) {++ if (rated.has(model)) continue;++ rows.push({ id: model.slug || model.canonical, kind: model.kind || null, maker: makerOf(model),++ routed: model.routed !== false, weights: model.open_weights == null ? null : model.open_weights ? 'open' : 'closed',++ status: model.status || 'current', uncensored: Boolean(model.uncensored), community: Boolean(model.community),++ rating: { value: null, reason: 'Not rated yet.' }, benchmark: { value: null, reason: 'Not rated yet.' },++ provider_offers: model.offers || [], source: { name: 'OpenVibe.AI', date: catalogueResult.generated_at || null, sample_size: 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];@@@ -163,10 +171,10 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analyticsif (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.price) rows = rows.filter((x) => x.provider_offers.some((o) => o.price && o.price.out != null && (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));++ if (q.sort === 'price') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => (o.price?.out == null ? NaN : Number(o.price.out))).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => (o.price?.out == null ? NaN : Number(o.price.out))).filter(Number.isFinite), Infinity));++ if (q.sort === 'speed') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => (o.speed?.p50_ms == null ? NaN : Number(o.speed.p50_ms))).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => (o.speed?.p50_ms == null ? NaN : Number(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);if (q.format === 'csv') {const csv = (v) => `"${String(v == null ? '' : v).replace(/"/g, '""')}"`;@@@ -182,15 +190,15 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analyticsreturn 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();++ const data = await require('../data/ai-market').comparisonCatalogue(aiCatalogue);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 { ...offer, model: model.slug, routed: offer.routed !== false, 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: offer.routed === false ? null : offer.speed?.tokens_per_second ?? null, reason: offer.routed === false ? 'Not routed here' : 'Not measured yet' },++ ttft_ms: { value: offer.routed === false ? null : offer.speed?.ttft_ms ?? null, reason: offer.routed === false ? 'Not routed here' : 'Not measured yet' }, source: evidence };}));-- return res.json(aiEnvelope('report.ai-providers@1', { generated_at: data.generated_at, providers }));++ return res.json(aiEnvelope('report.ai-providers@1', { generated_at: data.generated_at, stale: data.stale || false, metadata: data.metadata || null, providers }));});r.get('/ai/compare', async (req, res) => {const ids = String(req.query.models || '').split(',').filter(Boolean).slice(0, 4);@@@ -200,9 +208,9 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analyticsr.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 cheapest = rows.filter((x) => x.provider_offers.some((o) => o.price && o.price.out != null)).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 fastest = rows.filter((x) => x.provider_offers.some((o) => Number.isFinite((o.speed?.p50_ms == null ? NaN : Number(o.speed.p50_ms))))).sort((a, b) => Math.min(...a.provider_offers.map((o) => (o.speed?.p50_ms == null ? NaN : Number(o.speed.p50_ms))).filter(Number.isFinite)) - Math.min(...b.provider_offers.map((o) => (o.speed?.p50_ms == null ? NaN : Number(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;@@@ -61,7 +61,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =}async function comparisonData() {-- const [kindsResult, catalogue] = await Promise.all([bench.kinds(), aiCatalogue && aiCatalogue.catalog()]);++ const [kindsResult, catalogue] = await Promise.all([bench.kinds(), require('../data/ai-market').comparisonCatalogue(aiCatalogue)]);const kinds = kindsResult && kindsResult.data && kindsResult.data.kinds || [];const boards = await Promise.all(kinds.map((kind) => bench.board(kind.id, 'elo')));const catModels = (catalogue && catalogue.models || []).map((model) => ({ ...model, offers: (model.offers || []).map((offer) => ({ ...offer, source: offer.source || { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: offer.requests_7d ?? null } })) }));@@@ -72,7 +72,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =for (const entry of list) {const id = entry.subject || entry.model || entry.name;const cat = findCat(id);-- rows.push({ id, kind: kinds[i].id, model_kind: cat && cat.kind || 'chat', maker: makerOf(cat, id), weights: cat && (cat.weights || (cat.open_weights ? 'open' : null)), status: cat && cat.status || 'current', uncensored: Boolean(cat && cat.uncensored), community: Boolean(cat && cat.community), rating: { value: entry.elo == null || Number(entry.games) === 0 ? null : Number(entry.elo), interval: Number(entry.games) === 0 ? [null, null] : [entry.ci_lower ?? null, entry.ci_upper ?? null], source: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null }, benchmark: { value: entry.score == null ? null : Number(entry.score), reason: entry.score == null ? 'No benchmark score.' : undefined }, provider_offers: cat && Array.isArray(cat.offers) ? cat.offers : [], source: { name: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null } });++ rows.push({ id, routed: cat ? cat.routed !== false : false, kind: kinds[i].id, model_kind: cat && cat.kind || 'chat', maker: makerOf(cat, id), weights: cat && (cat.weights || (cat.open_weights == null ? null : cat.open_weights ? 'open' : 'closed')), status: cat && cat.status || 'current', uncensored: Boolean(cat && cat.uncensored), community: Boolean(cat && cat.community), rating: { value: entry.elo == null || Number(entry.games) === 0 ? null : Number(entry.elo), interval: Number(entry.games) === 0 ? [null, null] : [entry.ci_lower ?? null, entry.ci_upper ?? null], source: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null }, benchmark: { value: entry.score == null ? null : Number(entry.score), reason: entry.score == null ? 'No benchmark score.' : undefined }, provider_offers: cat && Array.isArray(cat.offers) ? cat.offers : [], source: { name: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null } });}}const grouped = new Map();@@@ -91,7 +91,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =const id = cat.slug || cat.canonical || cat.id;if (!id) continue;const source = { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: null };-- rows.push({ id, kind: cat.kind || null, model_kind: cat.kind || 'chat', maker: makerOf(cat, id), weights: cat.weights || (cat.open_weights ? 'open' : null), status: cat.status || 'current', uncensored: Boolean(cat.uncensored), community: Boolean(cat.community), rating: { value: null, interval: [null, null], source: 'OpenVibe.Bench', date: null, sample_size: 0 }, benchmark: { value: null, reason: 'Not rated yet.' }, provider_offers: Array.isArray(cat.offers) ? cat.offers : [], source, task_ratings: [], unrated: true });++ rows.push({ id, routed: cat.routed !== false, kind: cat.kind || null, model_kind: cat.kind || 'chat', maker: makerOf(cat, id), weights: cat.weights || (cat.open_weights == null ? null : cat.open_weights ? 'open' : 'closed'), status: cat.status || 'current', uncensored: Boolean(cat.uncensored), community: Boolean(cat.community), rating: { value: null, interval: [null, null], source: 'OpenVibe.Bench', date: null, sample_size: 0 }, benchmark: { value: null, reason: 'Not rated yet.' }, provider_offers: Array.isArray(cat.offers) ? cat.offers : [], source, task_ratings: [], unrated: true });}for (const kind of kinds) {const ranked = rows.map((row) => ({ row, task: row.task_ratings.find((rating) => rating.kind === kind.id) })).filter((x) => x.task).sort((a, b) => (b.task.rating.value ?? -Infinity) - (a.task.rating.value ?? -Infinity));@@@ -112,7 +112,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =}});const tasks = kinds.map((kind) => ({ id: kind.id, name: kind.name }));-- const providers = catalogue && Array.isArray(catalogue.models) ? catalogue.models.flatMap((m) => (m.offers || []).map((o) => ({ ...o, model: m.slug }))) : [];++ const providers = catModels.flatMap((m) => (m.offers || []).map((o) => ({ ...o, routed: o.routed !== false, model: m.slug })));return { rows, providers, tasks, picks: [], catalogue };}@@@ -147,7 +147,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =let rows = data.rows;if (query.kind) rows = rows.map((x) => {const task = x.task_ratings.find((rating) => rating.kind === query.kind);-- return task ? { ...x, kind: query.kind, rating: task.rating, benchmark: task.benchmark } : null;++ return task ? { ...x, kind: query.kind, rating: task.rating, benchmark: task.benchmark } : x.kind === query.kind ? x : null;}).filter(Boolean);const type = ['chat', 'embedding', 'stt', 'all'].includes(query.type) ? query.type : 'chat';if (type !== 'all') rows = rows.filter((x) => (x.model_kind || 'chat') === type);@@@ -31,6 +31,7 @@ function makerName(row) {const shortName = (id) => String(id || '').replace(/^[^/]+\//, '') || String(id || '');function tags(row) {const t = [];++ if (typeof row.routed === 'boolean') t.push(kit.badge(row.routed ? 'Routed here' : 'Not routed here'));if (row.weights === 'open' || row.weights === 'open_weights') t.push(kit.badge('Open weights'));if (row.uncensored) t.push(kit.badge('18+', 'warn'));if (row.community) t.push(kit.badge('Community machines'));@@@ -55,7 +56,7 @@ function ratingCell(row, { range = false } = {}) {}const rankText = (row, i) => row.rank && row.rank.min != null ? (row.rank.min === row.rank.max ? String(row.rank.min) : `${row.rank.min}–${row.rank.max}`) : (rated(row) ? String(i + 1) : '–');const offersOf = (row) => (row && row.provider_offers || []).filter(Boolean);--const measured = (o) => Number(o.requests_7d) > 0;++const measured = (o) => o.routed !== false && Number(o.requests_7d) > 0;function cheapest(row) {const priced = offersOf(row).filter((o) => o.price && value(o.price.out) != null);return priced.sort((a, b) => Number(a.price.out) - Number(b.price.out))[0] || null;@@@ -188,10 +189,10 @@ function providerTable(list) {provider: `<span class="ai-prov">${providerMark(p)}<b>${esc(providerName(p))}</b></span>`,model: `<a href="${idPath(p.model)}">${esc(shortName(p.model))}</a>`,price: p.price && value(p.price.out) != null ? `<b>${esc(money(p.price.out))}</b> out · ${esc(money(p.price.in) || '–')} in` : quiet('No price listed'),-- reply: measured(p) && p.speed && value(p.speed.p50_ms) != null ? `<b>${esc(ms(p.speed.p50_ms))}</b><small>p95 ${esc(ms(p.speed.p95_ms) || '–')}</small>` : quiet('No traffic yet'),-- errors: measured(p) && share(p.error_rate) != null ? `<b class="${Number(typeof p.error_rate === 'object' ? p.error_rate.value : p.error_rate) > 0.05 ? 'ai-bad' : ''}">${esc(share(p.error_rate))}</b>` : quiet('–'),-- uptime: measured(p) && share(p.uptime) != null ? `<b class="${Number(p.uptime) < 0.95 ? 'ai-bad' : ''}">${esc(share(p.uptime))}</b>` : quiet('–'),-- requests: measured(p) ? Number(p.requests_7d).toLocaleString('en-US') : quiet('0'),++ reply: p.routed === false ? quiet('Not routed here') : measured(p) && p.speed && value(p.speed.p50_ms) != null ? `<b>${esc(ms(p.speed.p50_ms))}</b><small>p95 ${esc(ms(p.speed.p95_ms) || '–')}</small>` : quiet('Not measured yet'),++ errors: p.routed === false ? quiet('Not routed here') : measured(p) && share(p.error_rate) != null ? `<b class="${Number(typeof p.error_rate === 'object' ? p.error_rate.value : p.error_rate) > 0.05 ? 'ai-bad' : ''}">${esc(share(p.error_rate))}</b>` : quiet('Not measured yet'),++ uptime: p.routed === false ? quiet('Not routed here') : measured(p) && share(p.uptime) != null ? `<b class="${Number(p.uptime) < 0.95 ? 'ai-bad' : ''}">${esc(share(p.uptime))}</b>` : quiet('Not measured yet'),++ requests: p.routed === false ? quiet('Not routed here') : measured(p) ? Number(p.requests_7d).toLocaleString('en-US') : quiet('Not measured yet'),}));return kit.table({columns: [@@@ -209,7 +210,7 @@ function providerTable(list) {function providers({ providers: list = [] } = {}) {const sorted = [...list].sort((a, b) => (Number(b.requests_7d) || 0) - (Number(a.requests_7d) || 0));const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI providers</h1>`-- + `<p class="ai-lede">Every provider and model the Exchange lists. Prices come from each provider's own page. Reply time, errors and uptime are measured on real requests over the last seven days; a provider with no traffic yet has no measurement.</p></header>`++ + `<p class="ai-lede">Every model and provider offer in the market. Prices come from each provider's own page. Reply time, errors and uptime are measured on real requests over the last seven days; offers outside the router say “Not routed here”, while routed offers without measurements say “Not measured yet”.</p></header>`+ providerTable(sorted);return page('AI providers', 'AI providers compared: price per million tokens, reply time, error rate and uptime measured on real traffic.', body, '/ai/providers');}nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++const assert = require('assert');++const { createAiCatalogue } = require('../server/data/ai-catalogue');++const { mergeMarket } = require('../server/data/ai-market');++const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot');++const market = { generated_at: '2026-11-09T00:00:00Z', offers: [++ { model:'openai/gpt-5-nano', provider:'openai', provider_name:'OpenAI', provider_model:'gpt-5-nano', name:'GPT nano', kind:'chat', maker:'openai', routed:true, price:{in:0.1,out:0.5}, price_source:'https://openai.com/api/pricing/', price_checked_at:'2026-11-09' },++ { model:'openai/gpt-5-nano', provider:'cheap', provider_name:'Cheaper fixture', provider_model:'gpt-5-nano', name:'GPT nano', kind:'chat', maker:'openai', routed:false, price:{in:0.01,out:0.2}, price_source:'https://example.test/prices' },++ { model:'qwen/unrated', provider:'qwen', provider_model:'unrated', name:'Unrated', kind:'chat', maker:'qwen', routed:false, price:{in:null,out:null} },++] };++const catalogue = { models:[{ slug:'openai/gpt-5-nano', offers:[{provider:'openai',model:'gpt-5-nano',speed:{p50_ms:1234},requests_7d:42,uptime:0.99,error_rate:0.01}] }] };++(async()=>{++ await check('market client follows pages, shares cache and keeps a complete stale result',async()=>{++ let now=1000,calls=0,fail=false;++ const client=createAiCatalogue({now:()=>now,log:{warn(){}},fetchImpl:async url=>{++ calls++; if(fail)return jsonResponse({},503);++ return jsonResponse({...market,offers:url.includes('cursor=2')?market.offers.slice(2):market.offers.slice(0,2),next_cursor:url.includes('cursor=2')?null:'2'});++ }});++ const [a,b]=await Promise.all([client.market(),client.market()]); assert.strictEqual(calls,2);assert.strictEqual(a.offers.length,3);assert.deepStrictEqual(a,b);++ await client.market();assert.strictEqual(calls,2);now+=120001;fail=true;++ const stale=await client.market();assert(stale.stale);assert.strictEqual(stale.offers.length,3);++ });++ await check('cheapest provider wins without copying routed measurements onto another offer',()=>{++ const result=mergeMarket(catalogue,market);assert.strictEqual(result.models.length,2);++ const model=result.models[0];assert(model.routed);assert.strictEqual(model.offers[0].provider,'cheap');assert.strictEqual(model.offers[0].speed,undefined);assert.strictEqual(model.offers[1].speed.p50_ms,1234);++ });++ await check('routed offers absent from the pricebook still mark their market model as routed',()=>{++ const result=mergeMarket(catalogue,{...market,offers:market.offers.filter(o=>o.provider!=='openai')});++ const model=result.models.find(m=>m.slug==='openai/gpt-5-nano');++ assert(model.routed);assert.strictEqual(model.offers[0].provider,'cheap');++ assert.strictEqual(model.offers.find(o=>o.provider==='openai').speed.p50_ms,1234);++ });++ const source=stubFetch();++ const t=await boot({env:{OV_AI_INTERNAL_URL:'http://127.0.0.1:5231',OV_BENCH_INTERNAL_URL:'http://127.0.0.1:5230'},fetchImpl:async(url,opts)=>{++ const u=new URL(String(url));++ if(u.pathname==='/api/v1/exchange/market')return jsonResponse({...market,next_cursor:null});++ if(u.pathname==='/api/v1/exchange/catalog')return jsonResponse(catalogue);++ if(u.pathname==='/api/v1/kinds')return jsonResponse({kinds:[{id:'chat.arena',name:'Chat'}]});++ if(u.pathname==='/api/v1/boards/chat.arena')return jsonResponse({board:{rows:[{subject:'gpt-5-nano-2025-08-07',elo:1000,games:4}]}});++ return source(url,opts);++ }});++ try {++ await check('pages list every market model, cheapest price, route marks and measured-only routed rows',async()=>{++ const r=await t.get('/ai/leaderboard');assert.strictEqual(r.status,200);assert(r.text.includes('qwen/unrated'));assert(r.text.includes('$0.01'));assert(r.text.includes('Routed here'));assert(r.text.includes('Not routed here'));++ assert(r.text.indexOf('gpt-5-nano-2025-08-07')<r.text.indexOf('qwen/unrated'));++ const p=await t.get('/ai/providers');assert(p.text.includes('Cheaper fixture'));assert(/1\.2[0-9]*\s*s/.test(p.text));assert(p.text.includes('Not routed here'));++ const api=await t.get('/api/v1/ai/leaderboard');assert(api.json().rows.some(r=>r.id==='qwen/unrated'&&!r.routed));++ });++ } finally { await t.close(); }++ done();++})().catch(e=>{console.error(e);process.exitCode=1;});@@@ -162,7 +162,7 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')const filtered = await fixture.get('/ai/leaderboard?maker=Maker%20A&weights=open&price=low&status=current&kind=code');assert.ok(filtered.text.includes('maker/model-a') && !filtered.text.includes('maker/model-b'));const provider = await fixture.get('/ai/providers');-- assert.ok(provider.text.includes('Provider One') && provider.text.includes('No traffic yet'), 'an offer without requests has no measurement');++ assert.ok(provider.text.includes('Provider One') && provider.text.includes('Not measured yet'), 'an offer without requests has no measurement');assert.strictEqual((await fixture.get('/ai/compare?models=maker%2Fmodel-a,maker%2Fmodel-b')).status, 301);assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-a-vs-maker%2Fmodel-b')).status, 200);assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-b-vs-maker%2Fmodel-a')).status, 301);
@@ -262,3 +262,32 @@ HMAC. See [SECURITY.md](SECURITY.md).
@@ -262,3 +262,32 @@ HMAC. See [SECURITY.md](SECURITY.md).
- openvibe-shared: v4.177.0
- openvibe-shared: v4.177.0
- openvibe-publishing: v1.5.0
- openvibe-publishing: v1.5.0
<!-- versions:end -->
<!-- versions:end -->
+
+### AI market data and provenance
+
+The AI pages and `GET /api/v1/ai/leaderboard` and `/api/v1/ai/providers` read
+`https://api.openvibe.exchange/api/v1/exchange/market` alongside the routed catalog.
+Report follows every market cursor and caches the complete result for 120 seconds.
+An upstream failure retains the last complete market snapshot with `stale: true`;
+without a snapshot, Report falls back to the routed catalog.
+
+The market is public (`exchange.market@1`, cached 60 seconds). It accepts `limit`
+(1–500, default 100), opaque `cursor`, `maker`, `open_weights=true|false`, and
+`kind=chat|embedding|stt|image`. Responses contain `offers`, `total_models`,
+`total_offers`, `next_cursor`, `generated_at`, and the models.dev metadata snapshot's
+`source`, `checked_at`, and `stale` state. Each offer identifies `model`, `provider`,
+`provider_model`, `routed`, `provider_access`, and the preset's `uncensored` flag.
+Prices are USD per 1M tokens (`in`, `out`, `cached`), with `price_source` pointing
+to that provider's own page and `price_checked_at`. Unknown prices remain null.
+A latest observed price can differ from the price currently approved for routing.
+
+Model fields include maker, context, max output, open weights, licence, release
+date, knowledge cutoff and modalities. `field_sources` names the provider page or
+`https://models.dev/api.json` for each known field. Metadata refreshes daily on
+read; a failed refresh preserves the previous copy and marks it stale.
+
+Comparison rows include every market model, rated rows first, then unrated rows.
+Their first provider offer has the lowest known own-page output price, breaking
+ties by input price; input/output/cached prices always come from that same offer.
+Provider measurements join only routed offers. Other offers display “Not routed
+here”; missing measurements on routed offers display “Not measured yet”.
@@ -17,7 +17,28 @@ function createAiCatalogue({ config = {}, fetchImpl = globalThis.fetch, log = co
@@ -17,7 +17,28 @@ function createAiCatalogue({ config = {}, fetchImpl = globalThis.fetch, log = co
})();
})();
return pending;
return pending;
}
}
- return { catalog, baseUrl, ttlMs: TTL_MS };
+ let marketHit = null; let marketPending = null;
+ async function market() {
+ if (marketHit && marketHit.expires > now()) return marketHit.value;
+ if (!marketPending) marketPending = (async () => {
+ const offers = []; const seen = new Set(); let cursor = null; let first = null;
+ do {
+ const suffix = cursor == null ? '' : `&cursor=${encodeURIComponent(cursor)}`;
+ const data = await requestJson(`${baseUrl}/api/v1/exchange/market?limit=500${suffix}`, { fetchImpl, log, label: 'ai exchange market', timeoutMs });
+ if (!data || !Array.isArray(data.offers) || !('next_cursor' in data) || (data.next_cursor != null && typeof data.next_cursor !== 'string') || (first && first.generated_at !== data.generated_at) || seen.has(data.next_cursor) || seen.size > 1000) {
+ const value = marketHit ? { ...marketHit.value, stale: true } : null;
+ marketHit = { value, expires: now() + TTL_MS };
+ return value;
+ }
+ first ||= data; offers.push(...data.offers); cursor = data.next_cursor;
+ if (cursor != null) seen.add(cursor);
+ } while (cursor != null);
+ const value = { ...first, offers, next_cursor: null, stale: false };
+ marketHit = { value, expires: now() + TTL_MS }; return value;
+ })().finally(() => { marketPending = null; });
+ return marketPending;
+ }
+ return { catalog, market, baseUrl, ttlMs: TTL_MS };
}
}
module.exports = { createAiCatalogue, TTL_MS };
module.exports = { createAiCatalogue, TTL_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+const { bare, findCatalogModel } = require('./ai-join');
+// The first offer supplies the displayed prices: lowest known own-page output price,
+// then input price. Unknown prices sort last; never turn null into a free price.
+const amount = v => v == null || !Number.isFinite(Number(v)) ? Infinity : Number(v);
+function mergeMarket(catalogue, market) {
+ if (!market || !Array.isArray(market.offers)) return catalogue;
+ const models = new Map();
+ for (const offer of market.offers) {
+ const key = bare(offer.model);
+ const cat = findCatalogModel(catalogue?.models, offer.model);
+ let model = models.get(key);
+ if (!model) {
+ model = { ...cat, slug: cat?.slug || offer.model, canonical: cat?.canonical || offer.model,
+ name: offer.name, kind: offer.kind, maker: offer.maker, open_weights: offer.open_weights,
+ context: offer.context, max_output: offer.max_output, licence: offer.licence,
+ field_sources: offer.field_sources, routed: false, uncensored: false, offers: [] };
+ models.set(key, model);
+ }
+ const measured = offer.routed && cat?.offers?.find(o => o.provider === offer.provider && bare(o.model) === bare(offer.provider_model));
+ model.offers.push({ ...(measured || {}), ...offer, model: offer.provider_model, license: offer.licence,
+ source: { name: 'Provider price page', url: offer.price_source, date: offer.price_checked_at, sample_size: measured?.requests_7d ?? null } });
+ model.routed ||= offer.routed; model.uncensored ||= offer.uncensored;
+ }
+ // Routed models with no own-page pricebook entry still remain discoverable.
+ for (const cat of catalogue?.models || []) {
+ const key = bare(cat.slug || cat.canonical);
+ const model = models.get(key);
+ if (!model) {
+ models.set(key, { ...cat, routed: true, offers: (cat.offers || []).map(o => ({ ...o, routed: true })) });
+ continue;
+ }
+ for (const offer of cat.offers || []) {
+ model.routed = true;
+ const matched = model.offers.find(o => o.provider === offer.provider && bare(o.model) === bare(offer.model));
+ if (matched) { Object.assign(matched, { ...offer, ...matched, routed: true }); continue; }
+ // A routed endpoint may not have a pricebook observation yet. Keep its measurements,
+ // but only a sourced page price may compete with the market's advertised prices.
+ model.offers.push({ ...offer, routed: true,
+ price: /^https:\/\//.test(offer.price_source || '') ? offer.price : null,
+ source: offer.source || { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: offer.requests_7d ?? null } });
+ }
+ }
+ for (const model of models.values()) model.offers.sort((a,b) => amount(a.price?.out) - amount(b.price?.out) || amount(a.price?.in) - amount(b.price?.in));
+ return { ...catalogue, generated_at: market.generated_at, metadata: market.metadata, stale: market.stale || false, models: [...models.values()] };
+}
+async function comparisonCatalogue(client) {
+ if (!client) return null;
+ const [catalogue, market] = await Promise.all([client.catalog(), client.market?.()]);
+ return mergeMarket(catalogue, market);
+}
+module.exports = { mergeMarket, comparisonCatalogue };
@@ -121,7 +121,7 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
@@ -121,7 +121,7 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
const aiEnvelope = (schema, data) => ({ '$schema': schema, generated_at: new Date(now()).toISOString(), ...data });
const aiEnvelope = (schema, data) => ({ '$schema': schema, generated_at: new Date(now()).toISOString(), ...data });
const aiRows = async () => {
const aiRows = async () => {
- const [kindsResult, catalogueResult] = await Promise.all([bench && bench.kinds(), aiCatalogue && aiCatalogue.catalog()]);
+ const [kindsResult, catalogueResult] = await Promise.all([bench && bench.kinds(), require('../data/ai-market').comparisonCatalogue(aiCatalogue)]);
if (!kindsResult) return null;
if (!kindsResult) return null;
const kinds = kindsResult.data && kindsResult.data.kinds || [];
const kinds = kindsResult.data && kindsResult.data.kinds || [];
const catalogModels = catalogueResult && catalogueResult.models || [];
const catalogModels = catalogueResult && catalogueResult.models || [];
@@ -133,15 +133,23 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
@@ -133,15 +133,23 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
for (const entry of list) {
for (const entry of list) {
const id = entry.subject || entry.model || entry.name;
const id = entry.subject || entry.model || entry.name;
const catalogue = findCatalogModel(catalogModels, id);
const catalogue = findCatalogModel(catalogModels, id);
- // Bench starts every entrant at its base rating; with no games that number is not a rating.
const elo = entry.elo != null && Number.isFinite(Number(entry.elo)) && Number(entry.games) !== 0 ? Number(entry.elo) : null;
const elo = entry.elo != null && Number.isFinite(Number(entry.elo)) && Number(entry.games) !== 0 ? Number(entry.elo) : null;
- rows.push({ id, kind: kinds[i].id, maker: makerOf(catalogue, id), weights: catalogue && (catalogue.weights || (catalogue.open_weights === true ? 'open' : null)) || null,
+ rows.push({ id, routed: catalogue ? catalogue.routed !== false : false, kind: kinds[i].id, maker: makerOf(catalogue, id), weights: catalogue && (catalogue.weights || (catalogue.open_weights == null ? null : catalogue.open_weights ? 'open' : 'closed')) || null,
status: catalogue && catalogue.status || 'current', uncensored: catalogue && catalogue.uncensored === true,
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 },
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 },
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 } });
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 } });
}
}
}
}
+ const rated = new Set(rows.map(row => findCatalogModel(catalogModels, row.id)).filter(Boolean));
+ for (const model of catalogModels) {
+ if (rated.has(model)) continue;
+ rows.push({ id: model.slug || model.canonical, kind: model.kind || null, maker: makerOf(model),
+ routed: model.routed !== false, weights: model.open_weights == null ? null : model.open_weights ? 'open' : 'closed',
+ status: model.status || 'current', uncensored: Boolean(model.uncensored), community: Boolean(model.community),
+ rating: { value: null, reason: 'Not rated yet.' }, benchmark: { value: null, reason: 'Not rated yet.' },
+ provider_offers: model.offers || [], source: { name: 'OpenVibe.AI', date: catalogueResult.generated_at || null, sample_size: null } });
+ }
rows.sort((a, b) => (b.rating.value ?? -Infinity) - (a.rating.value ?? -Infinity) || String(a.id).localeCompare(String(b.id)));
rows.sort((a, b) => (b.rating.value ?? -Infinity) - (a.rating.value ?? -Infinity) || String(a.id).localeCompare(String(b.id)));
rows.forEach((row, index) => {
rows.forEach((row, index) => {
const lo = row.rating.interval && row.rating.interval[0]; const hi = row.rating.interval && row.rating.interval[1];
const lo = row.rating.interval && row.rating.interval[0]; const hi = row.rating.interval && row.rating.interval[1];
@@ -163,10 +171,10 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
@@ -163,10 +171,10 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
if (q.status === 'current') rows = rows.filter((x) => x.status === 'current');
if (q.status === 'current') rows = rows.filter((x) => x.status === 'current');
if (q.uncensored === 'true') rows = rows.filter((x) => x.uncensored);
if (q.uncensored === 'true') rows = rows.filter((x) => x.uncensored);
if (q.community === 'true') rows = rows.filter((x) => x.community);
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.price) rows = rows.filter((x) => x.provider_offers.some((o) => o.price && o.price.out != null && (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 === '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));
+ if (q.sort === 'price') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => (o.price?.out == null ? NaN : Number(o.price.out))).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => (o.price?.out == null ? NaN : Number(o.price.out))).filter(Number.isFinite), Infinity));
+ if (q.sort === 'speed') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => (o.speed?.p50_ms == null ? NaN : Number(o.speed.p50_ms))).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => (o.speed?.p50_ms == null ? NaN : Number(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);
const limit = Math.max(1, Math.min(100, Number(q.limit) || 25)); const offset = Math.max(0, Number(q.cursor) || 0);
if (q.format === 'csv') {
if (q.format === 'csv') {
const csv = (v) => `"${String(v == null ? '' : v).replace(/"/g, '""')}"`;
const csv = (v) => `"${String(v == null ? '' : v).replace(/"/g, '""')}"`;
@@ -182,15 +190,15 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
@@ -182,15 +190,15 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
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 } }));
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) => {
r.get('/ai/providers', async (_req, res) => {
- const data = aiCatalogue && await aiCatalogue.catalog();
+ const data = await require('../data/ai-market').comparisonCatalogue(aiCatalogue);
if (!data) return problem(_req, res, 502, 'report.ai_source_unavailable', 'OpenVibe.AI did not answer with provider metrics.');
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 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 };
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 { ...offer, model: model.slug, routed: offer.routed !== false, 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: offer.routed === false ? null : offer.speed?.tokens_per_second ?? null, reason: offer.routed === false ? 'Not routed here' : 'Not measured yet' },
+ ttft_ms: { value: offer.routed === false ? null : offer.speed?.ttft_ms ?? null, reason: offer.routed === false ? 'Not routed here' : 'Not measured yet' }, source: evidence };
}));
}));
- return res.json(aiEnvelope('report.ai-providers@1', { generated_at: data.generated_at, providers }));
+ return res.json(aiEnvelope('report.ai-providers@1', { generated_at: data.generated_at, stale: data.stale || false, metadata: data.metadata || null, providers }));
});
});
r.get('/ai/compare', async (req, res) => {
r.get('/ai/compare', async (req, res) => {
const ids = String(req.query.models || '').split(',').filter(Boolean).slice(0, 4);
const ids = String(req.query.models || '').split(',').filter(Boolean).slice(0, 4);
@@ -200,9 +208,9 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
@@ -200,9 +208,9 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
r.get('/ai/picks', async (_req, res) => {
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 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 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 cheapest = rows.filter((x) => x.provider_offers.some((o) => o.price && o.price.out != null)).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 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 fastest = rows.filter((x) => x.provider_offers.some((o) => Number.isFinite((o.speed?.p50_ms == null ? NaN : Number(o.speed.p50_ms))))).sort((a, b) => Math.min(...a.provider_offers.map((o) => (o.speed?.p50_ms == null ? NaN : Number(o.speed.p50_ms))).filter(Number.isFinite)) - Math.min(...b.provider_offers.map((o) => (o.speed?.p50_ms == null ? NaN : Number(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.' }];
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']) {
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;
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;
@@ -61,7 +61,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
@@ -61,7 +61,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
}
}
async function comparisonData() {
async function comparisonData() {
- const [kindsResult, catalogue] = await Promise.all([bench.kinds(), aiCatalogue && aiCatalogue.catalog()]);
+ const [kindsResult, catalogue] = await Promise.all([bench.kinds(), require('../data/ai-market').comparisonCatalogue(aiCatalogue)]);
const kinds = kindsResult && kindsResult.data && kindsResult.data.kinds || [];
const kinds = kindsResult && kindsResult.data && kindsResult.data.kinds || [];
const boards = await Promise.all(kinds.map((kind) => bench.board(kind.id, 'elo')));
const boards = await Promise.all(kinds.map((kind) => bench.board(kind.id, 'elo')));
const catModels = (catalogue && catalogue.models || []).map((model) => ({ ...model, offers: (model.offers || []).map((offer) => ({ ...offer, source: offer.source || { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: offer.requests_7d ?? null } })) }));
const catModels = (catalogue && catalogue.models || []).map((model) => ({ ...model, offers: (model.offers || []).map((offer) => ({ ...offer, source: offer.source || { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: offer.requests_7d ?? null } })) }));
@@ -72,7 +72,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
@@ -72,7 +72,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
for (const entry of list) {
for (const entry of list) {
const id = entry.subject || entry.model || entry.name;
const id = entry.subject || entry.model || entry.name;
const cat = findCat(id);
const cat = findCat(id);
- rows.push({ id, kind: kinds[i].id, model_kind: cat && cat.kind || 'chat', maker: makerOf(cat, id), weights: cat && (cat.weights || (cat.open_weights ? 'open' : null)), status: cat && cat.status || 'current', uncensored: Boolean(cat && cat.uncensored), community: Boolean(cat && cat.community), rating: { value: entry.elo == null || Number(entry.games) === 0 ? null : Number(entry.elo), interval: Number(entry.games) === 0 ? [null, null] : [entry.ci_lower ?? null, entry.ci_upper ?? null], source: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null }, benchmark: { value: entry.score == null ? null : Number(entry.score), reason: entry.score == null ? 'No benchmark score.' : undefined }, provider_offers: cat && Array.isArray(cat.offers) ? cat.offers : [], source: { name: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null } });
+ rows.push({ id, routed: cat ? cat.routed !== false : false, kind: kinds[i].id, model_kind: cat && cat.kind || 'chat', maker: makerOf(cat, id), weights: cat && (cat.weights || (cat.open_weights == null ? null : cat.open_weights ? 'open' : 'closed')), status: cat && cat.status || 'current', uncensored: Boolean(cat && cat.uncensored), community: Boolean(cat && cat.community), rating: { value: entry.elo == null || Number(entry.games) === 0 ? null : Number(entry.elo), interval: Number(entry.games) === 0 ? [null, null] : [entry.ci_lower ?? null, entry.ci_upper ?? null], source: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null }, benchmark: { value: entry.score == null ? null : Number(entry.score), reason: entry.score == null ? 'No benchmark score.' : undefined }, provider_offers: cat && Array.isArray(cat.offers) ? cat.offers : [], source: { name: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null } });
}
}
}
}
const grouped = new Map();
const grouped = new Map();
@@ -91,7 +91,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
@@ -91,7 +91,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
const id = cat.slug || cat.canonical || cat.id;
const id = cat.slug || cat.canonical || cat.id;
if (!id) continue;
if (!id) continue;
const source = { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: null };
const source = { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: null };
- rows.push({ id, kind: cat.kind || null, model_kind: cat.kind || 'chat', maker: makerOf(cat, id), weights: cat.weights || (cat.open_weights ? 'open' : null), status: cat.status || 'current', uncensored: Boolean(cat.uncensored), community: Boolean(cat.community), rating: { value: null, interval: [null, null], source: 'OpenVibe.Bench', date: null, sample_size: 0 }, benchmark: { value: null, reason: 'Not rated yet.' }, provider_offers: Array.isArray(cat.offers) ? cat.offers : [], source, task_ratings: [], unrated: true });
+ rows.push({ id, routed: cat.routed !== false, kind: cat.kind || null, model_kind: cat.kind || 'chat', maker: makerOf(cat, id), weights: cat.weights || (cat.open_weights == null ? null : cat.open_weights ? 'open' : 'closed'), status: cat.status || 'current', uncensored: Boolean(cat.uncensored), community: Boolean(cat.community), rating: { value: null, interval: [null, null], source: 'OpenVibe.Bench', date: null, sample_size: 0 }, benchmark: { value: null, reason: 'Not rated yet.' }, provider_offers: Array.isArray(cat.offers) ? cat.offers : [], source, task_ratings: [], unrated: true });
}
}
for (const kind of kinds) {
for (const kind of kinds) {
const ranked = rows.map((row) => ({ row, task: row.task_ratings.find((rating) => rating.kind === kind.id) })).filter((x) => x.task).sort((a, b) => (b.task.rating.value ?? -Infinity) - (a.task.rating.value ?? -Infinity));
const ranked = rows.map((row) => ({ row, task: row.task_ratings.find((rating) => rating.kind === kind.id) })).filter((x) => x.task).sort((a, b) => (b.task.rating.value ?? -Infinity) - (a.task.rating.value ?? -Infinity));
@@ -112,7 +112,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
@@ -112,7 +112,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
}
}
});
});
const tasks = kinds.map((kind) => ({ id: kind.id, name: kind.name }));
const tasks = kinds.map((kind) => ({ id: kind.id, name: kind.name }));
- const providers = catalogue && Array.isArray(catalogue.models) ? catalogue.models.flatMap((m) => (m.offers || []).map((o) => ({ ...o, model: m.slug }))) : [];
+ const providers = catModels.flatMap((m) => (m.offers || []).map((o) => ({ ...o, routed: o.routed !== false, model: m.slug })));
return { rows, providers, tasks, picks: [], catalogue };
return { rows, providers, tasks, picks: [], catalogue };
}
}
@@ -147,7 +147,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
@@ -147,7 +147,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
let rows = data.rows;
let rows = data.rows;
if (query.kind) rows = rows.map((x) => {
if (query.kind) rows = rows.map((x) => {
const task = x.task_ratings.find((rating) => rating.kind === query.kind);
const task = x.task_ratings.find((rating) => rating.kind === query.kind);
- return task ? { ...x, kind: query.kind, rating: task.rating, benchmark: task.benchmark } : null;
+ return task ? { ...x, kind: query.kind, rating: task.rating, benchmark: task.benchmark } : x.kind === query.kind ? x : null;
}).filter(Boolean);
}).filter(Boolean);
const type = ['chat', 'embedding', 'stt', 'all'].includes(query.type) ? query.type : 'chat';
const type = ['chat', 'embedding', 'stt', 'all'].includes(query.type) ? query.type : 'chat';
if (type !== 'all') rows = rows.filter((x) => (x.model_kind || 'chat') === type);
if (type !== 'all') rows = rows.filter((x) => (x.model_kind || 'chat') === type);
@@ -31,6 +31,7 @@ function makerName(row) {
@@ -31,6 +31,7 @@ function makerName(row) {
const shortName = (id) => String(id || '').replace(/^[^/]+\//, '') || String(id || '');
const shortName = (id) => String(id || '').replace(/^[^/]+\//, '') || String(id || '');
function tags(row) {
function tags(row) {
const t = [];
const t = [];
+ if (typeof row.routed === 'boolean') t.push(kit.badge(row.routed ? 'Routed here' : 'Not routed here'));
if (row.weights === 'open' || row.weights === 'open_weights') t.push(kit.badge('Open weights'));
if (row.weights === 'open' || row.weights === 'open_weights') t.push(kit.badge('Open weights'));
if (row.uncensored) t.push(kit.badge('18+', 'warn'));
if (row.uncensored) t.push(kit.badge('18+', 'warn'));
if (row.community) t.push(kit.badge('Community machines'));
if (row.community) t.push(kit.badge('Community machines'));
@@ -55,7 +56,7 @@ function ratingCell(row, { range = false } = {}) {
@@ -55,7 +56,7 @@ function ratingCell(row, { range = false } = {}) {
}
}
const rankText = (row, i) => row.rank && row.rank.min != null ? (row.rank.min === row.rank.max ? String(row.rank.min) : `${row.rank.min}–${row.rank.max}`) : (rated(row) ? String(i + 1) : '–');
const rankText = (row, i) => row.rank && row.rank.min != null ? (row.rank.min === row.rank.max ? String(row.rank.min) : `${row.rank.min}–${row.rank.max}`) : (rated(row) ? String(i + 1) : '–');
const offersOf = (row) => (row && row.provider_offers || []).filter(Boolean);
const offersOf = (row) => (row && row.provider_offers || []).filter(Boolean);
-const measured = (o) => Number(o.requests_7d) > 0;
+const measured = (o) => o.routed !== false && Number(o.requests_7d) > 0;
function cheapest(row) {
function cheapest(row) {
const priced = offersOf(row).filter((o) => o.price && value(o.price.out) != null);
const priced = offersOf(row).filter((o) => o.price && value(o.price.out) != null);
return priced.sort((a, b) => Number(a.price.out) - Number(b.price.out))[0] || null;
return priced.sort((a, b) => Number(a.price.out) - Number(b.price.out))[0] || null;
@@ -188,10 +189,10 @@ function providerTable(list) {
@@ -188,10 +189,10 @@ function providerTable(list) {
provider: `<span class="ai-prov">${providerMark(p)}<b>${esc(providerName(p))}</b></span>`,
provider: `<span class="ai-prov">${providerMark(p)}<b>${esc(providerName(p))}</b></span>`,
model: `<a href="${idPath(p.model)}">${esc(shortName(p.model))}</a>`,
model: `<a href="${idPath(p.model)}">${esc(shortName(p.model))}</a>`,
price: p.price && value(p.price.out) != null ? `<b>${esc(money(p.price.out))}</b> out · ${esc(money(p.price.in) || '–')} in` : quiet('No price listed'),
price: p.price && value(p.price.out) != null ? `<b>${esc(money(p.price.out))}</b> out · ${esc(money(p.price.in) || '–')} in` : quiet('No price listed'),
- reply: measured(p) && p.speed && value(p.speed.p50_ms) != null ? `<b>${esc(ms(p.speed.p50_ms))}</b><small>p95 ${esc(ms(p.speed.p95_ms) || '–')}</small>` : quiet('No traffic yet'),
- errors: measured(p) && share(p.error_rate) != null ? `<b class="${Number(typeof p.error_rate === 'object' ? p.error_rate.value : p.error_rate) > 0.05 ? 'ai-bad' : ''}">${esc(share(p.error_rate))}</b>` : quiet('–'),
- uptime: measured(p) && share(p.uptime) != null ? `<b class="${Number(p.uptime) < 0.95 ? 'ai-bad' : ''}">${esc(share(p.uptime))}</b>` : quiet('–'),
- requests: measured(p) ? Number(p.requests_7d).toLocaleString('en-US') : quiet('0'),
+ reply: p.routed === false ? quiet('Not routed here') : measured(p) && p.speed && value(p.speed.p50_ms) != null ? `<b>${esc(ms(p.speed.p50_ms))}</b><small>p95 ${esc(ms(p.speed.p95_ms) || '–')}</small>` : quiet('Not measured yet'),
+ errors: p.routed === false ? quiet('Not routed here') : measured(p) && share(p.error_rate) != null ? `<b class="${Number(typeof p.error_rate === 'object' ? p.error_rate.value : p.error_rate) > 0.05 ? 'ai-bad' : ''}">${esc(share(p.error_rate))}</b>` : quiet('Not measured yet'),
+ uptime: p.routed === false ? quiet('Not routed here') : measured(p) && share(p.uptime) != null ? `<b class="${Number(p.uptime) < 0.95 ? 'ai-bad' : ''}">${esc(share(p.uptime))}</b>` : quiet('Not measured yet'),
+ requests: p.routed === false ? quiet('Not routed here') : measured(p) ? Number(p.requests_7d).toLocaleString('en-US') : quiet('Not measured yet'),
}));
}));
return kit.table({
return kit.table({
columns: [
columns: [
@@ -209,7 +210,7 @@ function providerTable(list) {
@@ -209,7 +210,7 @@ function providerTable(list) {
function providers({ providers: list = [] } = {}) {
function providers({ providers: list = [] } = {}) {
const sorted = [...list].sort((a, b) => (Number(b.requests_7d) || 0) - (Number(a.requests_7d) || 0));
const sorted = [...list].sort((a, b) => (Number(b.requests_7d) || 0) - (Number(a.requests_7d) || 0));
const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI providers</h1>`
const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI providers</h1>`
- + `<p class="ai-lede">Every provider and model the Exchange lists. Prices come from each provider's own page. Reply time, errors and uptime are measured on real requests over the last seven days; a provider with no traffic yet has no measurement.</p></header>`
+ + `<p class="ai-lede">Every model and provider offer in the market. Prices come from each provider's own page. Reply time, errors and uptime are measured on real requests over the last seven days; offers outside the router say “Not routed here”, while routed offers without measurements say “Not measured yet”.</p></header>`
+ providerTable(sorted);
+ providerTable(sorted);
return page('AI providers', 'AI providers compared: price per million tokens, reply time, error rate and uptime measured on real traffic.', body, '/ai/providers');
return page('AI providers', 'AI providers compared: price per million tokens, reply time, error rate and uptime measured on real traffic.', body, '/ai/providers');
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+const assert = require('assert');
+const { createAiCatalogue } = require('../server/data/ai-catalogue');
+const { mergeMarket } = require('../server/data/ai-market');
+const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot');
+const market = { generated_at: '2026-11-09T00:00:00Z', offers: [
+ { model:'openai/gpt-5-nano', provider:'openai', provider_name:'OpenAI', provider_model:'gpt-5-nano', name:'GPT nano', kind:'chat', maker:'openai', routed:true, price:{in:0.1,out:0.5}, price_source:'https://openai.com/api/pricing/', price_checked_at:'2026-11-09' },
+ { model:'openai/gpt-5-nano', provider:'cheap', provider_name:'Cheaper fixture', provider_model:'gpt-5-nano', name:'GPT nano', kind:'chat', maker:'openai', routed:false, price:{in:0.01,out:0.2}, price_source:'https://example.test/prices' },
+ { model:'qwen/unrated', provider:'qwen', provider_model:'unrated', name:'Unrated', kind:'chat', maker:'qwen', routed:false, price:{in:null,out:null} },
+] };
+const catalogue = { models:[{ slug:'openai/gpt-5-nano', offers:[{provider:'openai',model:'gpt-5-nano',speed:{p50_ms:1234},requests_7d:42,uptime:0.99,error_rate:0.01}] }] };
+(async()=>{
+ await check('market client follows pages, shares cache and keeps a complete stale result',async()=>{
+ let now=1000,calls=0,fail=false;
+ const client=createAiCatalogue({now:()=>now,log:{warn(){}},fetchImpl:async url=>{
+ calls++; if(fail)return jsonResponse({},503);
+ return jsonResponse({...market,offers:url.includes('cursor=2')?market.offers.slice(2):market.offers.slice(0,2),next_cursor:url.includes('cursor=2')?null:'2'});
+ }});
+ const [a,b]=await Promise.all([client.market(),client.market()]); assert.strictEqual(calls,2);assert.strictEqual(a.offers.length,3);assert.deepStrictEqual(a,b);
+ await client.market();assert.strictEqual(calls,2);now+=120001;fail=true;
+ const stale=await client.market();assert(stale.stale);assert.strictEqual(stale.offers.length,3);
+ });
+ await check('cheapest provider wins without copying routed measurements onto another offer',()=>{
+ const result=mergeMarket(catalogue,market);assert.strictEqual(result.models.length,2);
+ const model=result.models[0];assert(model.routed);assert.strictEqual(model.offers[0].provider,'cheap');assert.strictEqual(model.offers[0].speed,undefined);assert.strictEqual(model.offers[1].speed.p50_ms,1234);
+ });
+ await check('routed offers absent from the pricebook still mark their market model as routed',()=>{
+ const result=mergeMarket(catalogue,{...market,offers:market.offers.filter(o=>o.provider!=='openai')});
+ const model=result.models.find(m=>m.slug==='openai/gpt-5-nano');
+ assert(model.routed);assert.strictEqual(model.offers[0].provider,'cheap');
+ assert.strictEqual(model.offers.find(o=>o.provider==='openai').speed.p50_ms,1234);
+ });
+ const source=stubFetch();
+ const t=await boot({env:{OV_AI_INTERNAL_URL:'http://127.0.0.1:5231',OV_BENCH_INTERNAL_URL:'http://127.0.0.1:5230'},fetchImpl:async(url,opts)=>{
+ const u=new URL(String(url));
+ if(u.pathname==='/api/v1/exchange/market')return jsonResponse({...market,next_cursor:null});
+ if(u.pathname==='/api/v1/exchange/catalog')return jsonResponse(catalogue);
+ if(u.pathname==='/api/v1/kinds')return jsonResponse({kinds:[{id:'chat.arena',name:'Chat'}]});
+ if(u.pathname==='/api/v1/boards/chat.arena')return jsonResponse({board:{rows:[{subject:'gpt-5-nano-2025-08-07',elo:1000,games:4}]}});
+ return source(url,opts);
+ }});
+ try {
+ await check('pages list every market model, cheapest price, route marks and measured-only routed rows',async()=>{
+ const r=await t.get('/ai/leaderboard');assert.strictEqual(r.status,200);assert(r.text.includes('qwen/unrated'));assert(r.text.includes('$0.01'));assert(r.text.includes('Routed here'));assert(r.text.includes('Not routed here'));
+ assert(r.text.indexOf('gpt-5-nano-2025-08-07')<r.text.indexOf('qwen/unrated'));
+ const p=await t.get('/ai/providers');assert(p.text.includes('Cheaper fixture'));assert(/1\.2[0-9]*\s*s/.test(p.text));assert(p.text.includes('Not routed here'));
+ const api=await t.get('/api/v1/ai/leaderboard');assert(api.json().rows.some(r=>r.id==='qwen/unrated'&&!r.routed));
+ });
+ } finally { await t.close(); }
+ done();
+})().catch(e=>{console.error(e);process.exitCode=1;});
@@ -162,7 +162,7 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
@@ -162,7 +162,7 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
const filtered = await fixture.get('/ai/leaderboard?maker=Maker%20A&weights=open&price=low&status=current&kind=code');
const filtered = await fixture.get('/ai/leaderboard?maker=Maker%20A&weights=open&price=low&status=current&kind=code');
assert.ok(filtered.text.includes('maker/model-a') && !filtered.text.includes('maker/model-b'));
assert.ok(filtered.text.includes('maker/model-a') && !filtered.text.includes('maker/model-b'));
const provider = await fixture.get('/ai/providers');
const provider = await fixture.get('/ai/providers');
- assert.ok(provider.text.includes('Provider One') && provider.text.includes('No traffic yet'), 'an offer without requests has no measurement');
+ assert.ok(provider.text.includes('Provider One') && provider.text.includes('Not measured yet'), 'an offer without requests has no measurement');
assert.strictEqual((await fixture.get('/ai/compare?models=maker%2Fmodel-a,maker%2Fmodel-b')).status, 301);
assert.strictEqual((await fixture.get('/ai/compare?models=maker%2Fmodel-a,maker%2Fmodel-b')).status, 301);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-a-vs-maker%2Fmodel-b')).status, 200);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-a-vs-maker%2Fmodel-b')).status, 200);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-b-vs-maker%2Fmodel-a')).status, 301);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-b-vs-maker%2Fmodel-a')).status, 301);