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