openvibers/openvibe.ai Commits

Exchange read API: the public catalog and aggregate usage (#308)

OP OpenVibers committed 7 days ago · 479af1e7d2 · 6 files, +540 −1

* Exchange read API: the public catalog and aggregate usage

GET /api/v1/exchange/catalog, /exchange/models/{slug} and /exchange/usage:
the models grouped with every live offer (price in, out and cached per million
from the rate cards; p50/p95 and uptime from provider_stats_daily; the provider's
metadata.data_policy when recorded; the exact artifact id a request may pin), and
aggregate requests, tokens and spend by day, model and product.

Capacity the network may not resell (CUSTOMER_BYOK, RESTRICTED) is never an
offer, prepaid capacity carries no price, and usage never names a person: it
reads usage_daily's service requester only. Documented in /openapi.json; the
catalog is memoized for 30 s and usage for 60 s in-process.

* Exchange catalog: carry the provider's anonymized data-policy flag

A provider that says it anonymises the request before the model sees it is a fact the site's
privacy label needs; it stays null when nobody recorded it, like the rest of data_policy.

* Merge remote-tracking branch 'origin/main' into ai/exchange-read-api

Changes

ddiff --git a/server/api/exchange.js b/server/api/exchange.js
nnew file mode 100644
iindex 0000000..eea2791
---- /dev/null
++++ b/server/api/exchange.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * OpenVibe.Exchange's read API: the public catalog, one model, and aggregate usage.
++ *
++ * GET /api/v1/exchange/catalog every model, its offers, prices, speed and uptime
++ * GET /api/v1/exchange/models/:slug one model ("deepseek/deepseek-chat", or org + model)
++ * GET /api/v1/exchange/usage?days=30 requests, tokens and spend by day, model and product
++ *
++ * No token: everything here is public on openvibe.exchange (the site, and anyone comparing prices).
++ * Nothing is per person, and no provider secret, base URL or metadata leaves the catalog shape in
++ * server/exchange/catalog.js. The answers are cached in this process for half a minute, so a busy
++ * Exchange does not scan the stats table per page view.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { createCatalog } = require('../exchange/catalog');
++const { createUsage } = require('../exchange/usage');
++
++const CATALOG_MS = 30_000;
++
++function memo(fn, ttlMs, clock) {
++ let hit = { at: 0, value: null };
++ return async () => {
++ if (hit.value && clock.now() - hit.at < ttlMs) return hit.value;
++ const value = await fn();
++ hit = { at: clock.now(), value };
++ return value;
++ };
++}
++
++function exchangeRouter({ db, registry, pool, clock = { now: () => Date.now() } }) {
++ const catalog = createCatalog({ db, registry, pool, clock });
++ const usage = createUsage({ db, clock });
++ const all = memo(() => catalog.models({ days: 7 }), CATALOG_MS, clock);
++ const usageByWindow = new Map();
++ const r = express.Router();
++
++ const publicCache = (seconds) => (req, res, next) => { res.set('Cache-Control', `public, max-age=${seconds}`); next(); };
++
++ r.get('/api/v1/exchange/catalog', publicCache(30), async (req, res, next) => {
++ try { res.json(await all()); } catch (err) { next(err); }
++ });
++
++ r.get('/api/v1/exchange/models/:slug', publicCache(30), async (req, res, next) => {
++ try {
++ const out = await all();
++ const slug = String(req.params.slug || '').toLowerCase();
++ const found = out.models.find((m) => m.slug === slug);
++ if (!found) return http.sendProblem(res, 404, 'exchange.model_not_found', { detail: `no model ${slug}`, ctx: req.ov });
++ return res.json(found);
++ } catch (err) { return next(err); }
++ });
++
++ r.get('/api/v1/exchange/models/:org/:model', publicCache(30), async (req, res, next) => {
++ try {
++ const out = await all();
++ const slug = `${String(req.params.org || '').toLowerCase()}/${String(req.params.model || '').toLowerCase()}`;
++ const found = out.models.find((m) => m.slug === slug);
++ if (!found) return http.sendProblem(res, 404, 'exchange.model_not_found', { detail: `no model ${slug}`, ctx: req.ov });
++ return res.json(found);
++ } catch (err) { return next(err); }
++ });
++
++ r.get('/api/v1/exchange/usage', publicCache(60), async (req, res, next) => {
++ try {
++ const days = Math.max(1, Math.min(366, Number.parseInt(req.query.days, 10) || 30));
++ const key = String(days);
++ const cached = usageByWindow.get(key);
++ if (cached && clock.now() - cached.at < 60_000) return res.json(cached.value);
++ const value = await usage.read({ days });
++ usageByWindow.set(key, { at: clock.now(), value });
++ if (usageByWindow.size > 16) usageByWindow.delete(usageByWindow.keys().next().value);
++ return res.json(value);
++ } catch (err) { return next(err); }
++ });
++
++ return r;
++}
++
++module.exports = { exchangeRouter, CATALOG_MS };
ddiff --git a/server/app.js b/server/app.js
iindex d3adab1..aec2d38 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -108,6 +108,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
app.get('/api/ready', readiness.handler);
// GET /openapi.json: the public OpenAPI 3.1 document (server/openapi.js); /api/v1/openapi.json redirects here.
openapi.mount(app);
++ // OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
++ // purpose — the site and anyone comparing prices reads the same numbers the router ranks on.
++ app.use(require('./api/exchange').exchangeRouter({ db, registry, pool, clock }));
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
ddiff --git a/server/exchange/catalog.js b/server/exchange/catalog.js
nnew file mode 100644
iindex 0000000..d980f91
---- /dev/null
++++ b/server/exchange/catalog.js
@@@ -0,0 +1,236 @@
++'use strict';
++/**
++ * The public catalog for OpenVibe.Exchange: every model the network can run, and every offer that
++ * 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.
++ *
++ * 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
++ * says nothing about training, retention or region.
++ */
++const rates = require('../providers/rate-cards');
++
++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'],
++ ['mixtral', 'Mistral'], ['gemma', 'Gemma'], ['gpt', 'GPT'], ['o1', 'GPT'], ['o3', 'GPT'], ['claude', 'Claude'],
++ ['gemini', 'Gemini'], ['phi', 'Phi'], ['yi', 'Yi'], ['ernie', 'ERNIE'], ['hunyuan', 'Hunyuan'],
++];
++const UNCENSORED = /abliterat|uncensor/i;
++// The org a family belongs to, so the same model served by several providers is one model with several offers.
++const ORG_BY_FAMILY = {
++ DeepSeek: 'deepseek', Qwen: 'qwen', Kimi: 'moonshot', GLM: 'zai', MiniMax: 'minimax',
++ Llama: 'meta-llama', Mistral: 'mistralai', Gemma: 'google', GPT: 'openai', Claude: 'anthropic',
++ Gemini: 'google', Phi: 'microsoft', Yi: '01-ai', ERNIE: 'baidu', Hunyuan: 'tencent',
++};
++
++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
++const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
++
++function familyFrom(text) {
++ const s = String(text || '').toLowerCase();
++ for (const [prefix, name] of FAMILY_BY_PREFIX) if (s.includes(prefix)) return name;
++ return null;
++}
++
++/** The artifact a row names, without its quant/revision: "deepseek/deepseek-chat". */
++function canonicalOf(row, provider) {
++ const meta = (row && row.metadata) || {};
++ if (typeof meta.id === 'string' && meta.id.includes('/')) {
++ return meta.id.replace(/:[^@]*@[^@]*$/, '').replace(/@[^@]*$/, '');
++ }
++ if (typeof meta.org === 'string' && typeof meta.model === 'string') return `${meta.org}/${meta.model}`;
++ const key = String((row && row.model_key) || '');
++ if (key.includes('/')) return key;
++ const family = familyFrom(key);
++ return `${ORG_BY_FAMILY[family] || provider.key}/${key}`;
++}
++
++const slugOf = (canonical) => String(canonical)
++ .toLowerCase()
++ .split('/')
++ .map((part) => part.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''))
++ .filter(Boolean)
++ .join('/');
++
++function familyOf(row, provider, canonical) {
++ const meta = (row && row.metadata) || {};
++ if (typeof meta.family === 'string' && meta.family.trim()) return meta.family.trim();
++ return familyFrom(canonical) || provider.display_name || provider.key;
++}
++
++function policyOf(provider) {
++ const p = provider && provider.metadata && provider.metadata.data_policy;
++ if (!p || typeof p !== 'object') return null;
++ const trains = typeof p.trains === 'boolean' ? p.trains : (typeof p.training === 'boolean' ? p.training : null);
++ const retention = Number.isFinite(Number(p.retention_days)) ? Number(p.retention_days) : null;
++ const region = typeof p.region === 'string' && p.region ? p.region : null;
++ const anonymized = typeof p.anonymized === 'boolean' ? p.anonymized : null;
++ if (trains == null && retention == null && region == null && anonymized == null) return null;
++ return { trains, retention_days: retention, region, anonymized };
++}
++
++const isCommunity = (provider) => Boolean(provider && provider.metadata && (provider.metadata.local === true || provider.metadata.community === true));
++
++function capabilitiesOf(row) {
++ const s = (row && row.supports) || {};
++ return { tools: Boolean(s.tools), json: Boolean(s.json), vision: Boolean(s.vision) || row.type === 'vision', stream: Boolean(s.streaming) };
++}
++
++function modalitiesOf(row) {
++ const out = new Set(['text']);
++ if (capabilitiesOf(row).vision) out.add('image');
++ if (row.type === 'stt') out.add('audio');
++ if (row.type === 'image') out.add('image');
++ return [...out];
++}
++
++function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }) {
++ 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;
++ }
++
++ /**
++ * Every live offer, grouped by the model it serves.
++ * days: the window the measured speed, uptime and request counts cover.
++ */
++ async function models({ days = 7 } = {}) {
++ 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 health = new Map();
++ for (const p of providers) {
++ try { health.set(p.key, await pool.health(p.key)); } catch (e) { health.set(p.key, null); }
++ }
++
++ const groups = new Map();
++ 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;
++ if (provider.metadata && provider.metadata.hidden === true) continue;
++ if (row.status !== 'active' || provider.status !== 'active') continue;
++ // Capacity the network may not resell is never an offer: a person's own key, or a restricted one.
++ if (provider.provider_access === 'CUSTOMER_BYOK' || provider.provider_access === 'RESTRICTED') continue;
++ if (!['chat', 'vision', 'embedding', 'stt', 'image'].includes(row.type)) continue;
++
++ const canonical = canonicalOf(row, provider);
++ const slug = slugOf(canonical);
++ 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: provider.key,
++ provider_name: provider.display_name || provider.key,
++ model: row.model_key,
++ // The exact artifact id a caller may pin (`org/model[:quant]@revision`); null when nobody recorded one.
++ id: (row.metadata && typeof row.metadata.id === 'string') ? row.metadata.id : null,
++ trust: isCommunity(provider) ? 'community' : 'first-party',
++ access: provider.provider_access || 'UNVERIFIED',
++ free: cards == null,
++ price: price ? { in: price.in, out: price.out, cached: price.cached, per: '1M tokens', currency: 'USD' } : 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,
++ };
++ if (!groups.has(slug)) {
++ groups.set(slug, {
++ slug, canonical,
++ name: row.display_name || row.model_key,
++ family: familyOf(row, provider, canonical),
++ variant: (row.metadata && row.metadata.variant) || null,
++ uncensored: UNCENSORED.test(`${canonical} ${(row.metadata && row.metadata.variant) || ''}`) || Boolean(row.metadata && row.metadata.uncensored === true),
++ type: row.type,
++ context: num(row.context_window),
++ max_output: num(row.max_output),
++ capabilities: capabilitiesOf(row),
++ modalities: modalitiesOf(row),
++ description: (row.metadata && typeof row.metadata.description === 'string' && row.metadata.description) || null,
++ offers: [], offers_listed: 0,
++ });
++ }
++ const g = groups.get(slug);
++ g.offers.push(offer);
++ g.offers_listed += 1;
++ 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;
++ }
++
++ const models = [...groups.values()];
++ for (const m of models) {
++ m.offers.sort((a, b) => {
++ const pa = a.price ? a.price.in + a.price.out : Number.POSITIVE_INFINITY;
++ const pb = b.price ? b.price.in + b.price.out : Number.POSITIVE_INFINITY;
++ return pa - pb || a.provider.localeCompare(b.provider);
++ });
++ 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.community = m.offers.some((o) => o.trust === 'community');
++ m.priced = m.offers.some((o) => o.price);
++ delete m.offers_listed;
++ }
++ models.sort((a, b) => b.requests_7d - a.requests_7d || b.requests_today - a.requests_today || a.name.localeCompare(b.name));
++
++ const families = new Map();
++ for (const m of models) {
++ const f = families.get(m.family) || { name: m.family, models: 0, offers: 0 };
++ f.models += 1;
++ f.offers += m.offers.length;
++ families.set(m.family, f);
++ }
++ return {
++ generated_at: new Date(now).toISOString(),
++ window_days: days,
++ count: models.length,
++ families: [...families.values()].sort((a, b) => b.models - a.models || a.name.localeCompare(b.name)),
++ models,
++ };
++ }
++
++ /** One model by its slug ("deepseek/deepseek-chat"), from the same read. */
++ async function model(slug, opts = {}) {
++ const all = await models(opts);
++ return all.models.find((m) => m.slug === String(slug || '').toLowerCase()) || null;
++ }
++
++ return { models, model, policyOf, canonicalOf, slugOf };
++}
++
++createCatalog.dayOf = dayOf;
++createCatalog.slugOf = slugOf;
++createCatalog.familyOf = familyOf;
++module.exports = { createCatalog, slugOf, canonicalOf, dayOf };
ddiff --git a/server/exchange/usage.js b/server/exchange/usage.js
nnew file mode 100644
iindex 0000000..7795a69
---- /dev/null
++++ b/server/exchange/usage.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * 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.
++ */
++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 shape = (r) => ({
++ requests: Number(r.requests) || 0,
++ tokens_in: Number(r.tokens_in) || 0,
++ tokens_out: Number(r.tokens_out) || 0,
++ cost_usd: Math.round((Number(r.cost_usd) || 0) * 1e6) / 1e6,
++});
++
++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 >= ?`);
++
++ /** days: the window (1–366). Costs are the network's own provider spend, in USD. */
++ async function read({ days = 30 } = {}) {
++ const now = clock.now();
++ const span = Math.max(1, Math.min(366, Number(days) || 30));
++ const from = dayOf(now - span * DAY_MS);
++ const todayKey = dayOf(now);
++ 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),
++ ]);
++ const products = new Map();
++ for (const r of productRows) {
++ const product = String(r.requester).slice('service:'.length);
++ products.set(product, { product, ...shape(r), models: [] });
++ }
++ for (const r of productModelRows) {
++ const product = products.get(String(r.requester).slice('service:'.length));
++ if (!product) continue;
++ product.models.push({ provider: r.provider_key, model: r.model_key || null, ...shape(r) });
++ }
++ for (const p of products.values()) p.models = p.models.slice(0, 8);
++ return {
++ generated_at: new Date(now).toISOString(),
++ days: span,
++ from,
++ to: todayKey,
++ today: shape(todayRow[0] || {}),
++ totals: shape(totalRow[0] || {}),
++ by_day: dayRows.map((r) => ({ day: String(r.day).slice(0, 10), ...shape(r) })),
++ by_model: modelRows.map((r) => ({ provider: r.provider_key, model: r.model_key || null, ...shape(r) })),
++ by_product: [...products.values()],
++ };
++ }
++
++ return { read };
++}
++
++module.exports = { createUsage };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 2e8b25c..1e5dd45 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -290,6 +290,66 @@ route('get', '/run-budgets/{jti}', {
responses: { 200: json('The budget and the spend', { type: 'object', required: ['jti'], properties: { jti: str(), budget_usd: nullable('number'), budget_tokens: nullable('integer'), spent_usd: nullable('number'), spent_tokens: nullable('integer'), expires_at: nullable('integer'), reached: { type: 'boolean' } } }), ...errors([401, 403, 422]) },
});
++// ── Exchange read API (OpenVibe.Exchange) ───────────────────────────────────────────────────
++const exchangeOffer = {
++ type: 'object', required: ['provider', 'provider_name', 'model', 'trust', 'free', 'price'],
++ properties: {
++ provider: str(), provider_name: str(), model: str(),
++ id: nullable('string', { description: 'The exact artifact id a request may pin, when the registry records one' }),
++ trust: { type: 'string', enum: ['first-party', 'community'] },
++ access: str({ enum: ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'UNVERIFIED'] }),
++ free: { type: 'boolean', description: 'Prepaid capacity with no per-token price (a free server, a subscription, a local machine).' },
++ price: nullable('object', { properties: { in: { type: 'number' }, out: { type: 'number' }, cached: { type: 'number' }, per: str(), currency: str() } }),
++ speed: { type: 'object', properties: { p50_ms: nullable('number'), p95_ms: nullable('number') } },
++ uptime: nullable('number', { minimum: 0, maximum: 1 }),
++ requests_7d: integer({ minimum: 0 }), requests_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'),
++ },
++};
++const exchangeModel = {
++ type: 'object', required: ['slug', 'name', 'family', 'uncensored', 'offers'],
++ properties: {
++ slug: str(), canonical: str(), name: str(), family: str(), variant: nullable('string'), uncensored: { type: 'boolean' },
++ 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' },
++ 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). The window is 7 days.',
++ 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(),
++ families: arrayOf({ type: 'object', properties: { name: str(), models: integer(), offers: integer() } }),
++ models: arrayOf(exchangeModel),
++ } }) },
++});
++route('get', '/exchange/models/{slug}', {
++ operationId: 'exchangeModel', summary: 'One model and its offers', tags: ['exchange'], security: [],
++ description: 'Public. The slug is "<org>/<model>", lowercased, e.g. deepseek/deepseek-chat.',
++ parameters: [path('slug', str())],
++ responses: { 200: json('The model', exchangeModel), 404: problem },
++});
++route('get', '/exchange/models/{org}/{model}', {
++ operationId: 'exchangeModelByParts', summary: 'One model, by its org and model', tags: ['exchange'], security: [],
++ description: 'Public. The same answer as /exchange/models/{slug}, for a client that has the two parts.',
++ parameters: [path('org', str()), path('model', str())],
++ responses: { 200: json('The model', exchangeModel), 404: problem },
++});
++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).',
++ 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(),
++ today: { type: 'object' }, totals: { type: 'object' },
++ by_day: arrayOf({ type: 'object' }), by_model: arrayOf({ type: 'object' }), by_product: arrayOf({ type: 'object' }),
++ } }) },
++});
++
// ── Action Center ────────────────────────────────────────────────────────────────────────────
route('get', '/actions', {
operationId: 'listActions', summary: 'The Action Center items for one person', tags: ['actions'], security: SERVICE,
@@@ -309,7 +369,7 @@ function openapi() {
contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.AI', email: '[email protected]' },
},
servers: [{ url: SITE }],
-- tags: ['runs', 'operations', 'actions'].map((name) => ({ name })),
++ tags: ['runs', 'operations', 'actions', 'exchange'].map((name) => ({ name })),
components: {
securitySchemes: {
bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or user token for audience openvibe.ai carrying the route\'s capability (ai.run.create, ai.run.read, ai.actions.read)' },
ddiff --git a/test/exchange-read.test.js b/test/exchange-read.test.js
nnew file mode 100644
iindex 0000000..137a2d1
---- /dev/null
++++ b/test/exchange-read.test.js
@@@ -0,0 +1,91 @@
++'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.
++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);
++
++t.test('boot', async () => {
++ h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
++});
++
++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',
++ base_url: 'https://api.deepseek.test', capabilities: ['chat', 'json', 'tools'], provider_access: 'OWN_DEPLOYMENT',
++ metadata: { data_policy: { trains: false, retention_days: 0, region: 'cn' } },
++ });
++ await h.registry.upsertModel({ provider_key: 'deepseek', model_key: 'deepseek-chat', type: 'chat', context_window: 131072,
++ cost: { in_per_mtok: 0.28, out_per_mtok: 0.42, cached_per_mtok: 0.028 } });
++ await h.registry.upsertProvider({
++ key: 'deepinfra', display_name: 'DeepInfra', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:DEEPINFRA_KEY',
++ base_url: 'https://api.deepinfra.test', capabilities: ['chat'], provider_access: 'AUTHORIZED_WHOLESALE',
++ });
++ 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));
++
++ const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.headers.get('cache-control'), /^public, max-age=30/);
++ const m = r.body.models.find((x) => x.slug === 'deepseek/deepseek-chat');
++ assert.ok(m, 'one model for both providers');
++ assert.strictEqual(m.family, 'DeepSeek');
++ assert.strictEqual(m.context, 131072);
++ assert.strictEqual(m.offers.length, 2, 'both offers on the one model page');
++ 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);
++ 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);
++});
++
++t.test('a person’s own-key capacity and a stub are never offers', async () => {
++ await h.registry.upsertProvider({ key: 'byok', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:BYOK', capabilities: ['chat'], provider_access: 'CUSTOMER_BYOK' });
++ await h.registry.upsertModel({ provider_key: 'byok', model_key: 'deepseek-chat', type: 'chat' });
++ const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');
++ const m = r.body.models.find((x) => x.slug === 'deepseek/deepseek-chat');
++ assert.ok(!m.offers.some((o) => o.provider === 'byok'));
++ assert.ok(!r.body.models.some((x) => x.offers.some((o) => o.provider === 'stub')));
++});
++
++t.test('one model by slug, and a miss is a problem document', async () => {
++ const ok = await request(h.base, 'GET', '/api/v1/exchange/models/deepseek/deepseek-chat');
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(ok.body.slug, 'deepseek/deepseek-chat');
++ const miss = await request(h.base, 'GET', '/api/v1/exchange/models/nope/nope');
++ assert.strictEqual(miss.status, 404);
++ 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);
++
++ 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.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.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));
++});