openvibers/openvibe.report Commits

List market models and provider offers in AI comparisons (#162)

OP OpenVibers committed 4 days ago · c6daf0d93f · 8 files, +188 −26

* AI pages read the real catalogue: every Exchange model, its makers, prices and measured speed

The catalogue was read from ai.openvibe.services, whose vhost does not pass /api/v1/exchange/catalog
(404 every time), so the leaderboard showed three models with no maker, price or speed and the
providers page was empty. Report now reads it where it is public, api.openvibe.exchange.

- Bench's dated names ("gpt-5-nano-2025-08-07") meet the catalogue's ("openai/gpt-5-nano"): one join
  in server/data/ai-join.js, used by the pages and the API alike; the maker falls back to the name's prefix.
- Every model the Exchange sells is listed with its real prices, rated or not ("Not rated yet").
- Providers: the provider's name, reply time p50/p95, error rate, uptime and requests over 7 days,
  as the catalogue measures them; no always-empty tokens/s or first-token columns.
- Styles use Report's own tokens (the sheet named tokens Report does not define, so cards were white
  on the dark theme); the rating band bar that drew 30%-70% for every model is gone; phones show every
  cell under its label instead of hiding columns six and up.
- Top picks the data cannot make yet are named in one line instead of "Unknown model" cards.
- test/ai-api.test.js now calls done(), so its failures fail the run (it failed silently on main).

* List market offers alongside routed AI models

* Merge main and preserve zero-game rating handling

* Merge Report redesign and retain market data indicators

* Assert the requested missing-measurement wording

* Preserve routed providers alongside matching market models

* Keep explicit closed-weight metadata in comparison API rows

Changes

ddiff --git a/README.md b/README.md
iindex aa06328..4fe500c 100644
---- a/README.md
++++ b/README.md
@@@ -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”.
ddiff --git a/server/data/ai-catalogue.js b/server/data/ai-catalogue.js
iindex ed4bdef..b019509 100644
---- a/server/data/ai-catalogue.js
++++ b/server/data/ai-catalogue.js
@@@ -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 };
ddiff --git a/server/data/ai-market.js b/server/data/ai-market.js
nnew file mode 100644
iindex 0000000..823b570
---- /dev/null
++++ b/server/data/ai-market.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex a6a415d..85ce0a8 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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 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, analytics
for (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, analytics
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.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, 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 } }));
});
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, analytics
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 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;
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 2ec0098..0c7eadf 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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);
ddiff --git a/server/views/ai-comparison.js b/server/views/ai-comparison.js
iindex b5b6935..cfee549 100644
---- a/server/views/ai-comparison.js
++++ b/server/views/ai-comparison.js
@@@ -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');
}
ddiff --git a/test/ai-market.test.js b/test/ai-market.test.js
nnew file mode 100644
iindex 0000000..8cf4212
---- /dev/null
++++ b/test/ai-market.test.js
@@@ -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;});
ddiff --git a/test/ai-pages.test.js b/test/ai-pages.test.js
iindex 5dd59d9..46b84a2 100644
---- a/test/ai-pages.test.js
++++ b/test/ai-pages.test.js
@@@ -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);