openvibers/openvibe.ai Commits
Serve a public price and latency page at /stats (#8)
Changes
@@@ -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 Networkkey 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` |@@@ -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;@@@ -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).',@@@ -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 } };nnew file mode 100644@@@ -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();
@@ -108,6 +108,7 @@ idempotent replay):
@@ -108,6 +108,7 @@ idempotent replay):
| `GET /metrics` | direct loopback callers only (a request carrying X-Forwarded-For gets 404) |
| `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 /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 /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
`/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
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
@@ -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` |
| 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` |
| 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` |
| 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` |
| 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` |
| `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` |
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
## /console the operator console (server-rendered, OpenVibe.Network staff only) and /auth/* its
## /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
## Network sign-in (redirect URI https://ai.openvibe.network/auth/callback, OAuth client
## `ai`); both noindex. /robots.txt keeps crawlers out of them.
## `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 {
server {
@@ -84,6 +85,8 @@ 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 = /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 = / {
location = / {
proxy_pass http://127.0.0.1:4700;
proxy_pass http://127.0.0.1:4700;
proxy_set_header Host $host;
proxy_set_header Host $host;
@@ -11,6 +11,8 @@ const { sendError } = require('./util');
@@ -11,6 +11,8 @@ const { sendError } = require('./util');
const { adminRouter } = require('./api/admin');
const { adminRouter } = require('./api/admin');
const { createOps } = require('./ops');
const { createOps } = require('./ops');
const { consoleRouter } = require('./console');
const { consoleRouter } = require('./console');
+const views = require('./console/views');
+const cachePolicy = require('openvibe-shared/cache-policy');
const pkg = require('../package.json');
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 }) {
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
@@ -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).
// 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 }));
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).
// Crawlers: nothing here is for search engines, the console and sign-in least of all (no sitemap either).
app.get('/robots.txt', (_req, res) => {
app.get('/robots.txt', (_req, res) => {
res.type('text/plain').send('User-agent: *\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n');
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
@@ -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}',
'POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}',
'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
'GET /api/health, /api/ready, /release.json',
'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)',
'GET /console the operator console (OpenVibe.Network staff sign-in)',
'',
'',
'Callers authenticate with OpenVibe.Network service tokens (audience openvibe.ai).',
'Callers authenticate with OpenVibe.Network service tokens (audience openvibe.ai).',
@@ -118,10 +118,10 @@ const NAV = [
@@ -118,10 +118,10 @@ const NAV = [
['/console/quotas', 'Quotas', 'quotas'], ['/console/usage', 'Usage', 'usage'], ['/console/cache', 'Cache', 'cache'], ['/console/audit', 'Audit', 'audit'],
['/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">
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>
${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>
<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>` : ''}
<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
@@ -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 } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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();