openvibers/openvibe.exchange Commits
Exchange: measured model numbers and your own AI usage (#248)
Changes
@@@ -50,6 +50,13 @@ OV_OAUTH_CLIENT_SECRET=#EXCHANGE_AI_TIMEOUT_MS=30000#EXCHANGE_AI_WAIT_MS=25000++# OpenVibe.Report: the public AI performance rows behind a model's Measured panel, and a signed-in person's own++# AI usage (their Network session token is forwarded; no service token is involved). A public row floor of 20++# calls in 30 days; the performance rows are cached for five minutes.++#OV_REPORT_URL=https://openvibe.report++#EXCHANGE_REPORT_TIMEOUT_MS=2000++#EXCHANGE_REPORT_CACHE_MS=300000++# /openapi.json reads the API host's document and keeps it for ten minutes.#OV_EXCHANGE_API_URL=https://api.openvibe.exchange#OV_EXCHANGE_OPENAPI_URL=https://api.openvibe.exchange/openapi.json@@@ -4,6 +4,13 @@ What changed in OpenVibe.Exchange, newest first.## Unreleased++- **Measured, and your own AI usage (2026-11-12).** A model page now has a **Measured** panel from++ OpenVibe.Report's public performance rows: first token and whole-answer percentiles, tokens a second, success,++ cache hits, what a call and a million output tokens were charged, and calls in the last 30 days. A model below++ Report's floor of 20 calls says "Not measured yet". A signed-in person's dashboard Usage adds **Your AI usage**++ (calls, tokens, charged, a call a day and the products), read from Report with the person's own Network session.++ Both draw with `openvibe-shared/ov-metrics`; the same numbers stay as plain text without JavaScript.++- **The public pages of ai.openvibe.services are here now (2026-11-07).** The old site's home, prices and latency,changelog, legal pages and discovery files answer 301 on an exact page map; every API route there is unchanged.Three pages carry the content: `/start` (the endpoint, four steps, OpenAI/Anthropic/Link samples, AI's six@@@ -98,6 +98,14 @@.ex-more a { text-decoration: none; }.ex-more a:hover { text-decoration: underline; }.ex-prose { max-width: 74ch; line-height: 1.65; }++/* OVMetrics blocks (openvibe-shared/ov-metrics.js): the browser draws tiles, strips, spark and bars; the plain-text++ fallback is the same numbers for a reader without JavaScript, and is shown until a builder has drawn. */++.ex-metrics { display: grid; gap: 12px; min-width: 0; }++.ex-metrics [data-ov-metrics-live][hidden], .ex-metrics .ovm-fallback[hidden] { display: none; }++.ex-metrics-line { margin: 0; line-height: 1.55; overflow-wrap: anywhere; }++@media (max-width: 420px) {++ .ex-metrics .ovm-tiles { grid-template-columns: minmax(0, 1fr); }++}.ex-prose code, .ex-wrap code { font-size: .9em; overflow-wrap: anywhere; }.ex-h3 { margin: 22px 0 8px; font-size: 1rem; }.ex-list { margin: 0 0 12px; padding-left: 1.2em; line-height: 1.65; }nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++++/**++ * Draws every [data-ov-metrics] block with openvibe-shared/ov-metrics (ai-metrics.md, 2026-11-12). The server leaves++ * the block's parts as JSON and the same numbers as plain text; this script draws the tiles, sparklines, bars and++ * percentile strips with OVMetrics and only then hides the text. Without JavaScript, or if the script fails, the text++ * is what a reader sees: no number is lost, and no chart is invented here.++ */++(function () {++ if (typeof OVMetrics === 'undefined') return; // the server's plain numbers stay++ var FORMATS = {++ ms: OVMetrics.format.ms,++ usd: OVMetrics.format.usd,++ count: OVMetrics.format.count,++ tokens: OVMetrics.format.tokens,++ rate: OVMetrics.format.rate,++ tps: OVMetrics.format.tps,++ bytes: OVMetrics.format.bytes,++ delta: OVMetrics.format.delta,++ };++++ function resolved(value) {++ if (!value || typeof value !== 'object' || typeof value.format !== 'string') return value;++ var copy = {};++ for (var key in value) if (Object.prototype.hasOwnProperty.call(value, key)) copy[key] = value[key];++ copy.format = FORMATS[value.format] || OVMetrics.format.count;++ return copy;++ }++++ function draw() {++ var blocks = document.querySelectorAll('[data-ov-metrics]');++ for (var i = 0; i < blocks.length; i += 1) {++ var box = blocks[i];++ var live = box.querySelector('[data-ov-metrics-live]');++ var data = box.querySelector('script[type="application/json"]');++ if (!live || !data || typeof OVMetrics === 'undefined') continue;++ try {++ var parts = JSON.parse(data.textContent || '[]');++ var frag = document.createDocumentFragment();++ for (var j = 0; j < parts.length; j += 1) {++ var part = parts[j];++ if (part && part.kind === 'heading' && part.text) {++ var heading = document.createElement('p');++ heading.className = 'ovm-label';++ heading.textContent = String(part.text);++ frag.append(heading);++ continue;++ }++ if (!part || typeof part.kind !== 'string' || typeof OVMetrics[part.kind] !== 'function') continue;++ var args = (part.args || []).map(resolved);++ frag.append(OVMetrics[part.kind].apply(OVMetrics, args));++ }++ if (!frag.childNodes.length) continue;++ live.append(frag);++ var fallback = box.querySelector('.ovm-fallback');++ if (fallback) fallback.hidden = true;++ live.hidden = false;++ } catch (err) {++ // The server's plain numbers stay; a metrics block never breaks the page.++ }++ }++ }++++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', draw);++ else draw();++})();@@@ -53,4 +53,4 @@ function createAnalysis({ config, ai, bench, fetchImpl = globalThis.fetch, now =}return { model, compare, metrics: source };}--module.exports = { createAnalysis, ratingFor };++module.exports = { createAnalysis, ratingFor, bare };@@@ -31,6 +31,7 @@ const { createAi } = require('./ai');const { createActor } = require('./actor');const { createNetwork } = require('./network');const { createBench } = require('./bench');++const { createReport } = require('./report');const { createCompare } = require('./compare');const { createOpenApi } = require('./openapi');const { createCode } = require('./code');@@@ -92,6 +93,8 @@ async function createApp(opts = {}) {// Network, read and changed with the person's own token: their machines and their machine pools.const network = opts.network || createNetwork({ config, fetchImpl, log });const bench = opts.bench || createBench({ config, fetchImpl: opts.benchFetchImpl || globalThis.fetch, log, now: s.now });++ // Report's public performance rows (a model's Measured panel) and a person's own AI usage.++ const report = opts.report || createReport({ config, fetchImpl: opts.reportFetchImpl || globalThis.fetch, log, now: s.now });// openvibe.compare's engine (server/compare.js): the join of AI's price/speed/privacy/tier with Bench's quality.const compare = createCompare({ s, ai, bench, config, now: s.now, log });const analysis = opts.analysis || require('./analysis').createAnalysis({ config, ai, bench, fetchImpl: opts.aiFetchImpl || globalThis.fetch, now: s.now });@@@ -131,7 +134,7 @@ async function createApp(opts = {}) {const code = opts.code || createCode({config, fetchImpl: opts.sourcesFetchImpl || globalThis.fetch, tokenFetch: fetchImpl, now: opts.now || (() => Date.now()), log,});-- const ctx = { config, s, keys, sso, principal, ai, actor, network, bench, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, code, log };++ const ctx = { config, s, keys, sso, principal, ai, actor, network, bench, report, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, code, log };const app = express();app.disable('x-powered-by');@@@ -99,6 +99,15 @@ function load(env = process.env) {pollMs: Math.max(500, int(env.EXCHANGE_ACTOR_POLL_MS, 2000)),},++ // OpenVibe.Report (server/report.js): the public AI performance rows behind a model's Measured panel, and a++ // signed-in person's own AI usage. A short timeout: Report is a read for the page, never a reason to wait.++ // Performance rows keep for five minutes; a person's rows are private and never cached.++ report: {++ url: trim(env.OV_REPORT_URL || 'https://openvibe.report'),++ timeoutMs: Math.max(250, int(env.EXCHANGE_REPORT_TIMEOUT_MS, 2000)),++ cacheMs: Math.max(10_000, int(env.EXCHANGE_REPORT_CACHE_MS, 300_000)),++ },++// OpenVibe.Bench (server/bench.js): the Arena's rounds and each person's vote are a Bench// human_pairwise judgement, and the rankings read the board by task category. The token is this// service's own client-credentials grant (bench.round.create, bench.judgement.cast, bench.board.read).@@@ -4,7 +4,8 @@ const { raw } = require('../render/html');const { send } = require('../render/layout');const { sameOrigin } = require('./principal');const views = require('../views/dashboard');--function createDashboardRoutes({ config, ai }) {++const metricsView = require('../views/metrics');++function createDashboardRoutes({ config, ai, report }) {const r = express.Router();r.use(express.urlencoded({ extended: false, limit: '32kb' }));const render = (req, res, tab, html, status = 200) =>@@@ -14,6 +15,7 @@ function createDashboardRoutes({ config, ai }) {path: req.originalUrl,index: false,title: 'Dashboard · ' + tab,++ head: tab === 'Usage' ? metricsView.HEAD : '',body: raw(html),});r.use('/dashboard', (req, res, next) => {@@@ -78,18 +80,17 @@ function createDashboardRoutes({ config, ai }) {safe(async (req, res) => {const data = await call(req, '/api/v1/exchange/key-usage');const rows = data.rows || [];-- const keyList = req.path.endsWith('.csv') ? null : await call(req, '/api/v1/exchange/keys').catch(() => ({ keys: [] }));-- const columns = [-- 'day',-- 'key_id',-- 'model',-- 'requests',-- 'tokens_in',-- 'tokens_out',-- 'cost_usd',-- 'status',-- ];if (req.path.endsWith('.csv')) {++ const columns = [++ 'day',++ 'key_id',++ 'model',++ 'requests',++ 'tokens_in',++ 'tokens_out',++ 'cost_usd',++ 'status',++ ];const cell = (v) =>'"' +String(v ?? '')@@@ -111,7 +112,12 @@ function createDashboardRoutes({ config, ai }) {].join('\n'),);}-- render(req, res, 'Usage', views.usagePage(rows, (keyList && keyList.keys) || []));++ // The person's own AI usage is a separate read: Report failing leaves the keys' usage untouched.++ const [keyList, aiUsage] = await Promise.all([++ call(req, '/api/v1/exchange/keys').catch(() => ({ keys: [] })),++ report ? report.personUsage(req.viewer.token).catch(() => null) : null,++ ]);++ render(req, res, 'Usage', views.usagePage(rows, (keyList && keyList.keys) || [], aiUsage));}),);r.get('/dashboard/credits', (req, res) => render(req, res, 'Credits', views.creditsPage()));@@@ -27,6 +27,7 @@ const { esc, raw } = require('../render/html');const marks = require('../views/marks');const { send } = require('../render/layout');const views = require('../views/exchange');++const metricsView = require('../views/metrics');const platform = require('../views/platform');const decisionsView = require('../views/decisions');const credentialViews = require('../views/credentials');@@@ -177,9 +178,15 @@ function createPageRoutes(ctx) {return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such model', text: `Nothing here serves ${esc(`${req.params.org}/${req.params.model}`)}. <a href="/models">Browse the models</a>.` })}</div>`) }, 404);}const isAdult = signedIn(req) ? await adult.confirmed(owner(req)) : false;-- let analysis = null;-- try { analysis = await ctx.analysis.model(model, { rating_kind: typeof req.query.rating_kind === 'string' ? req.query.rating_kind : 'chat.arena' }); }-- catch (err) { log.warn('[exchange] model analysis unavailable: ' + String(err.message || err).slice(0, 160)); }++ // The model's measured row and its Bench analysis are reads beside the page: each failing leaves its panel++ // saying so, never a 500 page. Report's row is cached for five minutes inside server/report.js, and the two++ // reads run together so neither waits on the other.++ const [analysis, measured] = await Promise.all([++ ctx.analysis.model(model, { rating_kind: typeof req.query.rating_kind === 'string' ? req.query.rating_kind : 'chat.arena' })++ .catch((err) => { log.warn('[exchange] model analysis unavailable: ' + String(err.message || err).slice(0, 160)); return null; }),++ (ctx.report ? ctx.report.measuredModel(model) : Promise.resolve(null))++ .catch((err) => { log.warn('[exchange] Report performance unavailable: ' + String(err.message || err).slice(0, 160)); return null; }),++ ]);const jsonLd = seo.jsonLd.webPage({name: `${model.name} on OpenVibe.Exchange`, url: `${config.baseUrl}${modelPath(model)}`,description: `${model.name}: ${model.offers.length} live offers with price, speed and uptime.`.slice(0, 160), siteUrl: config.baseUrl,@@@ -189,8 +196,8 @@ function createPageRoutes(ctx) {title: model.name, description: `${model.name}: price, speed, uptime and data policy of every offer.`,jsonLd: [jsonLd, { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: model.name,url: `${config.baseUrl}${modelPath(model)}`, description: `${model.name}: context, model limits, price and available providers.`,-- applicationCategory: 'DeveloperApplication' }], head: '',-- body: raw(views.modelPage({ model, viewer: req.viewer, result, problem, text, adult: isAdult, community, analysis })),++ applicationCategory: 'DeveloperApplication' }], head: metricsView.HEAD,++ body: raw(views.modelPage({ model, viewer: req.viewer, result, problem, text, adult: isAdult, community, analysis, measured })),}, status);}nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++++/**++ * OpenVibe.Report reads (ai-metrics.md, 2026-11-12):++ *++ * GET /api/v1/ai/performance?group=model&days=30&model=<key> the public row behind a model's Measured panel;++ * at least 20 calls in 30 days, no person, no prompt.++ * GET /api/v1/me/ai/usage?group=day|surface the signed-in person's own rows, with the person's++ * Network session (the same token Exchange forwards++ * to AI and Network), never a service token.++ *++ * Report is a read for the page, never a reason for it to fail: a timeout, a refusal or a row that does not match++ * answers null, and the page says "not measured yet" or "not available right now". Performance rows are cached for++ * five minutes; a person's rows are private and never cached.++ *++ * The model Report records is AI's own model key (the provider's model id, e.g. `deepseek-chat`), while a catalog++ * model is `deepseek/deepseek-chat`. The candidates are tried in that order and compared with the same `bare()`++ * rule the Bench rating uses, so a dated revision and a provider prefix do not hide the row.++ */++const { bare } = require('./analysis');++++const PERFORMANCE_DAYS = 30;++const NOT_MEASURED = 'Not measured yet: a model is measured after 20 calls in 30 days.';++++function createReport({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++ const report = (config && config.report) || {};++ const base = String(report.url || 'https://openvibe.report').replace(/\/+$/, '');++ const timeoutMs = Math.max(250, Number(report.timeoutMs) || 2000);++ const cacheMs = Math.max(1000, Number(report.cacheMs) || 300000);++ const cache = new Map();++++ /** One GET, JSON, short; a non-2xx is an error with its status so a caller can tell "down" from "no row". */++ async function read(path, { token = null } = {}) {++ const headers = { accept: 'application/json' };++ if (token) headers.authorization = `Bearer ${token}`;++ const res = await fetchImpl(`${base}${path}`, { headers, signal: AbortSignal.timeout(timeoutMs) });++ if (!res || !res.ok) {++ const err = new Error(`OpenVibe.Report answered ${res ? res.status : 'nothing'}`);++ err.status = res ? res.status : 0;++ throw err;++ }++ return res.json();++ }++++ /** The catalog ids a Report row for this model may carry, likeliest first: AI's model key, then the slug. */++ function candidatesOf(model) {++ const offers = Array.isArray(model && model.offers) ? model.offers : [];++ const ids = [...offers.map((o) => o && o.model), ...offers.map((o) => o && o.id), model && model.slug, model && model.canonical, model && model.id]++ .filter((v) => typeof v === 'string' && v.trim())++ .map((v) => v.trim());++ return [...new Set(ids)];++ }++++ /** The row of one candidate, or null when Report has none. A 5xx or a network failure stops the search. */++ async function rowFor(candidate) {++ const path = `/api/v1/ai/performance?group=model&days=${PERFORMANCE_DAYS}&model=${encodeURIComponent(candidate)}`;++ let body;++ try {++ body = await read(path);++ } catch (err) {++ if (err && err.status && err.status < 500) return { row: null, stop: true, err };++ if (log && log.warn) log.warn('[OpenVibe.Exchange] the Report performance read failed: ' + String((err && err.message) || err).slice(0, 160));++ return { row: null, stop: true, err };++ }++ const rows = body && Array.isArray(body.rows) ? body.rows : [];++ if (!rows.length) return { row: null, stop: false };++ const exact = rows.find((r) => r && r.key === candidate);++ const loose = rows.find((r) => r && bare(r.key) === bare(candidate));++ return { row: exact || loose || null, stop: true };++ }++++ /**++ * The public performance row for one catalog model, or null when Report knows no row yet. The whole answer,++ * including "no row", is cached for five minutes so a page view does not ask Report again.++ */++ async function measuredModel(model) {++ if (!model || !model.slug) return null;++ const key = `model:${model.slug}`;++ const hit = cache.get(key);++ if (hit && hit.until > now()) return hit.value;++ let found = null;++ for (const candidate of candidatesOf(model)) {++ const out = await rowFor(candidate);++ if (out.row) { found = out.row; break; }++ if (out.stop) break;++ }++ cache.set(key, { until: now() + cacheMs, value: found });++ if (cache.size > 512) cache.delete(cache.keys().next().value);++ return found;++ }++++ /**++ * The signed-in person's own AI usage: by day (the totals and the spark) and by surface (the bars). The++ * person's Network session token is forwarded as their Bearer; Report decides who that is, never the query.++ * Each read is independent: one failing leaves the other's numbers on the page. Nothing is cached.++ */++ async function personUsage(token, { from = null } = {}) {++ if (!token) return null;++ const query = (group) => `/api/v1/me/ai/usage?group=${group}${from ? `&from=${encodeURIComponent(from)}` : ''}`;++ const [day, surface] = await Promise.all([++ read(query('day'), { token }).catch((err) => {++ if (log && log.warn) log.warn('[OpenVibe.Exchange] the Report day usage read failed: ' + String((err && err.message) || err).slice(0, 160));++ return null;++ }),++ read(query('surface'), { token }).catch((err) => {++ if (log && log.warn) log.warn('[OpenVibe.Exchange] the Report surface usage read failed: ' + String((err && err.message) || err).slice(0, 160));++ return null;++ }),++ ]);++ if (!day && !surface) return null;++ return { day: day && day.totals ? day : null, surface: surface && Array.isArray(surface.rows) ? surface : null };++ }++++ return { measuredModel, personUsage, candidatesOf, NOT_MEASURED };++}++++module.exports = { createReport, NOT_MEASURED, PERFORMANCE_DAYS };@@@ -7,6 +7,8 @@*/const kit = require('openvibe-shared/kit');const { esc } = require('../render/html');++const metrics = require('./metrics');++const f = metrics.format;const TABS = [['keys', 'Keys'], ['usage', 'Usage'], ['credits', 'Credits'], ['deployments', 'Deployments'], ['settings', 'Settings']];const money = (v) => {@@@ -72,8 +74,70 @@ function newKeyPage(secret, key = null) {+ `${kit.snippet({ code: secret, id: 'new-key' })}<p class="xd-actions">${kit.button({ href: '/dashboard/keys', label: 'Done' })}</p></section>`);}++/**++ * "Your AI usage", for the signed-in person: Report's own answer for their session — total calls, tokens and what++ * was charged, a call a day, and the products the calls came from. It is a panel of its own above the API keys'++ * usage, and a Report failure says so without touching the rest of the page. Every number is OVMetrics' words++ * (server/views/metrics.js); the plain-text fallback is what a reader without JavaScript sees.++ */++function aiUsagePanel(usage) {++ const title = 'Your AI usage';++ const note = 'Every AI call on your account in the last 30 days, from OpenVibe.Report; no prompt and no answer.';++ const card = (body) => `<section class="xd-card" id="your-ai-usage"><h2>${title}</h2><p class="xd-note">${note}</p>${body}</section>`;++ if (!usage || !usage.day || !usage.day.totals) return card(kit.notice({ kind: 'info', text: 'Your AI usage is not available right now.' }));++ const totals = usage.day.totals;++ if (!Number(totals.calls)) return card(kit.empty({ title: 'No AI calls yet', text: 'Calls you make on any OpenVibe product show here, with their tokens and cost.' }));++++ const number = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v));++ const input = number(totals.input_tokens);++ const cached = number(totals.cached_tokens);++ const cacheWrite = number(totals.cache_write_tokens);++ const output = number(totals.output_tokens);++ const charged = number(totals.charged_usd);++ const saved = number(totals.saved_usd);++ const list = number(totals.cost_usd);++ const tokens = [input, cached, cacheWrite, output].every((n) => n != null) ? input + cached + cacheWrite + output : null;++ const tokensIn = [input, cached, cacheWrite].every((n) => n != null) ? input + cached + cacheWrite : null;++ const tiles = [++ { label: 'Calls', value: f.count(totals.calls) },++ { label: 'Tokens', value: f.count(tokens), sub: tokensIn != null && output != null ? `${f.count(tokensIn)} in · ${f.count(output)} out` : '' },++ { label: 'Charged', value: f.usd(charged), sub: saved != null && saved > 0 ? `${f.usd(saved)} saved by cache` : list != null ? `${f.usd(list)} at list` : '' },++ ];++++ // The last 30 UTC days, with a measured zero on a day Report has no row: a day with no calls is not unknown.++ const dayCalls = new Map((usage.day.rows || []).map((r) => [String(r.key).slice(0, 10), Number(r.calls) || 0]));++ const today = new Date();++ const end = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());++ const days = [];++ const callsPerDay = [];++ for (let i = 29; i >= 0; i -= 1) {++ const d = new Date(end - i * 86400000).toISOString().slice(0, 10);++ days.push(d);++ callsPerDay.push(dayCalls.get(d) || 0);++ }++++ const parts = [++ { kind: 'tiles', args: [tiles] },++ { kind: 'heading', text: 'Calls a day, last 30 days' },++ { kind: 'spark', args: [callsPerDay, { label: 'AI calls a day, last 30 days' }] },++ ];++ const byDay = days.filter((d) => dayCalls.get(d)).map((d) => `${esc(d)} ${f.count(dayCalls.get(d))}`);++ const tokensText = tokens == null ? f.count(null) : `${f.count(tokens)}${tokensIn != null && output != null ? ` (${f.count(tokensIn)} in · ${f.count(output)} out)` : ''}`;++ let fallback = `<p class="ex-metrics-line"><b>Calls</b> ${f.count(totals.calls)} · <b>Tokens</b> ${tokensText} · <b>Charged</b> ${f.usd(charged)}${saved != null && saved > 0 ? ` (${f.usd(saved)} saved by cache)` : ''}</p>`;++ fallback += `<p class="ex-metrics-line"><b>Calls a day</b> ${byDay.length ? byDay.join(' · ') : 'none in the last 30 days'}</p>`;++++ const surfaces = ((usage.surface && usage.surface.rows) || []).filter((r) => Number(r.calls) > 0).slice(0, 8);++ if (surfaces.length) {++ const rows = surfaces.map((r) => ({ label: !r.key || r.key === 'unknown' ? 'Not said' : String(r.key).slice(0, 80), value: Number(r.calls) || 0 }));++ parts.push({ kind: 'heading', text: 'By product' });++ parts.push({ kind: 'bars', args: [rows, { format: 'count' }] });++ fallback += `<p class="ex-metrics-line"><b>By product</b> ${rows.map((r) => `${esc(r.label)} ${f.count(r.value)} calls`).join(' · ')}</p>`;++ }++ return card(metrics.block(parts, fallback));++}++/** Usage rows from the API (by day, key, model and status) with key names; the newest day first. */--function usagePage(rows = [], keys = []) {++function usagePage(rows = [], keys = [], aiUsage = null) {const names = new Map(keys.map((k) => [k.id, k.name]));const totals = rows.reduce((t, r) => ({cost: t.cost + (Number(r.cost_usd) || 0), requests: t.requests + (Number(r.requests) || 0),@@@ -81,7 +145,8 @@ function usagePage(rows = [], keys = []) {}), { cost: 0, requests: 0, tokens: 0 });const days = [...new Set(rows.map((r) => String(r.day).slice(0, 10)))].sort();const head = `<div class="xd-title"><h2>Usage</h2>${rows.length ? `<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/dashboard/usage.csv" download><span>CSV</span></a>` : ''}</div>`;-- if (!rows.length) return shell('usage', `<section class="xd-card">${head}${kit.empty({ title: 'No usage yet', text: 'Calls made with your keys show here by day, key and model, with their tokens and cost.' })}</section>`);++ const ai = aiUsagePanel(aiUsage);++ if (!rows.length) return shell('usage', `${ai}<section class="xd-card">${head}${kit.empty({ title: 'No usage yet', text: 'Calls made with your keys show here by day, key and model, with their tokens and cost.' })}</section>`);const span = days.length > 1 ? `${day(days[0])} – ${day(days[days.length - 1])}` : day(days[0]);const stat = (v, label) => `<div><dd>${esc(v)}</dd><dt>${esc(label)}</dt></div>`;// Requests per day, the last 14 days that have any: bars scaled to the busiest one (the rows below hold the numbers).@@@ -100,7 +165,7 @@ function usagePage(rows = [], keys = []) {}).join('');return `<h3 class="xd-day">${esc(day(d))}</h3><ul class="xd-use">${items}</ul>`;}).join('');-- return shell('usage', `<section class="xd-card">${head}<p class="xd-note">${esc(span)}</p>`++ return shell('usage', `${ai}<section class="xd-card">${head}<p class="xd-note">${esc(span)}</p>`+ `<dl class="xd-stats">${stat(money(totals.cost), 'spent')}${stat(count(totals.requests), 'requests')}${stat(count(totals.tokens), 'tokens')}</dl>`+ `${bars}</section><section class="xd-card"><h2 class="xd-sub">By day, key and model</h2>${byDay}</section>`);}@@@ -9,6 +9,8 @@const kit = require('openvibe-shared/kit');const { esc } = require('../render/html');const { mark, product, productMark } = require('./marks');++const metrics = require('./metrics');++const { NOT_MEASURED } = require('../report');const ENDPOINT = 'https://api.openvibe.exchange/v1/chat/completions';const CHAT = 'https://openvibe.chat/vibe';@@@ -326,7 +328,42 @@ ${communityPools(community)}}// ── One model ──────────────────────────────────────────────--function modelPage({ model, viewer, result = null, problem = null, text = '', adult = false, community = null, analysis = null }) {++const f = metrics.format;++++/**++ * What real calls measured on this model in the last 30 days, from Report's public performance row: the first++ * token and the whole answer's percentiles, speed, success, cache hits and what a call was charged. A model below++ * Report's floor (20 calls in 30 days) has no row, and a Report failure looks the same: said plainly, never a zero.++ */++function measuredPanel(row) {++ const title = 'Measured';++ const note = 'From OpenVibe.Report: real calls in the last 30 days, with no prompt and no person in them.';++ if (!row) return panel({ title, note, id: 'measured', body: kit.notice({ kind: 'info', text: NOT_MEASURED }) });++ const ttft = row.ttft_ms || {};++ const latency = row.latency_ms || {};++ const tps = row.tps || {};++ const cost = row.cost || {};++ const tiles = [++ { label: 'Calls, 30 days', value: f.count(row.calls) },++ { label: 'First token p50', value: f.ms(ttft.p50), sub: `p95 ${f.ms(ttft.p95)}` },++ { label: 'Output speed p50', value: f.tps(tps.p50) },++ { label: 'Success', value: f.rate(row.success_rate) },++ { label: 'Cache hit', value: f.rate(row.cache_hit_rate) },++ { label: 'Charged per call', value: f.usd(cost.per_call_usd) },++ { label: 'Charged per 1M output', value: f.usd(cost.per_million_output_usd) },++ ];++ const parts = [++ { kind: 'tiles', args: [tiles] },++ { kind: 'percentiles', args: [ttft, { unit: 'ms', label: 'Time to first token' }] },++ { kind: 'percentiles', args: [latency, { unit: 'ms', label: 'Latency, whole answer' }] },++ ];++ const fallback = `<p class="ex-metrics-line"><b>Calls, 30 days</b> ${f.count(row.calls)} · <b>First token</b> p50 ${f.ms(ttft.p50)}, p95 ${f.ms(ttft.p95)} · <b>Output speed p50</b> ${f.tps(tps.p50)}</p>++<p class="ex-metrics-line"><b>Success</b> ${f.rate(row.success_rate)} · <b>Cache hit</b> ${f.rate(row.cache_hit_rate)} · <b>Charged</b> ${f.usd(cost.per_call_usd)} a call, ${f.usd(cost.per_million_output_usd)} per 1M output tokens</p>++<p class="ex-metrics-line"><b>Time to first token</b> p50 ${f.ms(ttft.p50)} · p95 ${f.ms(ttft.p95)} (${f.count(ttft.n)} samples) · <b>Latency, whole answer</b> p50 ${f.ms(latency.p50)} · p95 ${f.ms(latency.p95)} (${f.count(latency.n)} samples)</p>`;++ return panel({ title, note, id: 'measured', body: metrics.block(parts, fallback) });++}++++function modelPage({ model, viewer, result = null, problem = null, text = '', adult = false, community = null, analysis = null, measured = null }) {const offers = model.offers || [];const best = cheapest(model);const badges = [model.uncensored ? kit.badge('18+', 'bad') : '', model.community ? kit.badge('Community', 'accent') : '', offers.some(freeTier) ? kit.badge('Free to try') : (offers.some(freePaused) ? kit.badge('Free paused') : '')].join('');@@@ -383,6 +420,7 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad${kit.head({ title: model.name, sub: `${familyLabel(model)}${model.variant ? ` · ${model.variant}` : ''} · ${offers.length} offer${offers.length === 1 ? '' : 's'}`, back: { href: '/models', label: 'Models' }, actions: badges ? [badges] : [] })}${stats.length ? kit.stats(stats) : ''}${model.description ? `<p class="ex-prose">${esc(model.description)}</p>` : ''}++${measuredPanel(measured)}${panel({ title: 'Every offer that serves it', note: 'Price in, out and cached per million tokens; speed and uptime from the last seven days of real requests; the privacy label and the provider’s own data policy.', id: 'offers', body: kit.table({ columns, rows, label: `Offers for ${model.name}`, empty: 'No live offer serves this model right now.' }) })}<p class="ex-more"><a href="https://openvibe.compare/things/${encodeURIComponent(model.slug)}">Compare ${esc(model.name)} with another model →</a></p>${activityPanel(model.activity)}nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++++/**++ * The one way Exchange shows numbers (ai-metrics.md, 2026-11-12): openvibe-shared/ov-metrics. The builders are the++ * browser's (tiles, sparklines, bars, percentile strips); this module carries their data to the page and leaves the++ * server fallback — the same numbers in plain text, with no chart or tile of our own — for a reader without++ * JavaScript. Every word comes from OVMetrics.format in Node, so both renderings say the same thing.++ */++const ovServe = require('openvibe-shared/serve');++const M = require('openvibe-shared/ov-metrics');++const { assetVersion } = require('../render/layout');++++const HEAD = `<script src="${ovServe.url('ov-metrics.js')}" defer></script><script src="/js/metrics-panel.js?v=${assetVersion('js/metrics-panel.js')}" defer></script>`;++++/** The data as a script that cannot close its own tag, and cannot be read as markup. */++function json(parts) {++ return JSON.stringify(parts).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');++}++++/**++ * A metrics block: the browser draws `parts` (each `{ kind, args }`, called as OVMetrics[kind](...args)), and the++ * server's `fallback` HTML is what a reader without JavaScript sees. The renderer hides the fallback only after a++ * builder has drawn, so a broken script leaves the numbers readable.++ */++function block(parts, fallback) {++ return `<div class="ex-metrics" data-ov-metrics><div class="ovm-fallback">${fallback}</div><div data-ov-metrics-live hidden></div><script type="application/json">${json(parts)}</script></div>`;++}++++module.exports = { HEAD, block, format: M.format };@@@ -45,7 +45,9 @@ async function boot(opts = {}) {// no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });const emptyRatings = async () => new Response(JSON.stringify({ board: { minimum_samples: 20, rows: [] } }), { headers: { 'content-type': 'application/json' } });-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, actor: opts.actor, actorFetchImpl: opts.actorFetchImpl, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl, code: opts.code, sourcesFetchImpl: opts.sourcesFetchImpl });++ // No test reaches OpenVibe.Report unless it hands its own client (or a fetch for the real client's tests).++ const report = opts.report || (opts.reportFetchImpl ? undefined : { measuredModel: async () => null, personUsage: async () => null });++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, actor: opts.actor, actorFetchImpl: opts.actorFetchImpl, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, report, reportFetchImpl: opts.reportFetchImpl, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl, code: opts.code, sourcesFetchImpl: opts.sourcesFetchImpl });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;nnew file mode 100644@@@ -0,0 +1,190 @@++'use strict';++/**++ * The Measured panel on a model page and "Your AI usage" on a signed-in person's dashboard (ai-metrics.md).++ *++ * Both are OpenVibe.Report reads: a public performance row per model, and the person's own rows with the Network++ * session Exchange already holds. The pages are server-rendered and carry the same numbers as plain text for a++ * reader without JavaScript; the OVMetrics builders draw the tiles, strips, spark and bars in the browser.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { createFakeAi } = require('./helpers/ai');++const { createReport, NOT_MEASURED } = require('../server/report');++++const today = new Date().toISOString().slice(0, 10);++const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);++++const ROW = {++ group: 'model', key: 'deepseek-chat', calls: 13080,++ success_rate: 0.9941, cache_hit_rate: 0.42,++ ttft_ms: { p50: 540, p90: 980, p95: 1310, n: 13080 },++ latency_ms: { p50: 1200, p90: 2600, p95: 3400, n: 13080 },++ tps: { p50: 86, p90: 120, p95: 133, n: 13080 },++ tokens: { input: 130000, output: 54000 },++ cost: { charged_usd: 61.2, per_call_usd: 0.0042, per_million_output_usd: 12 },++};++++const PERSON = {++ day: {++ group: 'day', source: 'raw', insights: [],++ rows: [{ key: yesterday, calls: 4 }, { key: today, calls: 6 }],++ totals: { calls: 10, input_tokens: 1000, output_tokens: 500, cached_tokens: 100, cache_write_tokens: 0, cost_usd: 0.02, fee_usd: 0, charged_usd: 0.012, saved_usd: 0.003 },++ },++ surface: {++ group: 'surface', source: 'raw', insights: [],++ rows: [{ key: 'ai.complete', calls: 8 }, { key: 'chat', calls: 2 }],++ totals: { calls: 10, input_tokens: 1000, output_tokens: 500, cached_tokens: 100, cache_write_tokens: 0, cost_usd: 0.02, fee_usd: 0, charged_usd: 0.012, saved_usd: 0.003 },++ },++};++++function jsonOf(html, id) {++ const at = html.indexOf(`id="${id}"`);++ assert.ok(at >= 0, `no ${id} panel`);++ const start = html.indexOf('<script type="application/json">', at);++ assert.ok(start >= 0, `no metrics payload in ${id}`);++ const from = start + '<script type="application/json">'.length;++ const end = html.indexOf('</script>', from);++ return JSON.parse(html.slice(from, end));++}++++(async () => {++ // ── The Report client: candidate keys, the five-minute cache, and the person's own Bearer ──++ await check('a performance read asks for AI’s own model key first and keeps it five minutes', async () => {++ const urls = [];++ const report = createReport({++ config: { report: { url: 'https://report.test', timeoutMs: 500, cacheMs: 300000 } },++ now: () => 1000,++ fetchImpl: async (url, init) => {++ urls.push(String(url));++ assert(!new Headers(init.headers).has('authorization'), 'the public read has no token');++ return new Response(JSON.stringify({ group: 'model', rows: [ROW] }), { headers: { 'content-type': 'application/json' } });++ },++ });++ const model = { slug: 'deepseek/deepseek-chat', canonical: 'deepseek/deepseek-chat', offers: [{ model: 'deepseek-chat' }] };++ assert.deepStrictEqual(await report.measuredModel(model), ROW);++ assert.match(urls[0], /group=model&days=30&model=deepseek-chat$/);++ await report.measuredModel(model);++ assert.strictEqual(urls.length, 1, 'the row is cached for five minutes');++++ const missed = [];++ const later = createReport({++ config: { report: { url: 'https://report.test', timeoutMs: 500 } }, now: () => 1000,++ fetchImpl: async (url) => {++ missed.push(String(url));++ return new Response(JSON.stringify({ rows: [] }));++ },++ });++ assert.strictEqual(await later.measuredModel(model), null);++ assert.match(missed[0], /model=deepseek-chat$/);++ assert.match(missed[1], /model=deepseek%2Fdeepseek-chat$/);++ assert.strictEqual(await later.measuredModel(model), null, 'no row is cached too');++ assert.strictEqual(missed.length, 2, 'the slug is tried once and the miss is remembered');++ });++++ await check('a Report failure is null, never a page error, and a person read carries their own Bearer', async () => {++ const report = createReport({++ config: { report: { url: 'https://report.test', timeoutMs: 500 } }, now: () => 1000,++ fetchImpl: async () => { throw new Error('report is down'); },++ });++ assert.strictEqual(await report.measuredModel({ slug: 'deepseek/deepseek-chat', offers: [{ model: 'deepseek-chat' }] }), null);++ const seen = [];++ const asPerson = createReport({++ config: { report: { url: 'https://report.test', timeoutMs: 500 } }, now: () => 1000,++ fetchImpl: async (url, init) => {++ seen.push([String(url), new Headers(init.headers).get('authorization')]);++ const group = new URL(String(url)).searchParams.get('group');++ return new Response(JSON.stringify(group === 'day' ? { ...PERSON.day } : { ...PERSON.surface }));++ },++ });++ const out = await asPerson.personUsage('session-token');++ assert.strictEqual(out.day.totals.calls, 10);++ assert.strictEqual(out.surface.rows.length, 2);++ assert.strictEqual(seen.length, 2);++ assert(seen.every(([url, auth]) => url.startsWith('https://report.test/api/v1/me/ai/usage?group=') && auth === 'Bearer session-token'));++ });++++ // ── The Measured panel on a model page ──++ const ai = createFakeAi();++ const withRow = await boot({ ai, report: { measuredModel: async () => ROW, personUsage: async () => PERSON } });++ try {++ await check('the model page draws the Measured panel from the Report row', async () => {++ const r = await withRow.get('/m/deepseek/deepseek-chat');++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('Measured'), 'the panel is there');++ assert.ok(!r.text.includes(NOT_MEASURED), 'a measured model is not told to wait');++ assert.ok(r.text.includes('p50 540 ms') && r.text.includes('p95 1.3 s'), 'the plain numbers are readable without JavaScript');++ assert.ok(r.text.includes('86.0 tok/s') && r.text.includes('99.4%') && r.text.includes('42.0%'), 'speed, success and cache hit');++ assert.ok(r.text.includes('$0.0042') && r.text.includes('$12.00') && r.text.includes('13k'), 'cost and calls');++ const parts = jsonOf(r.text, 'measured');++ assert.strictEqual(parts[0].kind, 'tiles');++ assert.strictEqual(parts.filter((p) => p.kind === 'percentiles').length, 2, 'TTFT and latency strips');++ assert.ok(r.text.includes('/shared/ov-metrics.js') && r.text.includes('/js/metrics-panel.js'), 'the builders load');++ });++ } finally { await withRow.close(); }++++ const noRow = await boot({ ai, report: { measuredModel: async () => null, personUsage: async () => PERSON } });++ try {++ await check('a model below Report’s floor says it is not measured yet', async () => {++ const r = await noRow.get('/m/deepseek/deepseek-chat');++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes(NOT_MEASURED), 'the exact sentence');++ assert.ok(!r.text.includes('data-ov-metrics'), 'no metrics block is drawn');++ });++ } finally { await noRow.close(); }++++ const down = await boot({ ai, report: { measuredModel: async () => { throw new Error('down'); }, personUsage: async () => PERSON } });++ try {++ await check('Report down leaves the model page whole and says it is not measured yet', async () => {++ const r = await down.get('/m/deepseek/deepseek-chat');++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes(NOT_MEASURED));++ assert.ok(r.text.includes('Every offer that serves it'), 'the rest of the page is still there');++ });++ } finally { await down.close(); }++++ // ── "Your AI usage" on the person's own dashboard ──++ const USAGE = [{ day: today, key_id: 'k1', model: 'deepseek/deepseek-chat', status: 'settled', requests: 2, tokens_in: 100, tokens_out: 20, cost_usd: 0.001 }];++ const personAi = { ...createFakeAi(), meRequest: async (_token, path) => (path.endsWith('key-usage') ? { rows: USAGE } : { keys: [{ id: 'k1', name: 'My app', prefix: 'ovx_1', monthly_cap_usd: 5 }] }) };++ const dashboard = await boot({ ai: personAi, report: { measuredModel: async () => ROW, personUsage: async () => PERSON } });++ try {++ const user = dashboard.network.addUser('alice');++ await check('a signed-in person sees their own AI usage: totals, a call a day and products', async () => {++ const r = await dashboard.get('/dashboard/usage', { as: user });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('Your AI usage'), 'the panel is on their own usage page');++ assert.ok(r.text.includes('1.6k') && r.text.includes('$0.01'), 'tokens and what was charged');++ assert.ok(r.text.includes('By product') && r.text.includes('ai.complete'), 'the product bars');++ const parts = jsonOf(r.text, 'your-ai-usage');++ assert.strictEqual(parts[0].kind, 'tiles');++ assert.strictEqual(parts[1].kind, 'heading');++ assert.strictEqual(parts[2].kind, 'spark');++ assert.strictEqual(parts[2].args[0].length, 30, '30 days, with a measured zero');++ assert.ok(parts[2].args[0].includes(6) && parts[2].args[0].includes(4), 'the calls of the two days');++ assert.strictEqual(parts[3].kind, 'heading');++ assert.strictEqual(parts[4].kind, 'bars');++ assert.ok(r.text.includes('My app'), 'the keys’ own usage stays on the page');++ });++++ await check('Report down on the dashboard says so and leaves the keys’ usage alone', async () => {++ const d = await boot({ ai: personAi, report: { measuredModel: async () => null, personUsage: async () => { throw new Error('down'); } } });++ try {++ const r = await d.get('/dashboard/usage', { as: user });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('Your AI usage is not available right now.'));++ assert.ok(r.text.includes('My app'));++ } finally { await d.close(); }++ });++++ await check('a person with no AI calls is told so, not shown zeros', async () => {++ const zero = { day: { group: 'day', rows: [], totals: { calls: 0, input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, cost_usd: 0, fee_usd: 0, charged_usd: 0, saved_usd: 0 }, insights: [], source: 'raw' }, surface: { group: 'surface', rows: [] } };++ const d = await boot({ ai: personAi, report: { measuredModel: async () => null, personUsage: async () => zero } });++ try {++ const r = await d.get('/dashboard/usage', { as: user });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('No AI calls yet'));++ } finally { await d.close(); }++ });++ } finally { await dashboard.close(); }++++ done();++})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -50,6 +50,13 @@ OV_OAUTH_CLIENT_SECRET=
@@ -50,6 +50,13 @@ OV_OAUTH_CLIENT_SECRET=
#EXCHANGE_AI_TIMEOUT_MS=30000
#EXCHANGE_AI_TIMEOUT_MS=30000
#EXCHANGE_AI_WAIT_MS=25000
#EXCHANGE_AI_WAIT_MS=25000
+# OpenVibe.Report: the public AI performance rows behind a model's Measured panel, and a signed-in person's own
+# AI usage (their Network session token is forwarded; no service token is involved). A public row floor of 20
+# calls in 30 days; the performance rows are cached for five minutes.
+#OV_REPORT_URL=https://openvibe.report
+#EXCHANGE_REPORT_TIMEOUT_MS=2000
+#EXCHANGE_REPORT_CACHE_MS=300000
+
# /openapi.json reads the API host's document and keeps it for ten minutes.
# /openapi.json reads the API host's document and keeps it for ten minutes.
#OV_EXCHANGE_API_URL=https://api.openvibe.exchange
#OV_EXCHANGE_API_URL=https://api.openvibe.exchange
#OV_EXCHANGE_OPENAPI_URL=https://api.openvibe.exchange/openapi.json
#OV_EXCHANGE_OPENAPI_URL=https://api.openvibe.exchange/openapi.json
@@ -4,6 +4,13 @@ What changed in OpenVibe.Exchange, newest first.
@@ -4,6 +4,13 @@ What changed in OpenVibe.Exchange, newest first.
## Unreleased
## Unreleased
+- **Measured, and your own AI usage (2026-11-12).** A model page now has a **Measured** panel from
+ OpenVibe.Report's public performance rows: first token and whole-answer percentiles, tokens a second, success,
+ cache hits, what a call and a million output tokens were charged, and calls in the last 30 days. A model below
+ Report's floor of 20 calls says "Not measured yet". A signed-in person's dashboard Usage adds **Your AI usage**
+ (calls, tokens, charged, a call a day and the products), read from Report with the person's own Network session.
+ Both draw with `openvibe-shared/ov-metrics`; the same numbers stay as plain text without JavaScript.
+
- **The public pages of ai.openvibe.services are here now (2026-11-07).** The old site's home, prices and latency,
- **The public pages of ai.openvibe.services are here now (2026-11-07).** The old site's home, prices and latency,
changelog, legal pages and discovery files answer 301 on an exact page map; every API route there is unchanged.
changelog, legal pages and discovery files answer 301 on an exact page map; every API route there is unchanged.
Three pages carry the content: `/start` (the endpoint, four steps, OpenAI/Anthropic/Link samples, AI's six
Three pages carry the content: `/start` (the endpoint, four steps, OpenAI/Anthropic/Link samples, AI's six
@@ -98,6 +98,14 @@
@@ -98,6 +98,14 @@
.ex-more a { text-decoration: none; }
.ex-more a { text-decoration: none; }
.ex-more a:hover { text-decoration: underline; }
.ex-more a:hover { text-decoration: underline; }
.ex-prose { max-width: 74ch; line-height: 1.65; }
.ex-prose { max-width: 74ch; line-height: 1.65; }
+/* OVMetrics blocks (openvibe-shared/ov-metrics.js): the browser draws tiles, strips, spark and bars; the plain-text
+ fallback is the same numbers for a reader without JavaScript, and is shown until a builder has drawn. */
+.ex-metrics { display: grid; gap: 12px; min-width: 0; }
+.ex-metrics [data-ov-metrics-live][hidden], .ex-metrics .ovm-fallback[hidden] { display: none; }
+.ex-metrics-line { margin: 0; line-height: 1.55; overflow-wrap: anywhere; }
+@media (max-width: 420px) {
+ .ex-metrics .ovm-tiles { grid-template-columns: minmax(0, 1fr); }
+}
.ex-prose code, .ex-wrap code { font-size: .9em; overflow-wrap: anywhere; }
.ex-prose code, .ex-wrap code { font-size: .9em; overflow-wrap: anywhere; }
.ex-h3 { margin: 22px 0 8px; font-size: 1rem; }
.ex-h3 { margin: 22px 0 8px; font-size: 1rem; }
.ex-list { margin: 0 0 12px; padding-left: 1.2em; line-height: 1.65; }
.ex-list { margin: 0 0 12px; padding-left: 1.2em; line-height: 1.65; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+
+/**
+ * Draws every [data-ov-metrics] block with openvibe-shared/ov-metrics (ai-metrics.md, 2026-11-12). The server leaves
+ * the block's parts as JSON and the same numbers as plain text; this script draws the tiles, sparklines, bars and
+ * percentile strips with OVMetrics and only then hides the text. Without JavaScript, or if the script fails, the text
+ * is what a reader sees: no number is lost, and no chart is invented here.
+ */
+(function () {
+ if (typeof OVMetrics === 'undefined') return; // the server's plain numbers stay
+ var FORMATS = {
+ ms: OVMetrics.format.ms,
+ usd: OVMetrics.format.usd,
+ count: OVMetrics.format.count,
+ tokens: OVMetrics.format.tokens,
+ rate: OVMetrics.format.rate,
+ tps: OVMetrics.format.tps,
+ bytes: OVMetrics.format.bytes,
+ delta: OVMetrics.format.delta,
+ };
+
+ function resolved(value) {
+ if (!value || typeof value !== 'object' || typeof value.format !== 'string') return value;
+ var copy = {};
+ for (var key in value) if (Object.prototype.hasOwnProperty.call(value, key)) copy[key] = value[key];
+ copy.format = FORMATS[value.format] || OVMetrics.format.count;
+ return copy;
+ }
+
+ function draw() {
+ var blocks = document.querySelectorAll('[data-ov-metrics]');
+ for (var i = 0; i < blocks.length; i += 1) {
+ var box = blocks[i];
+ var live = box.querySelector('[data-ov-metrics-live]');
+ var data = box.querySelector('script[type="application/json"]');
+ if (!live || !data || typeof OVMetrics === 'undefined') continue;
+ try {
+ var parts = JSON.parse(data.textContent || '[]');
+ var frag = document.createDocumentFragment();
+ for (var j = 0; j < parts.length; j += 1) {
+ var part = parts[j];
+ if (part && part.kind === 'heading' && part.text) {
+ var heading = document.createElement('p');
+ heading.className = 'ovm-label';
+ heading.textContent = String(part.text);
+ frag.append(heading);
+ continue;
+ }
+ if (!part || typeof part.kind !== 'string' || typeof OVMetrics[part.kind] !== 'function') continue;
+ var args = (part.args || []).map(resolved);
+ frag.append(OVMetrics[part.kind].apply(OVMetrics, args));
+ }
+ if (!frag.childNodes.length) continue;
+ live.append(frag);
+ var fallback = box.querySelector('.ovm-fallback');
+ if (fallback) fallback.hidden = true;
+ live.hidden = false;
+ } catch (err) {
+ // The server's plain numbers stay; a metrics block never breaks the page.
+ }
+ }
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', draw);
+ else draw();
+})();
@@ -53,4 +53,4 @@ function createAnalysis({ config, ai, bench, fetchImpl = globalThis.fetch, now =
@@ -53,4 +53,4 @@ function createAnalysis({ config, ai, bench, fetchImpl = globalThis.fetch, now =
}
}
return { model, compare, metrics: source };
return { model, compare, metrics: source };
}
}
-module.exports = { createAnalysis, ratingFor };
+module.exports = { createAnalysis, ratingFor, bare };
@@ -31,6 +31,7 @@ const { createAi } = require('./ai');
@@ -31,6 +31,7 @@ const { createAi } = require('./ai');
const { createActor } = require('./actor');
const { createActor } = require('./actor');
const { createNetwork } = require('./network');
const { createNetwork } = require('./network');
const { createBench } = require('./bench');
const { createBench } = require('./bench');
+const { createReport } = require('./report');
const { createCompare } = require('./compare');
const { createCompare } = require('./compare');
const { createOpenApi } = require('./openapi');
const { createOpenApi } = require('./openapi');
const { createCode } = require('./code');
const { createCode } = require('./code');
@@ -92,6 +93,8 @@ async function createApp(opts = {}) {
@@ -92,6 +93,8 @@ async function createApp(opts = {}) {
// Network, read and changed with the person's own token: their machines and their machine pools.
// Network, read and changed with the person's own token: their machines and their machine pools.
const network = opts.network || createNetwork({ config, fetchImpl, log });
const network = opts.network || createNetwork({ config, fetchImpl, log });
const bench = opts.bench || createBench({ config, fetchImpl: opts.benchFetchImpl || globalThis.fetch, log, now: s.now });
const bench = opts.bench || createBench({ config, fetchImpl: opts.benchFetchImpl || globalThis.fetch, log, now: s.now });
+ // Report's public performance rows (a model's Measured panel) and a person's own AI usage.
+ const report = opts.report || createReport({ config, fetchImpl: opts.reportFetchImpl || globalThis.fetch, log, now: s.now });
// openvibe.compare's engine (server/compare.js): the join of AI's price/speed/privacy/tier with Bench's quality.
// openvibe.compare's engine (server/compare.js): the join of AI's price/speed/privacy/tier with Bench's quality.
const compare = createCompare({ s, ai, bench, config, now: s.now, log });
const compare = createCompare({ s, ai, bench, config, now: s.now, log });
const analysis = opts.analysis || require('./analysis').createAnalysis({ config, ai, bench, fetchImpl: opts.aiFetchImpl || globalThis.fetch, now: s.now });
const analysis = opts.analysis || require('./analysis').createAnalysis({ config, ai, bench, fetchImpl: opts.aiFetchImpl || globalThis.fetch, now: s.now });
@@ -131,7 +134,7 @@ async function createApp(opts = {}) {
@@ -131,7 +134,7 @@ async function createApp(opts = {}) {
const code = opts.code || createCode({
const code = opts.code || createCode({
config, fetchImpl: opts.sourcesFetchImpl || globalThis.fetch, tokenFetch: fetchImpl, now: opts.now || (() => Date.now()), log,
config, fetchImpl: opts.sourcesFetchImpl || globalThis.fetch, tokenFetch: fetchImpl, now: opts.now || (() => Date.now()), log,
});
});
- const ctx = { config, s, keys, sso, principal, ai, actor, network, bench, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, code, log };
+ const ctx = { config, s, keys, sso, principal, ai, actor, network, bench, report, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, code, log };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -99,6 +99,15 @@ function load(env = process.env) {
@@ -99,6 +99,15 @@ function load(env = process.env) {
pollMs: Math.max(500, int(env.EXCHANGE_ACTOR_POLL_MS, 2000)),
pollMs: Math.max(500, int(env.EXCHANGE_ACTOR_POLL_MS, 2000)),
},
},
+ // OpenVibe.Report (server/report.js): the public AI performance rows behind a model's Measured panel, and a
+ // signed-in person's own AI usage. A short timeout: Report is a read for the page, never a reason to wait.
+ // Performance rows keep for five minutes; a person's rows are private and never cached.
+ report: {
+ url: trim(env.OV_REPORT_URL || 'https://openvibe.report'),
+ timeoutMs: Math.max(250, int(env.EXCHANGE_REPORT_TIMEOUT_MS, 2000)),
+ cacheMs: Math.max(10_000, int(env.EXCHANGE_REPORT_CACHE_MS, 300_000)),
+ },
+
// OpenVibe.Bench (server/bench.js): the Arena's rounds and each person's vote are a Bench
// OpenVibe.Bench (server/bench.js): the Arena's rounds and each person's vote are a Bench
// human_pairwise judgement, and the rankings read the board by task category. The token is this
// human_pairwise judgement, and the rankings read the board by task category. The token is this
// service's own client-credentials grant (bench.round.create, bench.judgement.cast, bench.board.read).
// service's own client-credentials grant (bench.round.create, bench.judgement.cast, bench.board.read).
@@ -4,7 +4,8 @@ const { raw } = require('../render/html');
@@ -4,7 +4,8 @@ const { raw } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
const views = require('../views/dashboard');
const views = require('../views/dashboard');
-function createDashboardRoutes({ config, ai }) {
+const metricsView = require('../views/metrics');
+function createDashboardRoutes({ config, ai, report }) {
const r = express.Router();
const r = express.Router();
r.use(express.urlencoded({ extended: false, limit: '32kb' }));
r.use(express.urlencoded({ extended: false, limit: '32kb' }));
const render = (req, res, tab, html, status = 200) =>
const render = (req, res, tab, html, status = 200) =>
@@ -14,6 +15,7 @@ function createDashboardRoutes({ config, ai }) {
@@ -14,6 +15,7 @@ function createDashboardRoutes({ config, ai }) {
path: req.originalUrl,
path: req.originalUrl,
index: false,
index: false,
title: 'Dashboard · ' + tab,
title: 'Dashboard · ' + tab,
+ head: tab === 'Usage' ? metricsView.HEAD : '',
body: raw(html),
body: raw(html),
});
});
r.use('/dashboard', (req, res, next) => {
r.use('/dashboard', (req, res, next) => {
@@ -78,18 +80,17 @@ function createDashboardRoutes({ config, ai }) {
@@ -78,18 +80,17 @@ function createDashboardRoutes({ config, ai }) {
safe(async (req, res) => {
safe(async (req, res) => {
const data = await call(req, '/api/v1/exchange/key-usage');
const data = await call(req, '/api/v1/exchange/key-usage');
const rows = data.rows || [];
const rows = data.rows || [];
- const keyList = req.path.endsWith('.csv') ? null : await call(req, '/api/v1/exchange/keys').catch(() => ({ keys: [] }));
- const columns = [
- 'day',
- 'key_id',
- 'model',
- 'requests',
- 'tokens_in',
- 'tokens_out',
- 'cost_usd',
- 'status',
- ];
if (req.path.endsWith('.csv')) {
if (req.path.endsWith('.csv')) {
+ const columns = [
+ 'day',
+ 'key_id',
+ 'model',
+ 'requests',
+ 'tokens_in',
+ 'tokens_out',
+ 'cost_usd',
+ 'status',
+ ];
const cell = (v) =>
const cell = (v) =>
'"' +
'"' +
String(v ?? '')
String(v ?? '')
@@ -111,7 +112,12 @@ function createDashboardRoutes({ config, ai }) {
@@ -111,7 +112,12 @@ function createDashboardRoutes({ config, ai }) {
].join('\n'),
].join('\n'),
);
);
}
}
- render(req, res, 'Usage', views.usagePage(rows, (keyList && keyList.keys) || []));
+ // The person's own AI usage is a separate read: Report failing leaves the keys' usage untouched.
+ const [keyList, aiUsage] = await Promise.all([
+ call(req, '/api/v1/exchange/keys').catch(() => ({ keys: [] })),
+ report ? report.personUsage(req.viewer.token).catch(() => null) : null,
+ ]);
+ render(req, res, 'Usage', views.usagePage(rows, (keyList && keyList.keys) || [], aiUsage));
}),
}),
);
);
r.get('/dashboard/credits', (req, res) => render(req, res, 'Credits', views.creditsPage()));
r.get('/dashboard/credits', (req, res) => render(req, res, 'Credits', views.creditsPage()));
@@ -27,6 +27,7 @@ const { esc, raw } = require('../render/html');
@@ -27,6 +27,7 @@ const { esc, raw } = require('../render/html');
const marks = require('../views/marks');
const marks = require('../views/marks');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const views = require('../views/exchange');
const views = require('../views/exchange');
+const metricsView = require('../views/metrics');
const platform = require('../views/platform');
const platform = require('../views/platform');
const decisionsView = require('../views/decisions');
const decisionsView = require('../views/decisions');
const credentialViews = require('../views/credentials');
const credentialViews = require('../views/credentials');
@@ -177,9 +178,15 @@ function createPageRoutes(ctx) {
@@ -177,9 +178,15 @@ function createPageRoutes(ctx) {
return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such model', text: `Nothing here serves ${esc(`${req.params.org}/${req.params.model}`)}. <a href="/models">Browse the models</a>.` })}</div>`) }, 404);
return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such model', text: `Nothing here serves ${esc(`${req.params.org}/${req.params.model}`)}. <a href="/models">Browse the models</a>.` })}</div>`) }, 404);
}
}
const isAdult = signedIn(req) ? await adult.confirmed(owner(req)) : false;
const isAdult = signedIn(req) ? await adult.confirmed(owner(req)) : false;
- let analysis = null;
- try { analysis = await ctx.analysis.model(model, { rating_kind: typeof req.query.rating_kind === 'string' ? req.query.rating_kind : 'chat.arena' }); }
- catch (err) { log.warn('[exchange] model analysis unavailable: ' + String(err.message || err).slice(0, 160)); }
+ // The model's measured row and its Bench analysis are reads beside the page: each failing leaves its panel
+ // saying so, never a 500 page. Report's row is cached for five minutes inside server/report.js, and the two
+ // reads run together so neither waits on the other.
+ const [analysis, measured] = await Promise.all([
+ ctx.analysis.model(model, { rating_kind: typeof req.query.rating_kind === 'string' ? req.query.rating_kind : 'chat.arena' })
+ .catch((err) => { log.warn('[exchange] model analysis unavailable: ' + String(err.message || err).slice(0, 160)); return null; }),
+ (ctx.report ? ctx.report.measuredModel(model) : Promise.resolve(null))
+ .catch((err) => { log.warn('[exchange] Report performance unavailable: ' + String(err.message || err).slice(0, 160)); return null; }),
+ ]);
const jsonLd = seo.jsonLd.webPage({
const jsonLd = seo.jsonLd.webPage({
name: `${model.name} on OpenVibe.Exchange`, url: `${config.baseUrl}${modelPath(model)}`,
name: `${model.name} on OpenVibe.Exchange`, url: `${config.baseUrl}${modelPath(model)}`,
description: `${model.name}: ${model.offers.length} live offers with price, speed and uptime.`.slice(0, 160), siteUrl: config.baseUrl,
description: `${model.name}: ${model.offers.length} live offers with price, speed and uptime.`.slice(0, 160), siteUrl: config.baseUrl,
@@ -189,8 +196,8 @@ function createPageRoutes(ctx) {
@@ -189,8 +196,8 @@ function createPageRoutes(ctx) {
title: model.name, description: `${model.name}: price, speed, uptime and data policy of every offer.`,
title: model.name, description: `${model.name}: price, speed, uptime and data policy of every offer.`,
jsonLd: [jsonLd, { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: model.name,
jsonLd: [jsonLd, { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: model.name,
url: `${config.baseUrl}${modelPath(model)}`, description: `${model.name}: context, model limits, price and available providers.`,
url: `${config.baseUrl}${modelPath(model)}`, description: `${model.name}: context, model limits, price and available providers.`,
- applicationCategory: 'DeveloperApplication' }], head: '',
- body: raw(views.modelPage({ model, viewer: req.viewer, result, problem, text, adult: isAdult, community, analysis })),
+ applicationCategory: 'DeveloperApplication' }], head: metricsView.HEAD,
+ body: raw(views.modelPage({ model, viewer: req.viewer, result, problem, text, adult: isAdult, community, analysis, measured })),
}, status);
}, status);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+
+/**
+ * OpenVibe.Report reads (ai-metrics.md, 2026-11-12):
+ *
+ * GET /api/v1/ai/performance?group=model&days=30&model=<key> the public row behind a model's Measured panel;
+ * at least 20 calls in 30 days, no person, no prompt.
+ * GET /api/v1/me/ai/usage?group=day|surface the signed-in person's own rows, with the person's
+ * Network session (the same token Exchange forwards
+ * to AI and Network), never a service token.
+ *
+ * Report is a read for the page, never a reason for it to fail: a timeout, a refusal or a row that does not match
+ * answers null, and the page says "not measured yet" or "not available right now". Performance rows are cached for
+ * five minutes; a person's rows are private and never cached.
+ *
+ * The model Report records is AI's own model key (the provider's model id, e.g. `deepseek-chat`), while a catalog
+ * model is `deepseek/deepseek-chat`. The candidates are tried in that order and compared with the same `bare()`
+ * rule the Bench rating uses, so a dated revision and a provider prefix do not hide the row.
+ */
+const { bare } = require('./analysis');
+
+const PERFORMANCE_DAYS = 30;
+const NOT_MEASURED = 'Not measured yet: a model is measured after 20 calls in 30 days.';
+
+function createReport({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const report = (config && config.report) || {};
+ const base = String(report.url || 'https://openvibe.report').replace(/\/+$/, '');
+ const timeoutMs = Math.max(250, Number(report.timeoutMs) || 2000);
+ const cacheMs = Math.max(1000, Number(report.cacheMs) || 300000);
+ const cache = new Map();
+
+ /** One GET, JSON, short; a non-2xx is an error with its status so a caller can tell "down" from "no row". */
+ async function read(path, { token = null } = {}) {
+ const headers = { accept: 'application/json' };
+ if (token) headers.authorization = `Bearer ${token}`;
+ const res = await fetchImpl(`${base}${path}`, { headers, signal: AbortSignal.timeout(timeoutMs) });
+ if (!res || !res.ok) {
+ const err = new Error(`OpenVibe.Report answered ${res ? res.status : 'nothing'}`);
+ err.status = res ? res.status : 0;
+ throw err;
+ }
+ return res.json();
+ }
+
+ /** The catalog ids a Report row for this model may carry, likeliest first: AI's model key, then the slug. */
+ function candidatesOf(model) {
+ const offers = Array.isArray(model && model.offers) ? model.offers : [];
+ const ids = [...offers.map((o) => o && o.model), ...offers.map((o) => o && o.id), model && model.slug, model && model.canonical, model && model.id]
+ .filter((v) => typeof v === 'string' && v.trim())
+ .map((v) => v.trim());
+ return [...new Set(ids)];
+ }
+
+ /** The row of one candidate, or null when Report has none. A 5xx or a network failure stops the search. */
+ async function rowFor(candidate) {
+ const path = `/api/v1/ai/performance?group=model&days=${PERFORMANCE_DAYS}&model=${encodeURIComponent(candidate)}`;
+ let body;
+ try {
+ body = await read(path);
+ } catch (err) {
+ if (err && err.status && err.status < 500) return { row: null, stop: true, err };
+ if (log && log.warn) log.warn('[OpenVibe.Exchange] the Report performance read failed: ' + String((err && err.message) || err).slice(0, 160));
+ return { row: null, stop: true, err };
+ }
+ const rows = body && Array.isArray(body.rows) ? body.rows : [];
+ if (!rows.length) return { row: null, stop: false };
+ const exact = rows.find((r) => r && r.key === candidate);
+ const loose = rows.find((r) => r && bare(r.key) === bare(candidate));
+ return { row: exact || loose || null, stop: true };
+ }
+
+ /**
+ * The public performance row for one catalog model, or null when Report knows no row yet. The whole answer,
+ * including "no row", is cached for five minutes so a page view does not ask Report again.
+ */
+ async function measuredModel(model) {
+ if (!model || !model.slug) return null;
+ const key = `model:${model.slug}`;
+ const hit = cache.get(key);
+ if (hit && hit.until > now()) return hit.value;
+ let found = null;
+ for (const candidate of candidatesOf(model)) {
+ const out = await rowFor(candidate);
+ if (out.row) { found = out.row; break; }
+ if (out.stop) break;
+ }
+ cache.set(key, { until: now() + cacheMs, value: found });
+ if (cache.size > 512) cache.delete(cache.keys().next().value);
+ return found;
+ }
+
+ /**
+ * The signed-in person's own AI usage: by day (the totals and the spark) and by surface (the bars). The
+ * person's Network session token is forwarded as their Bearer; Report decides who that is, never the query.
+ * Each read is independent: one failing leaves the other's numbers on the page. Nothing is cached.
+ */
+ async function personUsage(token, { from = null } = {}) {
+ if (!token) return null;
+ const query = (group) => `/api/v1/me/ai/usage?group=${group}${from ? `&from=${encodeURIComponent(from)}` : ''}`;
+ const [day, surface] = await Promise.all([
+ read(query('day'), { token }).catch((err) => {
+ if (log && log.warn) log.warn('[OpenVibe.Exchange] the Report day usage read failed: ' + String((err && err.message) || err).slice(0, 160));
+ return null;
+ }),
+ read(query('surface'), { token }).catch((err) => {
+ if (log && log.warn) log.warn('[OpenVibe.Exchange] the Report surface usage read failed: ' + String((err && err.message) || err).slice(0, 160));
+ return null;
+ }),
+ ]);
+ if (!day && !surface) return null;
+ return { day: day && day.totals ? day : null, surface: surface && Array.isArray(surface.rows) ? surface : null };
+ }
+
+ return { measuredModel, personUsage, candidatesOf, NOT_MEASURED };
+}
+
+module.exports = { createReport, NOT_MEASURED, PERFORMANCE_DAYS };
@@ -7,6 +7,8 @@
@@ -7,6 +7,8 @@
*/
*/
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const { esc } = require('../render/html');
const { esc } = require('../render/html');
+const metrics = require('./metrics');
+const f = metrics.format;
const TABS = [['keys', 'Keys'], ['usage', 'Usage'], ['credits', 'Credits'], ['deployments', 'Deployments'], ['settings', 'Settings']];
const TABS = [['keys', 'Keys'], ['usage', 'Usage'], ['credits', 'Credits'], ['deployments', 'Deployments'], ['settings', 'Settings']];
const money = (v) => {
const money = (v) => {
@@ -72,8 +74,70 @@ function newKeyPage(secret, key = null) {
@@ -72,8 +74,70 @@ function newKeyPage(secret, key = null) {
+ `${kit.snippet({ code: secret, id: 'new-key' })}<p class="xd-actions">${kit.button({ href: '/dashboard/keys', label: 'Done' })}</p></section>`);
+ `${kit.snippet({ code: secret, id: 'new-key' })}<p class="xd-actions">${kit.button({ href: '/dashboard/keys', label: 'Done' })}</p></section>`);
}
}
+/**
+ * "Your AI usage", for the signed-in person: Report's own answer for their session — total calls, tokens and what
+ * was charged, a call a day, and the products the calls came from. It is a panel of its own above the API keys'
+ * usage, and a Report failure says so without touching the rest of the page. Every number is OVMetrics' words
+ * (server/views/metrics.js); the plain-text fallback is what a reader without JavaScript sees.
+ */
+function aiUsagePanel(usage) {
+ const title = 'Your AI usage';
+ const note = 'Every AI call on your account in the last 30 days, from OpenVibe.Report; no prompt and no answer.';
+ const card = (body) => `<section class="xd-card" id="your-ai-usage"><h2>${title}</h2><p class="xd-note">${note}</p>${body}</section>`;
+ if (!usage || !usage.day || !usage.day.totals) return card(kit.notice({ kind: 'info', text: 'Your AI usage is not available right now.' }));
+ const totals = usage.day.totals;
+ if (!Number(totals.calls)) return card(kit.empty({ title: 'No AI calls yet', text: 'Calls you make on any OpenVibe product show here, with their tokens and cost.' }));
+
+ const number = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v));
+ const input = number(totals.input_tokens);
+ const cached = number(totals.cached_tokens);
+ const cacheWrite = number(totals.cache_write_tokens);
+ const output = number(totals.output_tokens);
+ const charged = number(totals.charged_usd);
+ const saved = number(totals.saved_usd);
+ const list = number(totals.cost_usd);
+ const tokens = [input, cached, cacheWrite, output].every((n) => n != null) ? input + cached + cacheWrite + output : null;
+ const tokensIn = [input, cached, cacheWrite].every((n) => n != null) ? input + cached + cacheWrite : null;
+ const tiles = [
+ { label: 'Calls', value: f.count(totals.calls) },
+ { label: 'Tokens', value: f.count(tokens), sub: tokensIn != null && output != null ? `${f.count(tokensIn)} in · ${f.count(output)} out` : '' },
+ { label: 'Charged', value: f.usd(charged), sub: saved != null && saved > 0 ? `${f.usd(saved)} saved by cache` : list != null ? `${f.usd(list)} at list` : '' },
+ ];
+
+ // The last 30 UTC days, with a measured zero on a day Report has no row: a day with no calls is not unknown.
+ const dayCalls = new Map((usage.day.rows || []).map((r) => [String(r.key).slice(0, 10), Number(r.calls) || 0]));
+ const today = new Date();
+ const end = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
+ const days = [];
+ const callsPerDay = [];
+ for (let i = 29; i >= 0; i -= 1) {
+ const d = new Date(end - i * 86400000).toISOString().slice(0, 10);
+ days.push(d);
+ callsPerDay.push(dayCalls.get(d) || 0);
+ }
+
+ const parts = [
+ { kind: 'tiles', args: [tiles] },
+ { kind: 'heading', text: 'Calls a day, last 30 days' },
+ { kind: 'spark', args: [callsPerDay, { label: 'AI calls a day, last 30 days' }] },
+ ];
+ const byDay = days.filter((d) => dayCalls.get(d)).map((d) => `${esc(d)} ${f.count(dayCalls.get(d))}`);
+ const tokensText = tokens == null ? f.count(null) : `${f.count(tokens)}${tokensIn != null && output != null ? ` (${f.count(tokensIn)} in · ${f.count(output)} out)` : ''}`;
+ let fallback = `<p class="ex-metrics-line"><b>Calls</b> ${f.count(totals.calls)} · <b>Tokens</b> ${tokensText} · <b>Charged</b> ${f.usd(charged)}${saved != null && saved > 0 ? ` (${f.usd(saved)} saved by cache)` : ''}</p>`;
+ fallback += `<p class="ex-metrics-line"><b>Calls a day</b> ${byDay.length ? byDay.join(' · ') : 'none in the last 30 days'}</p>`;
+
+ const surfaces = ((usage.surface && usage.surface.rows) || []).filter((r) => Number(r.calls) > 0).slice(0, 8);
+ if (surfaces.length) {
+ const rows = surfaces.map((r) => ({ label: !r.key || r.key === 'unknown' ? 'Not said' : String(r.key).slice(0, 80), value: Number(r.calls) || 0 }));
+ parts.push({ kind: 'heading', text: 'By product' });
+ parts.push({ kind: 'bars', args: [rows, { format: 'count' }] });
+ fallback += `<p class="ex-metrics-line"><b>By product</b> ${rows.map((r) => `${esc(r.label)} ${f.count(r.value)} calls`).join(' · ')}</p>`;
+ }
+ return card(metrics.block(parts, fallback));
+}
+
/** Usage rows from the API (by day, key, model and status) with key names; the newest day first. */
/** Usage rows from the API (by day, key, model and status) with key names; the newest day first. */
-function usagePage(rows = [], keys = []) {
+function usagePage(rows = [], keys = [], aiUsage = null) {
const names = new Map(keys.map((k) => [k.id, k.name]));
const names = new Map(keys.map((k) => [k.id, k.name]));
const totals = rows.reduce((t, r) => ({
const totals = rows.reduce((t, r) => ({
cost: t.cost + (Number(r.cost_usd) || 0), requests: t.requests + (Number(r.requests) || 0),
cost: t.cost + (Number(r.cost_usd) || 0), requests: t.requests + (Number(r.requests) || 0),
@@ -81,7 +145,8 @@ function usagePage(rows = [], keys = []) {
@@ -81,7 +145,8 @@ function usagePage(rows = [], keys = []) {
}), { cost: 0, requests: 0, tokens: 0 });
}), { cost: 0, requests: 0, tokens: 0 });
const days = [...new Set(rows.map((r) => String(r.day).slice(0, 10)))].sort();
const days = [...new Set(rows.map((r) => String(r.day).slice(0, 10)))].sort();
const head = `<div class="xd-title"><h2>Usage</h2>${rows.length ? `<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/dashboard/usage.csv" download><span>CSV</span></a>` : ''}</div>`;
const head = `<div class="xd-title"><h2>Usage</h2>${rows.length ? `<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/dashboard/usage.csv" download><span>CSV</span></a>` : ''}</div>`;
- if (!rows.length) return shell('usage', `<section class="xd-card">${head}${kit.empty({ title: 'No usage yet', text: 'Calls made with your keys show here by day, key and model, with their tokens and cost.' })}</section>`);
+ const ai = aiUsagePanel(aiUsage);
+ if (!rows.length) return shell('usage', `${ai}<section class="xd-card">${head}${kit.empty({ title: 'No usage yet', text: 'Calls made with your keys show here by day, key and model, with their tokens and cost.' })}</section>`);
const span = days.length > 1 ? `${day(days[0])} – ${day(days[days.length - 1])}` : day(days[0]);
const span = days.length > 1 ? `${day(days[0])} – ${day(days[days.length - 1])}` : day(days[0]);
const stat = (v, label) => `<div><dd>${esc(v)}</dd><dt>${esc(label)}</dt></div>`;
const stat = (v, label) => `<div><dd>${esc(v)}</dd><dt>${esc(label)}</dt></div>`;
// Requests per day, the last 14 days that have any: bars scaled to the busiest one (the rows below hold the numbers).
// Requests per day, the last 14 days that have any: bars scaled to the busiest one (the rows below hold the numbers).
@@ -100,7 +165,7 @@ function usagePage(rows = [], keys = []) {
@@ -100,7 +165,7 @@ function usagePage(rows = [], keys = []) {
}).join('');
}).join('');
return `<h3 class="xd-day">${esc(day(d))}</h3><ul class="xd-use">${items}</ul>`;
return `<h3 class="xd-day">${esc(day(d))}</h3><ul class="xd-use">${items}</ul>`;
}).join('');
}).join('');
- return shell('usage', `<section class="xd-card">${head}<p class="xd-note">${esc(span)}</p>`
+ return shell('usage', `${ai}<section class="xd-card">${head}<p class="xd-note">${esc(span)}</p>`
+ `<dl class="xd-stats">${stat(money(totals.cost), 'spent')}${stat(count(totals.requests), 'requests')}${stat(count(totals.tokens), 'tokens')}</dl>`
+ `<dl class="xd-stats">${stat(money(totals.cost), 'spent')}${stat(count(totals.requests), 'requests')}${stat(count(totals.tokens), 'tokens')}</dl>`
+ `${bars}</section><section class="xd-card"><h2 class="xd-sub">By day, key and model</h2>${byDay}</section>`);
+ `${bars}</section><section class="xd-card"><h2 class="xd-sub">By day, key and model</h2>${byDay}</section>`);
}
}
@@ -9,6 +9,8 @@
@@ -9,6 +9,8 @@
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const { esc } = require('../render/html');
const { esc } = require('../render/html');
const { mark, product, productMark } = require('./marks');
const { mark, product, productMark } = require('./marks');
+const metrics = require('./metrics');
+const { NOT_MEASURED } = require('../report');
const ENDPOINT = 'https://api.openvibe.exchange/v1/chat/completions';
const ENDPOINT = 'https://api.openvibe.exchange/v1/chat/completions';
const CHAT = 'https://openvibe.chat/vibe';
const CHAT = 'https://openvibe.chat/vibe';
@@ -326,7 +328,42 @@ ${communityPools(community)}
@@ -326,7 +328,42 @@ ${communityPools(community)}
}
}
// ── One model ──────────────────────────────────────────────
// ── One model ──────────────────────────────────────────────
-function modelPage({ model, viewer, result = null, problem = null, text = '', adult = false, community = null, analysis = null }) {
+const f = metrics.format;
+
+/**
+ * What real calls measured on this model in the last 30 days, from Report's public performance row: the first
+ * token and the whole answer's percentiles, speed, success, cache hits and what a call was charged. A model below
+ * Report's floor (20 calls in 30 days) has no row, and a Report failure looks the same: said plainly, never a zero.
+ */
+function measuredPanel(row) {
+ const title = 'Measured';
+ const note = 'From OpenVibe.Report: real calls in the last 30 days, with no prompt and no person in them.';
+ if (!row) return panel({ title, note, id: 'measured', body: kit.notice({ kind: 'info', text: NOT_MEASURED }) });
+ const ttft = row.ttft_ms || {};
+ const latency = row.latency_ms || {};
+ const tps = row.tps || {};
+ const cost = row.cost || {};
+ const tiles = [
+ { label: 'Calls, 30 days', value: f.count(row.calls) },
+ { label: 'First token p50', value: f.ms(ttft.p50), sub: `p95 ${f.ms(ttft.p95)}` },
+ { label: 'Output speed p50', value: f.tps(tps.p50) },
+ { label: 'Success', value: f.rate(row.success_rate) },
+ { label: 'Cache hit', value: f.rate(row.cache_hit_rate) },
+ { label: 'Charged per call', value: f.usd(cost.per_call_usd) },
+ { label: 'Charged per 1M output', value: f.usd(cost.per_million_output_usd) },
+ ];
+ const parts = [
+ { kind: 'tiles', args: [tiles] },
+ { kind: 'percentiles', args: [ttft, { unit: 'ms', label: 'Time to first token' }] },
+ { kind: 'percentiles', args: [latency, { unit: 'ms', label: 'Latency, whole answer' }] },
+ ];
+ const fallback = `<p class="ex-metrics-line"><b>Calls, 30 days</b> ${f.count(row.calls)} · <b>First token</b> p50 ${f.ms(ttft.p50)}, p95 ${f.ms(ttft.p95)} · <b>Output speed p50</b> ${f.tps(tps.p50)}</p>
+<p class="ex-metrics-line"><b>Success</b> ${f.rate(row.success_rate)} · <b>Cache hit</b> ${f.rate(row.cache_hit_rate)} · <b>Charged</b> ${f.usd(cost.per_call_usd)} a call, ${f.usd(cost.per_million_output_usd)} per 1M output tokens</p>
+<p class="ex-metrics-line"><b>Time to first token</b> p50 ${f.ms(ttft.p50)} · p95 ${f.ms(ttft.p95)} (${f.count(ttft.n)} samples) · <b>Latency, whole answer</b> p50 ${f.ms(latency.p50)} · p95 ${f.ms(latency.p95)} (${f.count(latency.n)} samples)</p>`;
+ return panel({ title, note, id: 'measured', body: metrics.block(parts, fallback) });
+}
+
+function modelPage({ model, viewer, result = null, problem = null, text = '', adult = false, community = null, analysis = null, measured = null }) {
const offers = model.offers || [];
const offers = model.offers || [];
const best = cheapest(model);
const best = cheapest(model);
const badges = [model.uncensored ? kit.badge('18+', 'bad') : '', model.community ? kit.badge('Community', 'accent') : '', offers.some(freeTier) ? kit.badge('Free to try') : (offers.some(freePaused) ? kit.badge('Free paused') : '')].join('');
const badges = [model.uncensored ? kit.badge('18+', 'bad') : '', model.community ? kit.badge('Community', 'accent') : '', offers.some(freeTier) ? kit.badge('Free to try') : (offers.some(freePaused) ? kit.badge('Free paused') : '')].join('');
@@ -383,6 +420,7 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
@@ -383,6 +420,7 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
${kit.head({ title: model.name, sub: `${familyLabel(model)}${model.variant ? ` · ${model.variant}` : ''} · ${offers.length} offer${offers.length === 1 ? '' : 's'}`, back: { href: '/models', label: 'Models' }, actions: badges ? [badges] : [] })}
${kit.head({ title: model.name, sub: `${familyLabel(model)}${model.variant ? ` · ${model.variant}` : ''} · ${offers.length} offer${offers.length === 1 ? '' : 's'}`, back: { href: '/models', label: 'Models' }, actions: badges ? [badges] : [] })}
${stats.length ? kit.stats(stats) : ''}
${stats.length ? kit.stats(stats) : ''}
${model.description ? `<p class="ex-prose">${esc(model.description)}</p>` : ''}
${model.description ? `<p class="ex-prose">${esc(model.description)}</p>` : ''}
+${measuredPanel(measured)}
${panel({ title: 'Every offer that serves it', note: 'Price in, out and cached per million tokens; speed and uptime from the last seven days of real requests; the privacy label and the provider’s own data policy.', id: 'offers', body: kit.table({ columns, rows, label: `Offers for ${model.name}`, empty: 'No live offer serves this model right now.' }) })}
${panel({ title: 'Every offer that serves it', note: 'Price in, out and cached per million tokens; speed and uptime from the last seven days of real requests; the privacy label and the provider’s own data policy.', id: 'offers', body: kit.table({ columns, rows, label: `Offers for ${model.name}`, empty: 'No live offer serves this model right now.' }) })}
<p class="ex-more"><a href="https://openvibe.compare/things/${encodeURIComponent(model.slug)}">Compare ${esc(model.name)} with another model →</a></p>
<p class="ex-more"><a href="https://openvibe.compare/things/${encodeURIComponent(model.slug)}">Compare ${esc(model.name)} with another model →</a></p>
${activityPanel(model.activity)}
${activityPanel(model.activity)}
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+
+/**
+ * The one way Exchange shows numbers (ai-metrics.md, 2026-11-12): openvibe-shared/ov-metrics. The builders are the
+ * browser's (tiles, sparklines, bars, percentile strips); this module carries their data to the page and leaves the
+ * server fallback — the same numbers in plain text, with no chart or tile of our own — for a reader without
+ * JavaScript. Every word comes from OVMetrics.format in Node, so both renderings say the same thing.
+ */
+const ovServe = require('openvibe-shared/serve');
+const M = require('openvibe-shared/ov-metrics');
+const { assetVersion } = require('../render/layout');
+
+const HEAD = `<script src="${ovServe.url('ov-metrics.js')}" defer></script><script src="/js/metrics-panel.js?v=${assetVersion('js/metrics-panel.js')}" defer></script>`;
+
+/** The data as a script that cannot close its own tag, and cannot be read as markup. */
+function json(parts) {
+ return JSON.stringify(parts).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
+}
+
+/**
+ * A metrics block: the browser draws `parts` (each `{ kind, args }`, called as OVMetrics[kind](...args)), and the
+ * server's `fallback` HTML is what a reader without JavaScript sees. The renderer hides the fallback only after a
+ * builder has drawn, so a broken script leaves the numbers readable.
+ */
+function block(parts, fallback) {
+ return `<div class="ex-metrics" data-ov-metrics><div class="ovm-fallback">${fallback}</div><div data-ov-metrics-live hidden></div><script type="application/json">${json(parts)}</script></div>`;
+}
+
+module.exports = { HEAD, block, format: M.format };
@@ -45,7 +45,9 @@ async function boot(opts = {}) {
@@ -45,7 +45,9 @@ async function boot(opts = {}) {
// no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).
// no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).
const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });
const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });
const emptyRatings = async () => new Response(JSON.stringify({ board: { minimum_samples: 20, rows: [] } }), { headers: { 'content-type': 'application/json' } });
const emptyRatings = async () => new Response(JSON.stringify({ board: { minimum_samples: 20, rows: [] } }), { headers: { 'content-type': 'application/json' } });
- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, actor: opts.actor, actorFetchImpl: opts.actorFetchImpl, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl, code: opts.code, sourcesFetchImpl: opts.sourcesFetchImpl });
+ // No test reaches OpenVibe.Report unless it hands its own client (or a fetch for the real client's tests).
+ const report = opts.report || (opts.reportFetchImpl ? undefined : { measuredModel: async () => null, personUsage: async () => null });
+ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, actor: opts.actor, actorFetchImpl: opts.actorFetchImpl, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, report, reportFetchImpl: opts.reportFetchImpl, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl, code: opts.code, sourcesFetchImpl: opts.sourcesFetchImpl });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
+'use strict';
+/**
+ * The Measured panel on a model page and "Your AI usage" on a signed-in person's dashboard (ai-metrics.md).
+ *
+ * Both are OpenVibe.Report reads: a public performance row per model, and the person's own rows with the Network
+ * session Exchange already holds. The pages are server-rendered and carry the same numbers as plain text for a
+ * reader without JavaScript; the OVMetrics builders draw the tiles, strips, spark and bars in the browser.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { createFakeAi } = require('./helpers/ai');
+const { createReport, NOT_MEASURED } = require('../server/report');
+
+const today = new Date().toISOString().slice(0, 10);
+const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
+
+const ROW = {
+ group: 'model', key: 'deepseek-chat', calls: 13080,
+ success_rate: 0.9941, cache_hit_rate: 0.42,
+ ttft_ms: { p50: 540, p90: 980, p95: 1310, n: 13080 },
+ latency_ms: { p50: 1200, p90: 2600, p95: 3400, n: 13080 },
+ tps: { p50: 86, p90: 120, p95: 133, n: 13080 },
+ tokens: { input: 130000, output: 54000 },
+ cost: { charged_usd: 61.2, per_call_usd: 0.0042, per_million_output_usd: 12 },
+};
+
+const PERSON = {
+ day: {
+ group: 'day', source: 'raw', insights: [],
+ rows: [{ key: yesterday, calls: 4 }, { key: today, calls: 6 }],
+ totals: { calls: 10, input_tokens: 1000, output_tokens: 500, cached_tokens: 100, cache_write_tokens: 0, cost_usd: 0.02, fee_usd: 0, charged_usd: 0.012, saved_usd: 0.003 },
+ },
+ surface: {
+ group: 'surface', source: 'raw', insights: [],
+ rows: [{ key: 'ai.complete', calls: 8 }, { key: 'chat', calls: 2 }],
+ totals: { calls: 10, input_tokens: 1000, output_tokens: 500, cached_tokens: 100, cache_write_tokens: 0, cost_usd: 0.02, fee_usd: 0, charged_usd: 0.012, saved_usd: 0.003 },
+ },
+};
+
+function jsonOf(html, id) {
+ const at = html.indexOf(`id="${id}"`);
+ assert.ok(at >= 0, `no ${id} panel`);
+ const start = html.indexOf('<script type="application/json">', at);
+ assert.ok(start >= 0, `no metrics payload in ${id}`);
+ const from = start + '<script type="application/json">'.length;
+ const end = html.indexOf('</script>', from);
+ return JSON.parse(html.slice(from, end));
+}
+
+(async () => {
+ // ── The Report client: candidate keys, the five-minute cache, and the person's own Bearer ──
+ await check('a performance read asks for AI’s own model key first and keeps it five minutes', async () => {
+ const urls = [];
+ const report = createReport({
+ config: { report: { url: 'https://report.test', timeoutMs: 500, cacheMs: 300000 } },
+ now: () => 1000,
+ fetchImpl: async (url, init) => {
+ urls.push(String(url));
+ assert(!new Headers(init.headers).has('authorization'), 'the public read has no token');
+ return new Response(JSON.stringify({ group: 'model', rows: [ROW] }), { headers: { 'content-type': 'application/json' } });
+ },
+ });
+ const model = { slug: 'deepseek/deepseek-chat', canonical: 'deepseek/deepseek-chat', offers: [{ model: 'deepseek-chat' }] };
+ assert.deepStrictEqual(await report.measuredModel(model), ROW);
+ assert.match(urls[0], /group=model&days=30&model=deepseek-chat$/);
+ await report.measuredModel(model);
+ assert.strictEqual(urls.length, 1, 'the row is cached for five minutes');
+
+ const missed = [];
+ const later = createReport({
+ config: { report: { url: 'https://report.test', timeoutMs: 500 } }, now: () => 1000,
+ fetchImpl: async (url) => {
+ missed.push(String(url));
+ return new Response(JSON.stringify({ rows: [] }));
+ },
+ });
+ assert.strictEqual(await later.measuredModel(model), null);
+ assert.match(missed[0], /model=deepseek-chat$/);
+ assert.match(missed[1], /model=deepseek%2Fdeepseek-chat$/);
+ assert.strictEqual(await later.measuredModel(model), null, 'no row is cached too');
+ assert.strictEqual(missed.length, 2, 'the slug is tried once and the miss is remembered');
+ });
+
+ await check('a Report failure is null, never a page error, and a person read carries their own Bearer', async () => {
+ const report = createReport({
+ config: { report: { url: 'https://report.test', timeoutMs: 500 } }, now: () => 1000,
+ fetchImpl: async () => { throw new Error('report is down'); },
+ });
+ assert.strictEqual(await report.measuredModel({ slug: 'deepseek/deepseek-chat', offers: [{ model: 'deepseek-chat' }] }), null);
+ const seen = [];
+ const asPerson = createReport({
+ config: { report: { url: 'https://report.test', timeoutMs: 500 } }, now: () => 1000,
+ fetchImpl: async (url, init) => {
+ seen.push([String(url), new Headers(init.headers).get('authorization')]);
+ const group = new URL(String(url)).searchParams.get('group');
+ return new Response(JSON.stringify(group === 'day' ? { ...PERSON.day } : { ...PERSON.surface }));
+ },
+ });
+ const out = await asPerson.personUsage('session-token');
+ assert.strictEqual(out.day.totals.calls, 10);
+ assert.strictEqual(out.surface.rows.length, 2);
+ assert.strictEqual(seen.length, 2);
+ assert(seen.every(([url, auth]) => url.startsWith('https://report.test/api/v1/me/ai/usage?group=') && auth === 'Bearer session-token'));
+ });
+
+ // ── The Measured panel on a model page ──
+ const ai = createFakeAi();
+ const withRow = await boot({ ai, report: { measuredModel: async () => ROW, personUsage: async () => PERSON } });
+ try {
+ await check('the model page draws the Measured panel from the Report row', async () => {
+ const r = await withRow.get('/m/deepseek/deepseek-chat');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('Measured'), 'the panel is there');
+ assert.ok(!r.text.includes(NOT_MEASURED), 'a measured model is not told to wait');
+ assert.ok(r.text.includes('p50 540 ms') && r.text.includes('p95 1.3 s'), 'the plain numbers are readable without JavaScript');
+ assert.ok(r.text.includes('86.0 tok/s') && r.text.includes('99.4%') && r.text.includes('42.0%'), 'speed, success and cache hit');
+ assert.ok(r.text.includes('$0.0042') && r.text.includes('$12.00') && r.text.includes('13k'), 'cost and calls');
+ const parts = jsonOf(r.text, 'measured');
+ assert.strictEqual(parts[0].kind, 'tiles');
+ assert.strictEqual(parts.filter((p) => p.kind === 'percentiles').length, 2, 'TTFT and latency strips');
+ assert.ok(r.text.includes('/shared/ov-metrics.js') && r.text.includes('/js/metrics-panel.js'), 'the builders load');
+ });
+ } finally { await withRow.close(); }
+
+ const noRow = await boot({ ai, report: { measuredModel: async () => null, personUsage: async () => PERSON } });
+ try {
+ await check('a model below Report’s floor says it is not measured yet', async () => {
+ const r = await noRow.get('/m/deepseek/deepseek-chat');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes(NOT_MEASURED), 'the exact sentence');
+ assert.ok(!r.text.includes('data-ov-metrics'), 'no metrics block is drawn');
+ });
+ } finally { await noRow.close(); }
+
+ const down = await boot({ ai, report: { measuredModel: async () => { throw new Error('down'); }, personUsage: async () => PERSON } });
+ try {
+ await check('Report down leaves the model page whole and says it is not measured yet', async () => {
+ const r = await down.get('/m/deepseek/deepseek-chat');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes(NOT_MEASURED));
+ assert.ok(r.text.includes('Every offer that serves it'), 'the rest of the page is still there');
+ });
+ } finally { await down.close(); }
+
+ // ── "Your AI usage" on the person's own dashboard ──
+ const USAGE = [{ day: today, key_id: 'k1', model: 'deepseek/deepseek-chat', status: 'settled', requests: 2, tokens_in: 100, tokens_out: 20, cost_usd: 0.001 }];
+ const personAi = { ...createFakeAi(), meRequest: async (_token, path) => (path.endsWith('key-usage') ? { rows: USAGE } : { keys: [{ id: 'k1', name: 'My app', prefix: 'ovx_1', monthly_cap_usd: 5 }] }) };
+ const dashboard = await boot({ ai: personAi, report: { measuredModel: async () => ROW, personUsage: async () => PERSON } });
+ try {
+ const user = dashboard.network.addUser('alice');
+ await check('a signed-in person sees their own AI usage: totals, a call a day and products', async () => {
+ const r = await dashboard.get('/dashboard/usage', { as: user });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('Your AI usage'), 'the panel is on their own usage page');
+ assert.ok(r.text.includes('1.6k') && r.text.includes('$0.01'), 'tokens and what was charged');
+ assert.ok(r.text.includes('By product') && r.text.includes('ai.complete'), 'the product bars');
+ const parts = jsonOf(r.text, 'your-ai-usage');
+ assert.strictEqual(parts[0].kind, 'tiles');
+ assert.strictEqual(parts[1].kind, 'heading');
+ assert.strictEqual(parts[2].kind, 'spark');
+ assert.strictEqual(parts[2].args[0].length, 30, '30 days, with a measured zero');
+ assert.ok(parts[2].args[0].includes(6) && parts[2].args[0].includes(4), 'the calls of the two days');
+ assert.strictEqual(parts[3].kind, 'heading');
+ assert.strictEqual(parts[4].kind, 'bars');
+ assert.ok(r.text.includes('My app'), 'the keys’ own usage stays on the page');
+ });
+
+ await check('Report down on the dashboard says so and leaves the keys’ usage alone', async () => {
+ const d = await boot({ ai: personAi, report: { measuredModel: async () => null, personUsage: async () => { throw new Error('down'); } } });
+ try {
+ const r = await d.get('/dashboard/usage', { as: user });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('Your AI usage is not available right now.'));
+ assert.ok(r.text.includes('My app'));
+ } finally { await d.close(); }
+ });
+
+ await check('a person with no AI calls is told so, not shown zeros', async () => {
+ const zero = { day: { group: 'day', rows: [], totals: { calls: 0, input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, cost_usd: 0, fee_usd: 0, charged_usd: 0, saved_usd: 0 }, insights: [], source: 'raw' }, surface: { group: 'surface', rows: [] } };
+ const d = await boot({ ai: personAi, report: { measuredModel: async () => null, personUsage: async () => zero } });
+ try {
+ const r = await d.get('/dashboard/usage', { as: user });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('No AI calls yet'));
+ } finally { await d.close(); }
+ });
+ } finally { await dashboard.close(); }
+
+ done();
+})().catch((err) => { console.error(err); process.exitCode = 1; });