openvibers/openvibe.ai Commits

Serve a public price and latency page at /stats (#8)

OP OpenVibers committed 2 Oct 2026 · d2b16bdaec · 5 files, +137 −4

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by space-bunny-alpha

https://github.com/OpenVibers/OpenVibe.AI/pull/8

Changes

ddiff --git a/README.md b/README.md
iindex 75296dd..986b5ee 100644
---- a/README.md
++++ b/README.md
@@@ -108,6 +108,7 @@ idempotent replay):
| `GET /metrics` | direct loopback callers only (a request carrying X-Forwarded-For gets 404) |
| `GET /console…`, `/auth/login\|callback`, `POST /auth/logout` | Network staff, signed in (see [Operator console](#operator-console)) |
| `GET /robots.txt` | public: disallows `/console`, `/auth/`, `/api/` |
++| `GET /stats` | public: p50/p95 latency, success rate and rate-card price per registered provider and model over the last 7 days (server-rendered, `openvibe-shared/cache-policy` HTML headers, nothing per caller) |
`/api/ready` (openvibe-shared/ready) is 503 when the database, the active workflows or the Network
key fail; provider configuration (an active provider without its credentials, or with an open
@@@ -279,6 +280,7 @@ file beside it, change `--model`/`--alias` and `AI_LOCAL_LLM_MODEL`, and restart
| The shared compiled-schema cache (caller-supplied schemas) is an LRU bounded by count and bytes, in Ajv too | `test/schemas.test.js` |
| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
| Operator console: SSO + PKCE, staff-only (anonymous → sign-in, non-staff 403), the staff → AI capability map, CSRF on every write, no script/secret on any page, noindex/robots, failed-run filters, audit rows from API and console changes | `test/console.test.js` |
++| Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | `test/stats-page.test.js` |
| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
| `media.analyze` on a real 28 s fixture: signals, scenes and loud moments measured, highlights with evidence, the local model's overview from the facts only; the local model down → extractive and said; paid only with `allow_paid` and a `media.paid` budget, never past it; too little disk refused before downloading | `test/media-analysis.test.js` |
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
ddiff --git a/deploy/nginx/ai.openvibe.network.conf b/deploy/nginx/ai.openvibe.network.conf
iindex 370e2b9..f6295b3 100644
---- a/deploy/nginx/ai.openvibe.network.conf
++++ b/deploy/nginx/ai.openvibe.network.conf
@@@ -14,6 +14,7 @@
## /console the operator console (server-rendered, OpenVibe.Network staff only) and /auth/* its
## Network sign-in (redirect URI https://ai.openvibe.network/auth/callback, OAuth client
## `ai`); both noindex. /robots.txt keeps crawlers out of them.
++## /stats public price and latency per provider and model (server-rendered, no sign-in)
## ═══════════════════════════════════════════════════════════════
server {
@@@ -84,6 +85,8 @@ server {
}
location = /robots.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ location = /stats { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++
location = / {
proxy_pass http://127.0.0.1:4700;
proxy_set_header Host $host;
ddiff --git a/server/app.js b/server/app.js
iindex 3c8bbbb..43468e5 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -11,6 +11,8 @@ const { sendError } = require('./util');
const { adminRouter } = require('./api/admin');
const { createOps } = require('./ops');
const { consoleRouter } = require('./console');
++const views = require('./console/views');
++const cachePolicy = require('openvibe-shared/cache-policy');
const pkg = require('../package.json');
function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null }) {
@@@ -53,6 +55,33 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log }));
++ // Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,
++ // nothing per caller. Only registered providers are listed: a person's own key records its runs as
++ // byo:<owner>:<subject>, which must never reach a public page.
++ const STATS_DAYS = 7;
++ app.get('/stats', async (req, res, next) => {
++ try {
++ const now = clock.now();
++ const rows = [];
++ for (const s of await quotas.statsFor({ days: STATS_DAYS })) {
++ const p = await registry.getProvider(s.provider);
++ if (!p) continue;
++ const m = s.model ? await registry.getModel(s.provider, s.model) : null;
++ rows.push({
++ provider: s.provider, model: s.model, capability: (m && m.type) || p.capabilities.join(', '),
++ requests: Number(s.requests) || 0, ok: Number(s.ok) || 0, p50: s.latency_p50_ms, p95: s.latency_p95_ms,
++ cards: await pool.rateCardsFor(s.provider, s.model || null),
++ });
++ }
++ res.setHeader('Cache-Control', cachePolicy.htmlHeaders());
++ res.setHeader('Content-Security-Policy', views.CSP);
++ res.type('html').send(views.pages.stats({
++ days: STATS_DAYS, rows,
++ from: new Date(now - STATS_DAYS * 86400000).toISOString().slice(0, 10), to: new Date(now).toISOString().slice(0, 10),
++ }));
++ } catch (e) { next(e); }
++ });
++
// Crawlers: nothing here is for search engines, the console and sign-in least of all (no sitemap either).
app.get('/robots.txt', (_req, res) => {
res.type('text/plain').send('User-agent: *\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n');
@@@ -67,6 +96,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
'POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}',
'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
'GET /api/health, /api/ready, /release.json',
++ 'GET /stats price and latency per provider and model (public)',
'GET /console the operator console (OpenVibe.Network staff sign-in)',
'',
'Callers authenticate with OpenVibe.Network service tokens (audience openvibe.ai).',
ddiff --git a/server/console/views.js b/server/console/views.js
iindex 72bd162..f70e882 100644
---- a/server/console/views.js
++++ b/server/console/views.js
@@@ -118,10 +118,10 @@ const NAV = [
['/console/quotas', 'Quotas', 'quotas'], ['/console/usage', 'Usage', 'usage'], ['/console/cache', 'Cache', 'cache'], ['/console/audit', 'Audit', 'audit'],
];
--function layout({ title, section, staff, csrf, notice, error, body }) {
++function layout({ title, section, staff, csrf, notice, error, body, site = 'operator console' }) {
return `<!doctype html>${render(html`<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
--<meta name="robots" content="noindex,nofollow"><meta name="referrer" content="no-referrer"><title>${title} · AI operator console</title><style>${new Raw(CSS)}</style></head>
--<body><a class="skip" href="#main">Skip to content</a><header><div class="bar"><span class="brand">OpenVibe.AI · operator console</span>
++<meta name="robots" content="noindex,nofollow"><meta name="referrer" content="no-referrer"><title>${title} · AI ${site}</title><style>${new Raw(CSS)}</style></head>
++<body><a class="skip" href="#main">Skip to content</a><header><div class="bar"><span class="brand">OpenVibe.AI · ${site}</span>
${staff ? html`<nav aria-label="Console"><ul>${NAV.map(([href, label, id]) => html`<li><a href="${href}"${id === section ? html` aria-current="page"` : ''}>${label}</a></li>`)}</ul></nav>
<div class="who"><span>${staff.username ? `@${staff.username} ` : ''}<code>${staff.subject}</code> · ${staff.role}</span>
<form class="inline" method="post" action="/auth/logout">${csrfField(csrf)}<button class="link" type="submit">Sign out</button></form></div>` : ''}
@@@ -476,4 +476,38 @@ ${table('Audit rows, newest first', ['#', 'At', 'Actor', 'Action', 'Target', 'De
});
}
--module.exports = { CSP, html, esc, safeUrl, pages: { signIn, message, overview, providers, definitions, definition, runs, run, quotas, usage, cache, audit } };
++// ── Public: price and latency (GET /stats, no sign-in) ───────
++const ms = (v) => (v == null ? '—' : `${n(v)} ms`);
++function rate(ok, total) {
++ if (!total) return '—';
++ const pct = (100 * ok) / total;
++ return `${pct === 100 || pct === 0 ? pct : pct.toFixed(1)} % (${n(ok)} / ${n(total)})`;
++}
++/** One cell per card set: input · cached · output per million tokens, as the platform.rate-card@1 cards state them. */
++function price(cards) {
++ if (!cards) return html`<span class="muted">not billed</span>`;
++ const per = cards.in.unit_size === 1e6 ? '1M' : n(cards.in.unit_size);
++ return html`<span class="nowrap">in ${usd(cards.in.unit_price_usd)}</span> · <span class="nowrap">cached ${usd(cards.cached.unit_price_usd)}</span> · <span class="nowrap">out ${usd(cards.out.unit_price_usd)}</span> <span class="muted nowrap">per ${per} tokens</span>`;
++}
++function allowance(cards) {
++ const free = cards ? [['in', cards.in], ['cached', cards.cached], ['out', cards.out]].filter(([, c]) => Number(c.free_allowance) > 0) : [];
++ if (!free.length) return '—';
++ return free.map(([k, c], i) => html`${i ? ' · ' : ''}<span class="nowrap">${k} ${n(c.free_allowance)} tokens${c.reset_period === 'none' ? '' : ` / ${c.reset_period}`}</span>`);
++}
++
++/**
++ * Public price and latency per provider and model: provider_stats_daily totals (quotas.statsFor) and the
++ * rate cards only. Nothing here is per caller; rows: { provider, model, capability, requests, ok, p50, p95, cards }.
++ */
++function stats({ days, from, to, rows }) {
++ return layout({
++ title: 'Price and latency', site: 'stats', body: html`<h1>Price and latency</h1>
++<p class="muted">Every provider and model AI called in the last ${days} UTC days (${from} to ${to}). Latency is the upper bound of a fixed latency bucket, the slowest day in the window; prices are per million tokens from the current rate cards.</p>
++${table(`Providers and models, ${from} to ${to}`, ['Provider', 'Model', 'Capability', 'p50', 'p95', 'Success rate', 'Price', 'Free allowance'], rows.map((r) => html`<tr>
++<td><code>${r.provider}</code></td><td>${r.model ? html`<code>${r.model}</code>` : '—'}</td><td>${r.capability || '—'}</td>
++<td class="nowrap">${ms(r.p50)}</td><td class="nowrap">${ms(r.p95)}</td><td class="nowrap">${rate(r.ok, r.requests)}</td><td>${price(r.cards)}</td><td>${allowance(r.cards)}</td></tr>`),
++ `no provider calls in the last ${days} days, so there is nothing to measure yet.`)}`,
++ });
++}
++
++module.exports = { CSP, html, esc, safeUrl, pages: { signIn, message, overview, providers, definitions, definition, runs, run, quotas, usage, cache, audit, stats } };
ddiff --git a/test/stats-page.test.js b/test/stats-page.test.js
nnew file mode 100644
iindex 0000000..2d51b16
---- /dev/null
++++ b/test/stats-page.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++// GET /stats: the public price/latency page. Server-rendered, no sign-in; rows come from provider_stats_daily
++// (quotas.statsFor) and the platform.rate-card@1 cards only. Nothing per caller ever reaches it: not the
++// requester, the attribution, a person's own-key provider (byo:<owner>:<subject>), a base URL or a secret.
++const assert = require('assert');
++const { boot, request, suite } = require('./helpers');
++
++const t = suite('stats-page');
++let h;
++
++const SEAM_URL = 'http://127.0.0.1:9/seam-internal';
++const ctx = { requesterType: 'service', requesterId: 'live', attributionKey: 'live:user:4242', workflowKey: 'default.generate' };
++const attempt = (provider_key, model_key, status, latency_ms) => ({ provider_key, model_key, status, latency_ms, cost_usd: 0.001, tokens_in: 10, tokens_out: 5, route_key: null });
++
++t.test('boot', async () => {
++ h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
++});
++
++t.test('no samples: 200 without auth, text/html, a short public cache and an honest empty state', async () => {
++ const r = await request(h.base, 'GET', '/stats');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.headers.get('content-type'), /^text\/html/);
++ assert.strictEqual(r.headers.get('cache-control'), require('openvibe-shared/cache-policy').htmlHeaders());
++ assert.match(r.headers.get('cache-control'), /^public, max-age=\d+/);
++ assert.ok(r.headers.get('content-security-policy'), 'the console CSP (no scripts)');
++ assert.match(r.text, /no provider calls in the last 7 days/);
++ assert.doesNotMatch(r.text, /<table/);
++ assert.doesNotMatch(r.text, /<script/i);
++});
++
++t.test('seeded stats: one row per provider and model with p50, p95, success rate, price and allowance', async () => {
++ await h.registry.upsertProvider({ key: 'seam', kind: 'http', auth_mode: 'bearer', secret_ref: 'env:SEAM_SECRET_KEY', base_url: SEAM_URL, capabilities: ['chat', 'json'], timeout_ms: 5000 });
++ await h.registry.upsertModel({ provider_key: 'seam', model_key: 'seam-model', type: 'chat', cost: { in_per_mtok: 3, out_per_mtok: 15, cached_per_mtok: 0.3, free_allowance: 1000000, reset_period: 'month' } });
++ await h.quotas.account(ctx, [], {
++ provider: 'seam', model: 'seam-model', writeUsage: false,
++ attempts: [attempt('seam', 'seam-model', 'ok', 80), attempt('seam', 'seam-model', 'ok', 90), attempt('seam', 'seam-model', 'ok', 150), attempt('seam', 'seam-model', 'error', 3000)],
++ });
++ // A person's own key: its runs are recorded under byo:<owner>:<subject>, never a public row.
++ await h.quotas.account(ctx, [], { provider: 'byo:user:usr_owner1:usr_subject1', model: 'own-model', writeUsage: false, attempts: [attempt('byo:user:usr_owner1:usr_subject1', 'own-model', 'ok', 40)] });
++
++ const r = await request(h.base, 'GET', '/stats');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.doesNotMatch(r.text, /no provider calls/);
++ const rows = r.text.match(/<tbody>([\s\S]*)<\/tbody>/)[1].split('</tr>').filter((x) => x.includes('<td'));
++ assert.strictEqual(rows.length, 1, r.text);
++ const cells = [...rows[0].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1].replace(/<[^>]+>/g, '').trim());
++ // Provider, Model, Capability, p50, p95, Success rate, Price, Free allowance (bucket upper bounds: 100 ms, 3200 ms).
++ assert.deepStrictEqual(cells.slice(0, 6), ['seam', 'seam-model', 'chat', '100 ms', '3,200 ms', '75.0 % (3 / 4)']);
++ assert.match(cells[6], /in \$3\.0000 · cached \$0\.3000 · out \$15\.0000 per 1M tokens/);
++ assert.match(cells[7], /in 1,000,000 tokens \/ month/);
++ for (const h2 of ['Provider', 'Model', 'Capability', 'p50', 'p95', 'Success rate', 'Price', 'Free allowance']) assert.ok(r.text.includes(`<th scope="col">${h2}</th>`), h2);
++ assert.match(r.text, /last 7 UTC days \(\d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2}\)/);
++});
++
++t.test('no caller identifiers, internal URLs or secrets in the HTML', async () => {
++ const r = await request(h.base, 'GET', '/stats');
++ for (const leak of ['byo:', 'usr_owner1', 'usr_subject1', 'own-model', 'service:live', 'live:user:4242', 'default.generate', SEAM_URL, '127.0.0.1', 'SEAM_SECRET_KEY', 'env:']) {
++ assert.ok(!r.text.includes(leak), `the page shows ${leak}`);
++ }
++});
++
++t.test('stop', async () => { await h.stop(); });
++
++t.run();