openvibers/openvibe.exchange Commits

Exchange: measured model numbers and your own AI usage (#248)

OP OpenVibers committed 11 h ago · 96a268c494 · 15 files, +579 −25

* Exchange: measured model numbers and your own AI usage

* Exchange: version the metrics panel script

Changes

ddiff --git a/.env.example b/.env.example
iindex d3d8776..d0ddc70 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e9400dd..bc1b432 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex 66f6097..0cfe60b 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -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; }
ddiff --git a/public/js/metrics-panel.js b/public/js/metrics-panel.js
nnew file mode 100644
iindex 0000000..26accd8
---- /dev/null
++++ b/public/js/metrics-panel.js
@@@ -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();
++})();
ddiff --git a/server/analysis.js b/server/analysis.js
iindex a7ef7d0..03c9e2b 100644
---- a/server/analysis.js
++++ b/server/analysis.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex 46047a2..3c44a09 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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');
ddiff --git a/server/config.js b/server/config.js
iindex ebfae34..3e15038 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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).
ddiff --git a/server/http/dashboard.js b/server/http/dashboard.js
iindex cbc719c..fc66d88 100644
---- a/server/http/dashboard.js
++++ b/server/http/dashboard.js
@@@ -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()));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 48dca6d..4f48b4c 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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);
}
ddiff --git a/server/report.js b/server/report.js
nnew file mode 100644
iindex 0000000..330ada0
---- /dev/null
++++ b/server/report.js
@@@ -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 };
ddiff --git a/server/views/dashboard.js b/server/views/dashboard.js
iindex e440767..8898dea 100644
---- a/server/views/dashboard.js
++++ b/server/views/dashboard.js
@@@ -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>`);
}
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex 26857b2..3a603c0 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -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)}
ddiff --git a/server/views/metrics.js b/server/views/metrics.js
nnew file mode 100644
iindex 0000000..174cfa5
---- /dev/null
++++ b/server/views/metrics.js
@@@ -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 };
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex bb8f48a..1ff78ab 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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}`;
ddiff --git a/test/measured.test.js b/test/measured.test.js
nnew file mode 100644
iindex 0000000..efdcace
---- /dev/null
++++ b/test/measured.test.js
@@@ -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; });