openvibers/openvibe.ai Commits
Exchange read API: the public catalog and aggregate usage (#308)
Changes
nnew file mode 100644@@@ -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 };@@@ -108,6 +108,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keysapp.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 });nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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)' },nnew file mode 100644@@@ -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));++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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 };
@@ -108,6 +108,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -108,6 +108,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// GET /openapi.json: the public OpenAPI 3.1 document (server/openapi.js); /api/v1/openapi.json redirects here.
// GET /openapi.json: the public OpenAPI 3.1 document (server/openapi.js); /api/v1/openapi.json redirects here.
openapi.mount(app);
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).
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
new file mode 100644
new file mode 100644
@@ -0,0 +1,236 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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 };
@@ -290,6 +290,66 @@ route('get', '/run-budgets/{jti}', {
@@ -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]) },
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 ────────────────────────────────────────────────────────────────────────────
// ── Action Center ────────────────────────────────────────────────────────────────────────────
route('get', '/actions', {
route('get', '/actions', {
operationId: 'listActions', summary: 'The Action Center items for one person', tags: ['actions'], security: SERVICE,
operationId: 'listActions', summary: 'The Action Center items for one person', tags: ['actions'], security: SERVICE,
@@ -309,7 +369,7 @@ function openapi() {
@@ -309,7 +369,7 @@ function openapi() {
contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.AI', email: '[email protected]' },
contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.AI', email: '[email protected]' },
},
},
servers: [{ url: SITE }],
servers: [{ url: SITE }],
- tags: ['runs', 'operations', 'actions'].map((name) => ({ name })),
+ tags: ['runs', 'operations', 'actions', 'exchange'].map((name) => ({ name })),
components: {
components: {
securitySchemes: {
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)' },
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)' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -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));
+});