openvibers/openvibe.ai Commits

Exchange catalog: speed and uptime from the runs that happened (#319)

OP OpenVibers committed 7 days ago · b9b7cdadad · 5 files, +291 −79

The catalog read provider_stats_daily keyed by the registry's model name, so the resolved
upstream id every run records (gpt-5-nano-2025-08-07) never matched the row (gpt-5-nano):
one stray failure showed 0% uptime and a 25.6 s typical answer while thousands of real
answers sat in runs. It now folds the window's runs and answering attempts itself: uptime
is succeeded/(succeeded+failed), requests are real runs, cached answers follow the run they
reused and are counted apart, and p50/p95 use the receipt's TTFT where recorded else the
answering attempt's total latency. No data stays null, never 0.

The public usage read now comes from the finished runs too, so a product's models are the
provider and model that actually answered, and a cached or failed run is not usage.

Changes

ddiff --git a/server/exchange/catalog.js b/server/exchange/catalog.js
iindex 4912c0e..878fe09 100644
---- a/server/exchange/catalog.js
++++ b/server/exchange/catalog.js
@@@ -4,9 +4,9 @@
* serves it, with the price, the measured speed and uptime, and the provider's data policy.
*
* It is a read over what the router already knows — the registry's providers and models, the rate
-- * cards (server/providers/rate-cards.js) and the daily stats (provider_stats_daily) — never a second
-- * list of models or prices. Nothing here is per person: a person's own key is not a registry
-- * provider, and CUSTOMER_BYOK capacity is never sold as a managed offer.
++ * cards (server/providers/rate-cards.js) and the runs that actually happened (server/exchange/observed.js)
++ * — never a second list of models or prices. Nothing here is per person: a person's own key is not a
++ * registry provider, and CUSTOMER_BYOK capacity is never sold as a managed offer.
*
* Facts stay visibly unknown when nobody recorded them: a prepaid provider has no per-token price,
* a model with no traffic has no latency or uptime, and a provider without metadata.data_policy
@@@ -14,9 +14,9 @@
*/
const rates = require('../providers/rate-cards');
const upstream = require('../providers/upstream');
++const observed = require('./observed');
const { allowsOffer } = require('../free-tier');
--const DAY_MS = 86400000;
const FAMILY_BY_PREFIX = [
['deepseek', 'DeepSeek'], ['qwen', 'Qwen'], ['qwq', 'Qwen'], ['kimi', 'Kimi'], ['moonshot', 'Kimi'],
['glm', 'GLM'], ['zhipu', 'GLM'], ['minimax', 'MiniMax'], ['llama', 'Llama'], ['mistral', 'Mistral'],
@@@ -107,31 +107,7 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
// the free allowance and the anonymous Try it may actually use. config.freeUse is fixed at boot, like the
// provider keys and upstreams the rest of this read shows.
const freeUse = config && config.freeUse ? config.freeUse : undefined;
-- const getStats = db.prepare(`SELECT provider, model,
-- SUM(requests)::bigint AS requests, SUM(ok)::bigint AS ok, SUM(errors)::bigint AS errors,
-- CASE WHEN SUM(requests) > 0 THEN SUM(COALESCE(latency_p50_ms, 0) * requests) / SUM(requests) END AS p50,
-- CASE WHEN SUM(requests) > 0 THEN SUM(COALESCE(latency_p95_ms, 0) * requests) / SUM(requests) END AS p95
-- FROM provider_stats_daily WHERE day >= ? GROUP BY provider, model`);
-- const getToday = db.prepare(`SELECT provider, model, SUM(requests)::bigint AS requests
-- FROM provider_stats_daily WHERE day = ? GROUP BY provider, model`);
--
-- async function statsFor(days, today) {
-- const from = dayOf(clock.now() - Math.max(1, days) * DAY_MS);
-- const map = new Map();
-- for (const r of await getStats.all(from)) {
-- map.set(`${r.provider}\u0000${r.model || ''}`, {
-- requests: Number(r.requests) || 0, ok: Number(r.ok) || 0, errors: Number(r.errors) || 0,
-- p50: num(r.p50), p95: num(r.p95),
-- });
-- }
-- for (const r of await getToday.all(today)) {
-- const key = `${r.provider}\u0000${r.model || ''}`;
-- const hit = map.get(key);
-- if (hit) hit.today = Number(r.requests) || 0;
-- else map.set(key, { requests: 0, ok: 0, errors: 0, p50: null, p95: null, today: Number(r.requests) || 0 });
-- }
-- return map;
-- }
++ const seen = observed.createObserved({ db, clock });
/**
* Every live offer, grouped by the model it serves.
@@@ -140,14 +116,13 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
*/
async function models({ days = 7, kind = null } = {}) {
const now = clock.now();
-- const today = dayOf(now);
-- const stats = await statsFor(days, today);
const providers = await registry.listProviders();
const byKey = new Map(providers.map((p) => [p.key, p]));
const upstreamByKey = new Map();
for (const p of providers) upstreamByKey.set(p.key, upstream.upstreamOf(p));
const groups = new Map();
++ const drafts = [];
for (const row of await registry.listModels()) {
const provider = byKey.get(row.provider_key);
if (!provider || provider.kind === 'stub' || (provider.metadata && provider.metadata.synthetic)) continue;
@@@ -169,8 +144,6 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
if (!slug) continue;
const cards = await pool.rateCardsFor(provider.key, row.model_key);
const price = cards ? rates.pricesOf(cards) : null;
-- const st = stats.get(`${provider.key}\u0000${row.model_key}`) || { requests: 0, ok: 0, errors: 0, p50: null, p95: null, today: 0 };
-- const uptime = st.requests > 0 ? st.ok / st.requests : null;
const offer = {
provider: up.key,
provider_name: up.name,
@@@ -189,10 +162,6 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
// Where the number came from and when someone checked it; null when nobody recorded it.
price_source: cards && cards.out.source && !String(cards.out.source).startsWith('urn:openvibe:ai:unverified:') ? cards.out.source : null,
price_checked_at: cards && cards.out.verified_at && cards.out.verified_at !== rates.UNKNOWN_DATE ? cards.out.verified_at : null,
-- speed: { p50_ms: st.p50, p95_ms: st.p95 },
-- uptime,
-- requests_7d: st.requests,
-- requests_today: st.today || 0,
data_policy: policyOf(provider),
updated_at: row.updated_at || provider.updated_at || null,
};
@@@ -216,11 +185,17 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
const g = groups.get(slug);
g.offers.push(offer);
g.offers_listed += 1;
++ drafts.push({ providerKey: provider.key, modelKey: row.model_key, offer });
if (row.type === 'vision') g.capabilities.vision = true;
if (!g.context && row.context_window) g.context = num(row.context_window);
if (!g.description && row.metadata && typeof row.metadata.description === 'string') g.description = row.metadata.description;
}
++ // What those offers actually did: real runs (cached answers counted apart), TTFT where recorded,
++ // else the answering attempt's total latency.
++ const measured = await seen.read({ days });
++ observed.fold(measured, drafts);
++
let models = [...groups.values()];
if (kind) models = models.filter((m) => m.kind === kind);
for (const m of models) {
@@@ -231,6 +206,8 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
});
m.requests_7d = m.offers.reduce((n, o) => n + o.requests_7d, 0);
m.requests_today = m.offers.reduce((n, o) => n + o.requests_today, 0);
++ m.cached_7d = m.offers.reduce((n, o) => n + o.cached_7d, 0);
++ m.cached_today = m.offers.reduce((n, o) => n + o.cached_today, 0);
m.community = m.offers.some((o) => o.trust === 'community');
m.priced = m.offers.some((o) => o.price);
delete m.offers_listed;
ddiff --git a/server/exchange/observed.js b/server/exchange/observed.js
nnew file mode 100644
iindex 0000000..07dd5aa
---- /dev/null
++++ b/server/exchange/observed.js
@@@ -0,0 +1,156 @@
++'use strict';
++/**
++ * What the Exchange catalog measures: uptime, p50/p95 and request counts from the runs that actually
++ * happened, never from a rollup keyed by a different model id.
++ *
++ * A registry model row names the model ("gpt-5-nano"); a run records the id its upstream resolved
++ * ("gpt-5-nano-2025-08-07"). A run belongs to the longest registry key it extends by a revision —
++ * a date, a version or a tag. `gpt-5` therefore never collects `gpt-5-mini-2025-08-07`; the mini row
++ * does, by exact name. Cached answers carry no provider of their own, so they follow the run they
++ * reused (`cached_from`). A failed run answered nothing, so the provider and model it last tried are
++ * read from its request rows.
++ *
++ * The latency of an answer is its time to the first token when the receipt recorded one (a streamed
++ * answer), else the total provider latency of the attempt that answered. No samples means null, not 0.
++ * Nothing here is per person, and neither a prompt nor an answer is read.
++ */
++const DAY_MS = 86400000;
++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
++
++// What an upstream appends to a registry name: -2025-08-07, -v3-0324, -4-5-20251001, :free, @rev.
++const REVISION = /^(?:-\d{4}-\d{2}-\d{2}(?:[-.][A-Za-z0-9]+)*|-[vV]\d+(?:[.-][A-Za-z0-9]+)*|-\d+(?:[.-]\d+)*(?:-[A-Za-z0-9]+)*|:[A-Za-z0-9._-]+|@[A-Za-z0-9._-]+)$/;
++
++const leafOf = (key) => {
++ const s = String(key || '');
++ return s.includes('/') ? s.slice(s.lastIndexOf('/') + 1) : s;
++};
++
++/** Does one registry model key name the model a run records? Exact, or a revision of it. */
++function modelMatches(rowModel, runModel) {
++ if (!rowModel || !runModel) return false;
++ const row = String(rowModel);
++ const run = String(runModel);
++ if (row === run) return true;
++ const rowLeaf = leafOf(row);
++ const runLeaf = leafOf(run);
++ if (rowLeaf === runLeaf) return true;
++ for (const [a, b] of [[rowLeaf, runLeaf], [row, run]]) {
++ if (b.startsWith(a) && REVISION.test(b.slice(a.length))) return true;
++ }
++ return false;
++}
++
++/** Linear-interpolated percentile (the same reading as PostgreSQL's percentile_cont), whole ms. */
++function percentile(values, p) {
++ if (!values || !values.length) return null;
++ const sorted = [...values].sort((a, b) => a - b);
++ const at = (sorted.length - 1) * p;
++ const lo = Math.floor(at);
++ const hi = Math.ceil(at);
++ const v = lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo);
++ return Math.round(v);
++}
++
++function createObserved({ db, clock = { now: () => Date.now() } }) {
++ // Counted per day, status and effective offer. A cached run's provider is the run it reused; a
++ // failed run's is its last attempt (only a provider call can fail an offer — a refused request
++ // never reached one).
++ const runRows = db.prepare(`SELECT to_char(r.created_at::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day, r.status,
++ COALESCE(r.provider_key, src.provider_key, a.provider_key) AS provider_key,
++ COALESCE(r.model_key, src.model_key, a.model_key) AS model_key,
++ COUNT(*)::bigint AS n
++ FROM runs r
++ LEFT JOIN runs src ON src.id = r.cached_from
++ LEFT JOIN LATERAL (
++ SELECT q.provider_key, q.model_key FROM requests q
++ WHERE q.run_id = r.id AND q.status <> 'skipped'
++ ORDER BY q.seq DESC LIMIT 1
++ ) a ON r.status = 'failed'
++ WHERE r.status IN ('succeeded', 'failed', 'cached')
++ AND r.created_at::timestamptz >= ?::timestamptz AND r.created_at::timestamptz <= ?::timestamptz
++ GROUP BY 1, 2, 3, 4`);
++ const latencyRows = db.prepare(`SELECT COALESCE(r.provider_key, q.provider_key) AS provider_key,
++ COALESCE(r.model_key, q.model_key) AS model_key,
++ q.latency_ms, rc.body->>'ttft_ms' AS ttft_ms
++ FROM runs r
++ JOIN requests q ON q.run_id = r.id AND q.status = 'ok'
++ LEFT JOIN ai_receipts rc ON rc.run_id = r.id
++ WHERE r.status = 'succeeded' AND q.latency_ms IS NOT NULL
++ AND r.created_at::timestamptz >= ?::timestamptz AND r.created_at::timestamptz <= ?::timestamptz`);
++
++ /** The real runs and answering attempts of the window, and the UTC day that is "today". */
++ async function read({ days = 7 } = {}) {
++ const now = clock.now();
++ const span = Math.max(1, Number(days) || 7);
++ const from = new Date(now - span * DAY_MS).toISOString();
++ const to = new Date(now).toISOString();
++ const [runs, latencies] = await Promise.all([runRows.all(from, to), latencyRows.all(from, to)]);
++ return { from, to, today: dayOf(now), runs, latencies };
++ }
++
++ return { read };
++}
++
++const EMPTY = () => ({ succeeded: 0, failed: 0, cached: 0, succeeded_today: 0, failed_today: 0, cached_today: 0, p50: null, p95: null });
++
++/**
++ * Fold the measured rows into the catalog's offers. `drafts` are `{ providerKey, modelKey, offer }`,
++ * where providerKey is the router's record (`shared`) and modelKey is the registry's name; the offer's
++ * `provider` stays the real upstream. A row goes to the longest matching model key of its provider.
++ */
++function fold(observed, drafts) {
++ const byProvider = new Map();
++ for (const d of drafts) {
++ if (!byProvider.has(d.providerKey)) byProvider.set(d.providerKey, []);
++ byProvider.get(d.providerKey).push(d);
++ }
++ const pick = (providerKey, modelKey) => {
++ let best = null;
++ for (const d of byProvider.get(providerKey) || []) {
++ if (!modelMatches(d.modelKey, modelKey)) continue;
++ if (!best || d.modelKey.length > best.modelKey.length) best = d;
++ }
++ return best;
++ };
++ const stats = new Map(drafts.map((d) => [d, EMPTY()]));
++ for (const row of observed.runs) {
++ const d = pick(row.provider_key, row.model_key);
++ if (!d) continue;
++ const s = stats.get(d);
++ const n = Number(row.n) || 0;
++ if (row.status === 'succeeded') s.succeeded += n;
++ else if (row.status === 'failed') s.failed += n;
++ else if (row.status === 'cached') s.cached += n;
++ if (row.day === observed.today) {
++ if (row.status === 'succeeded') s.succeeded_today += n;
++ else if (row.status === 'failed') s.failed_today += n;
++ else if (row.status === 'cached') s.cached_today += n;
++ }
++ }
++ const samples = new Map(drafts.map((d) => [d, []]));
++ for (const row of observed.latencies) {
++ const d = pick(row.provider_key, row.model_key);
++ if (!d) continue;
++ const total = row.latency_ms == null ? null : Number(row.latency_ms);
++ const ttft = row.ttft_ms == null || row.ttft_ms === '' ? null : Number(row.ttft_ms);
++ const value = Number.isFinite(ttft) && ttft >= 0 ? ttft : (Number.isFinite(total) && total >= 0 ? total : null);
++ if (value != null) samples.get(d).push(value);
++ }
++ for (const [d, values] of samples) {
++ const s = stats.get(d);
++ s.p50 = percentile(values, 0.5);
++ s.p95 = percentile(values, 0.95);
++ }
++ for (const [d, s] of stats) {
++ const real = s.succeeded + s.failed;
++ d.offer.speed = { p50_ms: s.p50, p95_ms: s.p95 };
++ d.offer.uptime = real > 0 ? s.succeeded / real : null;
++ d.offer.requests_7d = real;
++ d.offer.requests_today = s.succeeded_today + s.failed_today;
++ d.offer.cached_7d = s.cached;
++ d.offer.cached_today = s.cached_today;
++ }
++ return stats;
++}
++
++module.exports = { createObserved, fold, modelMatches, percentile };
ddiff --git a/server/exchange/usage.js b/server/exchange/usage.js
iindex 7795a69..6e66fb8 100644
---- a/server/exchange/usage.js
++++ b/server/exchange/usage.js
@@@ -2,15 +2,21 @@
/**
* Aggregate usage for OpenVibe.Exchange: requests, tokens and spend by day, by model and by product.
*
-- * It reads usage_daily (the daily rollup every finished run writes) and never the runs or requests
-- * tables: no prompt, no answer, no person's id. Attribution is left out on purpose — a product is
-- * `service:<name>` (usage_daily.requester), and rows that are not a service are counted in the totals
-- * but never named.
++ * It reads the finished runs themselves — the request its requester row names, and the provider and
++ * model that actually answered it — never a second roll-up that could carry the route's model instead
++ * of the answer's. Cached answers are not usage: no provider was called and nothing was spent, so they
++ * are not counted here (the catalog counts them apart). Only services are named as products; a run
++ * that is not a service is counted in the totals but never attributed. No prompt, no answer, and no
++ * person's id is selected.
*/
const DAY_MS = 86400000;
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
--const NUM_COLUMNS = 'SUM(requests)::bigint AS requests, SUM(tokens_in)::bigint AS tokens_in, SUM(tokens_out)::bigint AS tokens_out, SUM(cost_usd) AS cost_usd';
++const NUM_COLUMNS = 'COUNT(*)::bigint AS requests, SUM(tokens_in)::bigint AS tokens_in, SUM(tokens_out)::bigint AS tokens_out, SUM(cost_usd)::double precision AS cost_usd';
++const DAY = "to_char(created_at::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM-DD')";
++// A real answer: the provider was called and answered. A cached run called nobody; a failed one
++// answered nothing.
++const REAL = "status = 'succeeded' AND synthetic = 0";
const shape = (r) => ({
requests: Number(r.requests) || 0,
@@@ -20,15 +26,21 @@ const shape = (r) => ({
});
function createUsage({ db, clock = { now: () => Date.now() } }) {
-- const byDay = db.prepare(`SELECT day, ${NUM_COLUMNS} FROM usage_daily WHERE day >= ? GROUP BY day ORDER BY day`);
-- const byModel = db.prepare(`SELECT provider_key, model_key, ${NUM_COLUMNS} FROM usage_daily WHERE day >= ?
-- GROUP BY provider_key, model_key ORDER BY SUM(requests) DESC LIMIT 100`);
-- const byProduct = db.prepare(`SELECT requester, ${NUM_COLUMNS} FROM usage_daily WHERE day >= ? AND requester LIKE 'service:%'
-- GROUP BY requester ORDER BY SUM(requests) DESC LIMIT 100`);
-- const productModels = db.prepare(`SELECT requester, provider_key, model_key, ${NUM_COLUMNS} FROM usage_daily WHERE day >= ? AND requester LIKE 'service:%'
-- GROUP BY requester, provider_key, model_key ORDER BY SUM(requests) DESC`);
-- const today = db.prepare(`SELECT ${NUM_COLUMNS} FROM usage_daily WHERE day = ?`);
-- const totals = db.prepare(`SELECT ${NUM_COLUMNS} FROM usage_daily WHERE day >= ?`);
++ const byDay = db.prepare(`SELECT ${DAY} AS day, ${NUM_COLUMNS} FROM runs
++ WHERE ${REAL} AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz
++ GROUP BY 1 ORDER BY 1`);
++ const byModel = db.prepare(`SELECT provider_key, model_key, ${NUM_COLUMNS} FROM runs
++ WHERE ${REAL} AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz
++ GROUP BY provider_key, model_key ORDER BY COUNT(*) DESC LIMIT 100`);
++ const byProduct = db.prepare(`SELECT requester_id, ${NUM_COLUMNS} FROM runs
++ WHERE ${REAL} AND requester_type = 'service' AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz
++ GROUP BY requester_id ORDER BY COUNT(*) DESC LIMIT 100`);
++ const productModels = db.prepare(`SELECT requester_id, provider_key, model_key, ${NUM_COLUMNS} FROM runs
++ WHERE ${REAL} AND requester_type = 'service' AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz
++ GROUP BY requester_id, provider_key, model_key ORDER BY COUNT(*) DESC`);
++ const today = db.prepare(`SELECT ${NUM_COLUMNS} FROM runs WHERE ${REAL} AND ${DAY} = ?`);
++ const totals = db.prepare(`SELECT ${NUM_COLUMNS} FROM runs
++ WHERE ${REAL} AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz`);
/** days: the window (1–366). Costs are the network's own provider spend, in USD. */
async function read({ days = 30 } = {}) {
@@@ -36,16 +48,19 @@ function createUsage({ db, clock = { now: () => Date.now() } }) {
const span = Math.max(1, Math.min(366, Number(days) || 30));
const from = dayOf(now - span * DAY_MS);
const todayKey = dayOf(now);
++ const fromAt = `${from}T00:00:00.000Z`;
++ const toAt = new Date(now).toISOString();
const [dayRows, modelRows, productRows, productModelRows, todayRow, totalRow] = await Promise.all([
-- byDay.all(from), byModel.all(from), byProduct.all(from), productModels.all(from), today.all(todayKey), totals.all(from),
++ byDay.all(fromAt, toAt), byModel.all(fromAt, toAt), byProduct.all(fromAt, toAt),
++ productModels.all(fromAt, toAt), today.all(todayKey), totals.all(fromAt, toAt),
]);
const products = new Map();
for (const r of productRows) {
-- const product = String(r.requester).slice('service:'.length);
++ const product = String(r.requester_id);
products.set(product, { product, ...shape(r), models: [] });
}
for (const r of productModelRows) {
-- const product = products.get(String(r.requester).slice('service:'.length));
++ const product = products.get(String(r.requester_id));
if (!product) continue;
product.models.push({ provider: r.provider_key, model: r.model_key || null, ...shape(r) });
}
ddiff --git a/server/openapi.js b/server/openapi.js
iindex a27bfe3..bd4c6c5 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -305,8 +305,10 @@ const exchangeOffer = {
price_source: nullable('string', { description: 'The price page the card was read from, when recorded' }),
price_checked_at: nullable('string', { description: 'The date someone checked the price, when recorded' }),
speed: { type: 'object', properties: { p50_ms: nullable('number'), p95_ms: nullable('number') } },
-- uptime: nullable('number', { minimum: 0, maximum: 1 }),
++ uptime: nullable('number', { minimum: 0, maximum: 1, description: 'succeeded / (succeeded + failed) real runs; null when no run reached this offer' }),
requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }),
++ cached_7d: integer({ minimum: 0, description: 'Cached answers (no provider call) in the window, counted apart from requests_7d' }),
++ cached_today: integer({ minimum: 0 }),
data_policy: nullable('object', { properties: { trains: nullable('boolean'), retention_days: nullable('integer'), region: nullable('string'), anonymized: nullable('boolean', { description: 'The provider says the request is anonymised before the model sees it' }) } }),
updated_at: nullable('string'),
},
@@@ -318,13 +320,14 @@ const exchangeModel = {
kind: str({ enum: ['chat', 'embedding', 'stt', 'image'] }), type: str(), context: nullable('integer'), max_output: nullable('integer'),
capabilities: { type: 'object', properties: { tools: { type: 'boolean' }, json: { type: 'boolean' }, vision: { type: 'boolean' }, stream: { type: 'boolean' } } },
modalities: arrayOf(str()), description: nullable('string'),
-- requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }), community: { type: 'boolean' },
++ requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }),
++ cached_7d: integer({ minimum: 0 }), cached_today: integer({ minimum: 0 }), community: { type: 'boolean' },
offers: arrayOf(exchangeOffer),
},
};
route('get', '/exchange/catalog', {
operationId: 'exchangeCatalog', summary: 'Every model and every offer that serves it', tags: ['exchange'], security: [],
-- description: 'Public. The live offers: price in, out and cached per million tokens, measured p50/p95, uptime, requests, and the provider data policy. A prepaid offer has no price (free: true); free_tier says whether the free allowance and the anonymous Try it may use an offer at all (a named AI_FREE_PROVIDERS source under the output price ceiling). The window is 7 days. Models carry a kind (chat, embedding, stt, image); chat lists must use kind "chat" and never mix embeddings or speech-to-text.',
++ description: 'Public. The live offers: price in, out and cached per million tokens, measured p50/p95, uptime, requests, and the provider data policy. Speed and uptime are read from the last seven days of real runs (time to the first token where a receipt recorded one, else the answering attempt\'s total); cached answers are counted apart and excluded from speed, uptime and requests. Uptime is succeeded / (succeeded + failed); null when no data, never 0. A prepaid offer has no price (free: true); free_tier says whether the free allowance and the anonymous Try it may use an offer at all (a named AI_FREE_PROVIDERS source under the output price ceiling). Models carry a kind (chat, embedding, stt, image); chat lists must use kind "chat" and never mix embeddings or speech-to-text.',
parameters: [query('kind', str({ enum: ['chat', 'embedding', 'stt', 'image'] }), 'Only models of this kind')],
responses: { 200: json('The models, grouped by model, with their offers', { type: 'object', required: ['models'], properties: {
generated_at: str({ format: 'date-time' }), window_days: integer(), count: integer(),
@@@ -348,7 +351,7 @@ route('get', '/exchange/models/{org}/{model}', {
});
route('get', '/exchange/usage', {
operationId: 'exchangeUsage', summary: 'Requests, tokens and spend by day, model and product', tags: ['exchange'], security: [],
-- description: 'Public and aggregate only: never a prompt, an answer or a person. A product is the service that ran the model (usage_daily.requester).',
++ description: 'Public and aggregate only: never a prompt, an answer or a person. Read from the finished runs: a product is the service whose run answered (runs.requester_id), and each product\'s models are the provider and model that actually answered, not the route it asked for. Cached answers called no provider and are not counted; only aggregate rows are returned.',
parameters: [query('days', integer({ minimum: 1, maximum: 366 }), 'Default 30')],
responses: { 200: json('The aggregates', { type: 'object', properties: {
generated_at: str({ format: 'date-time' }), days: integer(), from: str(), to: str(),
ddiff --git a/test/exchange-read.test.js b/test/exchange-read.test.js
iindex dad90ed..1aaf82b 100644
---- a/test/exchange-read.test.js
++++ b/test/exchange-read.test.js
@@@ -1,15 +1,34 @@
'use strict';
--// GET /api/v1/exchange/{catalog,models,usage}: the public read OpenVibe.Exchange renders from. Prices,
--// measured speed and uptime come from the rate cards and provider_stats_daily; usage is aggregate only
--// (never a prompt, an answer, an attribution or a person); capacity the network may not resell is not
--// an offer.
++// GET /api/v1/exchange/{catalog,models,usage}: the public read OpenVibe.Exchange renders from. Prices come
++// from the rate cards, speed/uptime/requests from the real runs (cached answers counted apart), and usage is
++// aggregate only (never a prompt, an answer, an attribution or a person); capacity the network may not
++// resell is not an offer.
const assert = require('assert');
const { boot, request, suite } = require('./helpers');
const t = suite('exchange-read');
let h;
--const day = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);
++const at = (n) => new Date(Date.now() - n * 86400000).toISOString();
++
++let seq = 0;
++const uid = (p) => `${p}_${Date.now()}_${seq++}`;
++async function insertRun({ id = uid('run'), status = 'succeeded', provider = null, model = null, type = 'service', requester = 'live',
++ created = at(0), cachedFrom = null, tokensIn = 0, tokensOut = 0, cost = 0, synthetic = 0 }) {
++ await h.db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, status, requester_type, requester_id, input, input_hash,
++ provider_key, model_key, cached_from, tokens_in, tokens_out, cost_usd, synthetic, created_at, started_at, finished_at)
++ VALUES (?, 'ai.complete', 1, ?, ?, ?, '{}', 'input-hash', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(id, status, type, requester, provider, model, cachedFrom, tokensIn, tokensOut, cost, synthetic, created, created, created);
++ return id;
++}
++async function insertRequest({ runId, provider, model, status = 'ok', latency = 100, created = at(0), seqNo = 0 }) {
++ await h.db.prepare(`INSERT INTO requests (run_id, seq, operation, provider_key, model_key, status, latency_ms, created_at)
++ VALUES (?, ?, 'chat', ?, ?, ?, ?, ?)`).run(runId, seqNo, provider, model, status, latency, created);
++}
++async function insertReceipt({ runId, body }) {
++ await h.db.prepare(`INSERT INTO ai_receipts (id, run_id, body) VALUES (?, ?, ?::jsonb)`)
++ .run(`rcpt_${uid('r')}`, runId, JSON.stringify(body));
++}
t.test('boot', async () => {
// The catalog lists only offers whose key is configured: give the test's providers keys and the shared
@@@ -17,6 +36,17 @@ t.test('boot', async () => {
h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER: 'openai', AI_API_KEY: 'test-openai-key', DEEPSEEK_KEY: 'test-deepseek-key', DEEPINFRA_KEY: 'test-deepinfra-key' } });
});
++t.test('a run id is its registry name plus a revision, never another model', async () => {
++ const { modelMatches, percentile } = require('../server/exchange/observed');
++ assert.ok(modelMatches('gpt-5-nano', 'gpt-5-nano-2025-08-07'));
++ assert.ok(modelMatches('gpt-5', 'gpt-5-2025-08-07'));
++ assert.ok(modelMatches('claude-sonnet', 'claude-sonnet-4-5-20251001'));
++ assert.ok(!modelMatches('gpt-5', 'gpt-5-mini-2025-08-07'), 'mini is its own model');
++ assert.ok(!modelMatches('deepseek-chat', 'deepseek-reasoner'));
++ assert.strictEqual(percentile([10, 20, 30, 40], 0.5), 25);
++ assert.strictEqual(percentile([], 0.5), null);
++});
++
t.test('catalog groups the same model across providers, with prices, speed, uptime and data policy', async () => {
await h.registry.upsertProvider({
key: 'deepseek', display_name: 'DeepSeek', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:DEEPSEEK_KEY',
@@@ -31,8 +61,22 @@ t.test('catalog groups the same model across providers, with prices, speed, upti
});
await h.registry.upsertModel({ provider_key: 'deepinfra', model_key: 'deepseek-chat', type: 'chat', context_window: 131072,
cost: { in_per_mtok: 0.20, out_per_mtok: 0.30 } });
-- await h.db.prepare(`INSERT INTO provider_stats_daily (day, provider, model, requests, ok, errors, latency_p50_ms, latency_p95_ms)
-- VALUES (?, 'deepseek', 'deepseek-chat', 100, 99, 1, 400, 900), (?, 'deepinfra', 'deepseek-chat', 10, 8, 2, 700, 2000)`).run(day(1), day(1));
++ // Real runs name the id the upstream resolved ("deepseek-chat-2026-01-01"); the registry row names the
++ // model ("deepseek-chat"). 8 answers, 2 failures attributed through their last attempt, 3 cache hits
++ // whose provider is the run they reused. One answer recorded a TTFT (streamed); the rest have only the
++ // attempt's total latency.
++ const answered = [];
++ for (let i = 0; i < 8; i++) {
++ const id = await insertRun({ provider: 'deepseek', model: 'deepseek-chat-2026-01-01' });
++ answered.push(id);
++ await insertRequest({ runId: id, provider: 'deepseek', model: 'deepseek-chat-2026-01-01', latency: (i + 1) * 100 });
++ }
++ await insertReceipt({ runId: answered[3], body: { latency_ms: 400, ttft_ms: 50 } });
++ for (let i = 0; i < 2; i++) {
++ const id = await insertRun({ status: 'failed' }); // a failed run keeps no provider of its own
++ await insertRequest({ runId: id, provider: 'deepseek', model: 'deepseek-chat-2026-01-01', status: 'error', latency: 900 });
++ }
++ for (let i = 0; i < 3; i++) await insertRun({ status: 'cached', model: 'deepseek-chat-2026-01-01', cachedFrom: answered[0] });
const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');
assert.strictEqual(r.status, 200, r.text);
@@@ -45,11 +89,19 @@ t.test('catalog groups the same model across providers, with prices, speed, upti
const [cheap, dear] = m.offers;
assert.strictEqual(cheap.provider, 'deepinfra', 'sorted by price');
assert.deepStrictEqual(cheap.price, { in: 0.2, out: 0.3, cached: 0.02, per: '1M tokens', currency: 'USD' });
-- assert.strictEqual(dear.uptime, 0.99);
-- assert.strictEqual(dear.requests_7d, 100);
++ // The deepseek offer: p50/p95 over [100,200,300,400,500,600,700,800] with 400 replaced by the recorded TTFT 50.
++ assert.strictEqual(dear.uptime, 0.8, '8 succeeded / (8 + 2 failed), from runs');
++ assert.strictEqual(dear.requests_7d, 10, 'real runs only; the 3 cached answers are counted apart');
++ assert.strictEqual(dear.cached_7d, 3);
++ assert.deepStrictEqual(dear.speed, { p50_ms: 400, p95_ms: 765 }, 'TTFT where recorded, else total');
++ assert.strictEqual(dear.requests_today, 10);
++ assert.strictEqual(cheap.uptime, null, 'no data is unknown, not 0');
++ assert.strictEqual(cheap.requests_7d, 0);
++ assert.deepStrictEqual(cheap.speed, { p50_ms: null, p95_ms: null });
assert.deepStrictEqual(dear.data_policy, { trains: false, retention_days: 0, region: 'cn' });
assert.strictEqual(cheap.data_policy, null, 'unknown stays unknown');
-- assert.strictEqual(m.requests_7d, 110);
++ assert.strictEqual(m.requests_7d, 10);
++ assert.strictEqual(m.cached_7d, 3);
});
t.test('a person’s own-key capacity and a stub are never offers', async () => {
@@@ -70,24 +122,33 @@ t.test('one model by slug, and a miss is a problem document', async () => {
assert.strictEqual(miss.body.code, 'exchange.model_not_found');
});
--t.test('usage: day, model and product aggregates, and never an attribution', async () => {
-- const insert = h.db.prepare(`INSERT INTO usage_daily (day, requester, attribution, workflow_key, provider_key, model_key, requests, tokens_in, tokens_out, tokens_cached, cost_usd)
-- VALUES (?, ?, ?, 'ai.complete', ?, ?, ?, ?, ?, 0, ?)`);
-- await insert.run(day(0), 'service:live', 'live:user:usr_SECRETPERSON', 'deepseek', 'deepseek-chat', 5, 500, 100, 0.001);
-- await insert.run(day(0), 'service:wiki', '', 'deepseek', 'deepseek-chat', 3, 300, 60, 0.0006);
-- await insert.run(day(1), 'service:live', 'live:user:usr_SECRETPERSON', 'deepseek', 'deepseek-chat', 2, 200, 40, 0.0004);
-- await insert.run(day(1), 'app:app_ABC', '', 'deepseek', 'deepseek-chat', 1, 100, 20, 0.0002);
++t.test('usage: day, model and product aggregates from runs, and never an attribution', async () => {
++ // Live's real traffic is gpt-5-nano even though its route names a DeepSeek model: the run keeps the
++ // model that answered, and the product table must show that one.
++ for (let i = 0; i < 5; i++) await insertRun({ requester: 'live', provider: 'shared', model: 'gpt-5-nano-2025-08-07', tokensIn: 100, tokensOut: 20, cost: 0.0002 });
++ for (let i = 0; i < 2; i++) await insertRun({ requester: 'live', provider: 'deepseek', model: 'deepseek-chat', created: at(1), tokensIn: 100, tokensOut: 20, cost: 0.0002 });
++ for (let i = 0; i < 3; i++) await insertRun({ requester: 'wiki', provider: 'deepseek', model: 'deepseek-chat', tokensIn: 100, tokensOut: 20, cost: 0.0002 });
++ await insertRun({ type: 'app', requester: 'app_ABC', provider: 'deepseek', model: 'deepseek-chat', tokensIn: 100, tokensOut: 20, cost: 0.0002 });
++ // Not usage: a cache hit called no provider, and a failed run answered nothing.
++ await insertRun({ status: 'cached', requester: 'live', model: 'gpt-5-nano-2025-08-07', cachedFrom: 'run_source', tokensIn: 9999, tokensOut: 9999, cost: 9 });
++ await insertRun({ status: 'failed', requester: 'live', tokensIn: 9999, tokensOut: 9999, cost: 9 });
const r = await request(h.base, 'GET', '/api/v1/exchange/usage?days=7');
assert.strictEqual(r.status, 200, r.text);
assert.match(r.headers.get('cache-control'), /^public, max-age=60/);
assert.strictEqual(r.body.totals.requests, 11);
-- assert.strictEqual(r.body.today.requests, 8);
++ assert.strictEqual(r.body.totals.tokens_in, 1100);
++ assert.strictEqual(r.body.totals.tokens_out, 220);
++ assert.strictEqual(r.body.today.requests, 9, 'the two live runs from yesterday are not today');
assert.strictEqual(r.body.by_day.length, 2);
const live = r.body.by_product.find((p) => p.product === 'live');
assert.strictEqual(live.requests, 7);
-- assert.strictEqual(live.models[0].model, 'deepseek-chat');
++ assert.strictEqual(live.models.length, 2);
++ assert.strictEqual(live.models[0].model, 'gpt-5-nano-2025-08-07', 'the model that actually answered');
++ assert.strictEqual(live.models[0].requests, 5);
assert.ok(!r.body.by_product.some((p) => p.product.includes('app_')), 'only services are products');
-- assert.doesNotMatch(r.text, /SECRETPERSON|usr_/);
-- assert.ok(r.body.by_model.some((m) => m.model === 'deepseek-chat' && m.requests === 11));
++ assert.ok(!r.text.includes('SECRETPERSON') && !r.text.includes('usr_'), 'no person in the aggregate');
++ assert.ok(r.body.by_model.some((m) => m.model === 'gpt-5-nano-2025-08-07' && m.requests === 5));
++ assert.ok(r.body.by_model.some((m) => m.model === 'deepseek-chat' && m.requests === 6));
++ assert.doesNotMatch(r.text, /9999|9\.000000/, 'cached and failed runs are not counted');
});