openvibers/openvibe.report Commits

The AI meter: Report keeps the ledger (L40/L41) (#31)

OP OpenVibers committed 13 days ago · dd614a5df1 · 14 files, +701 −13

* The AI meter: Report keeps the ledger (L40/L41)

Report takes the ai.usage.recorded event on its own HMAC-verified /internal/events, writes each call once by the system and the run (or turn), and rolls the records up by day. The raw records go after 90 days; the roll-ups are kept for good. Three routes: the public totals, the operator's groups (a service token with report.ai_usage.read) and the person's own rows, which answer 501 until Report has sign-in.

* The consumer takes ai.call.recorded@1

The meter's record is not ADR-014's project usage rollup: it is one AI call, with who and where it came from. The topic Report keeps is ai.call.recorded now, and the tests follow it.

* Run the CI for the AI meter

* Run the CI for the AI meter

* openvibe-contracts v0.260.0 (was v0.225.0)

Report keeps the AI meter's ledger and serves the three ai-usage results, all in v0.260.0. Bumps the pin, the lockfile and the version lines.

* Merge branch 'main' of github.com:OpenVibers/OpenVibe.Report into pin/rep31

# Conflicts:
#	README.md
#	STATUS.json

* Merge remote-tracking branch 'origin/main' into pin/rep31

Changes

ddiff --git a/README.md b/README.md
iindex 27496cc..d49fc84 100644
---- a/README.md
++++ b/README.md
@@@ -205,7 +205,7 @@ payloads with money or personal data are not stored. `/internal/` is loopback-on
verifies the Events v2 HMAC. See [SECURITY.md](SECURITY.md).
<!-- versions:start -->
--- openvibe-contracts: v0.225.0
++- openvibe-contracts: v0.260.0
- openvibe-sdk: v0.78.0
- openvibe-shared: v4.66.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 6d88c78..478a5a1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,11 +8,11 @@
"runtime": "node 22, express 4, PostgreSQL (PGlite in development), no sessions",
"port": "5080",
"domain": "openvibe.report",
-- "contracts": "openvibe-contracts v0.225.0",
++ "contracts": "openvibe-contracts v0.260.0",
"sdk": "openvibe-sdk v0.78.0",
"shared": "openvibe-shared v4.66.0",
"packages": [
-- "openvibe-contracts v0.225.0",
++ "openvibe-contracts v0.260.0",
"openvibe-sdk v0.78.0",
"openvibe-shared v4.66.0",
"pg v8.23.1",
ddiff --git a/migrations/0002_ai_usage.sql b/migrations/0002_ai_usage.sql
nnew file mode 100644
iindex 0000000..68cd6dc
---- /dev/null
++++ b/migrations/0002_ai_usage.sql
@@@ -0,0 +1,78 @@
++-- phase: expand
++-- The AI meter (plan L40 and L41): Report keeps the ledger. OpenVibe.AI writes one ai.usage.recorded@1 per run;
++-- Report stores the record once and rolls the records up by day.
++
++-- The raw records. Kept for 90 days; the roll-ups below are kept for good. The record carries the tokens (the cache
++-- read and the cache write counted apart), the money and the ids: never a prompt, never an answer.
++CREATE TABLE report_ai_usage (
++ id bigserial PRIMARY KEY,
++ record_key text COLLATE "C" NOT NULL UNIQUE, -- system | run_id (or turn_id): the same call is stored once
++ system text COLLATE "C" NOT NULL, -- ai | harness:claude-code | harness:codex | actor | node
++ run_id text COLLATE "C",
++ turn_id text COLLATE "C",
++ subject text COLLATE "C", -- the person, or NULL when the call was anonymous
++ attribution text COLLATE "C" NOT NULL DEFAULT '',-- the site or app it came from, as '<type>:<id>'
++ surface text COLLATE "C" NOT NULL DEFAULT '',-- the product surface: design.generate, codes.turn
++ provider text COLLATE "C",
++ model text COLLATE "C",
++ effort text COLLATE "C",
++ paid_by text COLLATE "C" NOT NULL, -- own_key | openvibe_key | plan | own_machine | free_tier | network_allowance | org_key
++ paid_by_detail text COLLATE "C", -- 'anthropic ...a1b2' or 'claude-max': never a whole secret
++ tokens_input bigint NOT NULL DEFAULT 0, -- the UNCACHED input tokens
++ tokens_output bigint NOT NULL DEFAULT 0,
++ tokens_reasoning bigint NOT NULL DEFAULT 0,
++ tokens_cache_read bigint NOT NULL DEFAULT 0,
++ tokens_cache_write bigint NOT NULL DEFAULT 0,
++ cache_write_ttl text COLLATE "C", -- 5m | 1h | NULL
++ images bigint NOT NULL DEFAULT 0,
++ audio_seconds double precision NOT NULL DEFAULT 0,
++ video_seconds double precision NOT NULL DEFAULT 0,
++ list_cost_usd double precision NOT NULL DEFAULT 0,
++ charged_usd double precision NOT NULL DEFAULT 0,
++ fee_usd double precision NOT NULL DEFAULT 0,
++ cache_saved_usd double precision NOT NULL DEFAULT 0,
++ price_source text COLLATE "C" NOT NULL DEFAULT '',-- models | pricing | flat | no cache price
++ price_date text COLLATE "C",
++ latency_ms bigint,
++ ttft_ms bigint,
++ attempts integer NOT NULL DEFAULT 1,
++ outcome text COLLATE "C" NOT NULL, -- succeeded | failed | cancelled | cached
++ at timestamptz NOT NULL, -- when the call happened
++ day date NOT NULL,
++ source text COLLATE "C" NOT NULL DEFAULT 'events',
++ received_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE INDEX report_ai_usage_day ON report_ai_usage (day);
++CREATE INDEX report_ai_usage_subject ON report_ai_usage (subject);
++CREATE INDEX report_ai_usage_at ON report_ai_usage (at);
++
++-- The roll-ups, by day and by the people, the sites, the systems, the providers, the models and the payers behind them.
++-- Kept for good: the raw records go after 90 days, these stay.
++CREATE TABLE report_ai_usage_days (
++ day date NOT NULL,
++ subject text COLLATE "C" NOT NULL DEFAULT '',
++ attribution text COLLATE "C" NOT NULL DEFAULT '',
++ system text COLLATE "C" NOT NULL,
++ provider text COLLATE "C" NOT NULL DEFAULT '',
++ model text COLLATE "C" NOT NULL DEFAULT '',
++ paid_by text COLLATE "C" NOT NULL,
++ calls bigint NOT NULL DEFAULT 0,
++ tokens_input bigint NOT NULL DEFAULT 0,
++ tokens_output bigint NOT NULL DEFAULT 0,
++ tokens_reasoning bigint NOT NULL DEFAULT 0,
++ tokens_cache_read bigint NOT NULL DEFAULT 0,
++ tokens_cache_write bigint NOT NULL DEFAULT 0,
++ images bigint NOT NULL DEFAULT 0,
++ audio_seconds double precision NOT NULL DEFAULT 0,
++ video_seconds double precision NOT NULL DEFAULT 0,
++ list_cost_usd double precision NOT NULL DEFAULT 0,
++ charged_usd double precision NOT NULL DEFAULT 0,
++ fee_usd double precision NOT NULL DEFAULT 0,
++ cache_saved_usd double precision NOT NULL DEFAULT 0,
++ latency_ms bigint NOT NULL DEFAULT 0,
++ attempts bigint NOT NULL DEFAULT 0,
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (day, subject, attribution, system, provider, model, paid_by)
++);
++CREATE INDEX report_ai_usage_days_day ON report_ai_usage_days (day);
++CREATE INDEX report_ai_usage_days_subject ON report_ai_usage_days (subject);
ddiff --git a/package-lock.json b/package-lock.json
iindex 1665a99..83d3ca0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.260.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.78.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.1"
@@@ -1056,9 +1056,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.225.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
-- "integrity": "sha512-msLaRYa2Byyabnf/+L3aRPwkx8MI3zWbofFhwupq+RyBCdgbA4gLlkPsJu9nK/1UTCWdNdSTfWXW5pZY1/1zbQ==",
++ "version": "0.260.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.260.0",
++ "integrity": "sha512-gI0o3SGEj6kxOuFR/kWgA+YrGZ5xHthi5h/Zt6Y02Rtj0D+PlHz/bQTAvoQriwO6qX+JtbcKFI9RCQqgr/THcg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 8c3b8df..b98dced 100644
---- a/package.json
++++ b/package.json
@@@ -19,7 +19,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.260.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.78.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.1"
ddiff --git a/server/analytics/ai-usage.js b/server/analytics/ai-usage.js
nnew file mode 100644
iindex 0000000..1fc04fa
---- /dev/null
++++ b/server/analytics/ai-usage.js
@@@ -0,0 +1,268 @@
++'use strict';
++/**
++ * The AI ledger (plan L40 and L41): Report keeps the meter.
++ *
++ * OpenVibe.AI writes one ai.call.recorded@1 event per run. Report takes that event on its own HMAC-verified
++ * /internal/events, writes the record once (the key is the system and the run id, or the turn id), and rolls the
++ * records up by day. The raw records are kept for 90 days; the roll-ups are kept for good.
++ *
++ * The questions:
++ * usage() the rows of one group (day, site, model, system, paid_by), for one person or for everyone
++ * openTotals() the public totals: the tokens, the cache's share, the own-key share and the spend by week.
++ * No person, no site and no key is in them: the rows are summed before they are answered.
++ *
++ * A group that fewer than ten people share reads 'fewer than 10' instead of counting them.
++ */
++
++const GROUPS = { day: 'day', site: 'attribution', model: 'model', system: 'system', paid_by: 'paid_by' };
++const RAW_RETENTION_DAYS = 90;
++const PEOPLE_FLOOR = 10;
++const EMPTY = '';
++// The empty string as SQL writes it (two quotes), and one quote on its own.
++const SQL_EMPTY = String.fromCharCode(39, 39);
++const q = String.fromCharCode(39);
++
++const num = (v) => (v === null || v === undefined || v === '' ? 0 : (Number.isFinite(Number(v)) ? Number(v) : 0));
++const int = (v) => Math.trunc(num(v));
++/** Six decimals, the way money is written here. */
++const money = (v) => Math.round(num(v) * 1e6) / 1e6;
++
++/** The idempotency key of a record: the system and the run (or the turn). */
++const recordKey = (p) => `${String(p.system || 'ai')}|${p.run_id || p.turn_id || ''}`;
++
++/** The day of a timestamp (YYYY-MM-DD). */
++const dayOf = (v) => (v instanceof Date ? v : new Date(v)).toISOString().slice(0, 10);
++
++/** The Monday of the week a day falls in. */
++const weekOf = (day) => {
++ const d = new Date(`${day}T00:00:00Z`);
++ const dow = (d.getUTCDay() + 6) % 7;
++ return new Date(d.getTime() - dow * 86400000).toISOString().slice(0, 10);
++};
++
++/**
++ * Write one record inside the delivery's own transaction. The same call twice is one row: the key is the system
++ * and the run id (or the turn id).
++ */
++async function recordUsage(t, payload, { occurredAt = null, source = 'events' } = {}) {
++ const p = payload && typeof payload === 'object' ? payload : {};
++ const at = p.at || occurredAt || new Date().toISOString();
++ const key = recordKey(p);
++ const sql = 'INSERT INTO report_ai_usage (record_key, system, run_id, turn_id, subject, attribution, surface, provider, model, effort,'
++ + ' paid_by, paid_by_detail, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, cache_write_ttl,'
++ + ' images, audio_seconds, video_seconds, list_cost_usd, charged_usd, fee_usd, cache_saved_usd, price_source, price_date,'
++ + ' latency_ms, ttft_ms, attempts, outcome, at, day, source)'
++ + ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
++ + ' ON CONFLICT (record_key) DO NOTHING';
++ const r = await t.prepare(sql)
++ .run(key, String(p.system || 'ai'), p.run_id || null, p.turn_id || null, p.subject || null, String(p.attribution || EMPTY),
++ String(p.surface || EMPTY), p.provider || null, p.model || null, p.effort || null, String(p.paid_by || EMPTY), p.paid_by_detail || null,
++ int(p.input), int(p.output), int(p.reasoning), int(p.cache_read), int(p.cache_write), p.cache_write_ttl || null,
++ int(p.images), num(p.audio_seconds), num(p.video_seconds), money(p.list_cost_usd), money(p.charged_usd), money(p.fee_usd), money(p.cache_saved_usd),
++ String(p.price_source || EMPTY), p.price_date || null, p.latency_ms == null ? null : int(p.latency_ms), p.ttft_ms == null ? null : int(p.ttft_ms),
++ int(p.attempts) || 1, String(p.outcome || 'succeeded'), at, dayOf(at), String(source || 'events'));
++ return Number(r && r.changes) ? 'usage:recorded' : 'usage:duplicate';
++}
++
++/** The rows of one day, recomputed from the raw records (delete-then-insert, so a second pass counts nothing twice). */
++async function rollupDay(store, day) {
++ const sql = 'SELECT day, COALESCE(subject, ' + SQL_EMPTY + ') AS subject, attribution, system, COALESCE(provider, ' + SQL_EMPTY + ') AS provider,'
++ + ' COALESCE(model, ' + SQL_EMPTY + ') AS model, paid_by, COUNT(*)::bigint AS calls, SUM(tokens_input)::bigint AS tokens_input,'
++ + ' SUM(tokens_output)::bigint AS tokens_output, SUM(tokens_reasoning)::bigint AS tokens_reasoning,'
++ + ' SUM(tokens_cache_read)::bigint AS tokens_cache_read, SUM(tokens_cache_write)::bigint AS tokens_cache_write,'
++ + ' SUM(images)::bigint AS images, SUM(audio_seconds) AS audio_seconds, SUM(video_seconds) AS video_seconds,'
++ + ' SUM(list_cost_usd) AS list_cost_usd, SUM(charged_usd) AS charged_usd, SUM(fee_usd) AS fee_usd, SUM(cache_saved_usd) AS cache_saved_usd,'
++ + ' SUM(COALESCE(latency_ms, 0))::bigint AS latency_ms, SUM(attempts)::bigint AS attempts'
++ + ' FROM report_ai_usage WHERE day = ?::date GROUP BY day, subject, attribution, system, provider, model, paid_by';
++ const rows = await store.db.prepare(sql).all(day);
++ await store.db.prepare('DELETE FROM report_ai_usage_days WHERE day = ?::date').run(day);
++ const insert = 'INSERT INTO report_ai_usage_days (day, subject, attribution, system, provider, model, paid_by, calls, tokens_input, tokens_output,'
++ + ' tokens_reasoning, tokens_cache_read, tokens_cache_write, images, audio_seconds, video_seconds, list_cost_usd, charged_usd, fee_usd, cache_saved_usd,'
++ + ' latency_ms, attempts, updated_at)'
++ + ' VALUES (?::date, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())';
++ for (const r of rows) {
++ await store.db.prepare(insert)
++ .run(r.day, r.subject, r.attribution, r.system, r.provider, r.model, r.paid_by, r.calls, r.tokens_input, r.tokens_output,
++ r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write, r.images, r.audio_seconds, r.video_seconds,
++ money(r.list_cost_usd), money(r.charged_usd), money(r.fee_usd), money(r.cache_saved_usd), r.latency_ms, r.attempts);
++ }
++ return rows.length;
++}
++
++/** The days that still have raw records, rolled up again (the job calls this every hour). */
++async function rollup(store, { days = 14 } = {}) {
++ const list = await store.db.prepare('SELECT DISTINCT day FROM report_ai_usage ORDER BY day DESC LIMIT ?').all(Math.max(1, days));
++ let n = 0;
++ for (const r of list) n += await rollupDay(store, r.day);
++ return { days: list.length, rows: n };
++}
++
++/** The raw records go after 90 days; the roll-ups stay. */
++async function prune(store, { now = Date.now(), days = RAW_RETENTION_DAYS } = {}) {
++ const floor = new Date(now - days * 86400000).toISOString().slice(0, 10);
++ const r = await store.db.prepare('DELETE FROM report_ai_usage WHERE day < ?::date').run(floor);
++ return { raw: Number(r && r.changes) || 0 };
++}
++
++/** One row of a group, ready to be answered with. */
++function rowOf(group, key, r) {
++ const people = Number(r.people) || 0;
++ return {
++ group, key,
++ calls: Number(r.calls) || 0,
++ people: people >= PEOPLE_FLOOR ? people : 'fewer than 10',
++ input: Number(r.tokens_input) || 0,
++ output: Number(r.tokens_output) || 0,
++ reasoning: Number(r.tokens_reasoning) || 0,
++ cache_read: Number(r.tokens_cache_read) || 0,
++ cache_write: Number(r.tokens_cache_write) || 0,
++ images: Number(r.images) || 0,
++ audio_seconds: Number(r.audio_seconds) || 0,
++ video_seconds: Number(r.video_seconds) || 0,
++ list_cost_usd: money(r.list_cost_usd),
++ charged_usd: money(r.charged_usd),
++ fee_usd: money(r.fee_usd),
++ cache_saved_usd: money(r.cache_saved_usd),
++ };
++}
++
++/** The day bounds of a from/to pair: either may be a day (YYYY-MM-DD) or a full timestamp. */
++function bounds({ from = null, to = null } = {}) {
++ const day = (v) => (v ? String(v).slice(0, 10) : null);
++ return { from: day(from), to: day(to) };
++}
++
++/** The measures every group answers with. */
++const MEASURES = 'COUNT(DISTINCT NULLIF(subject, ' + SQL_EMPTY + '))::bigint AS people,'
++ + ' SUM(calls)::bigint AS calls, SUM(tokens_input)::bigint AS tokens_input, SUM(tokens_output)::bigint AS tokens_output,'
++ + ' SUM(tokens_reasoning)::bigint AS tokens_reasoning, SUM(tokens_cache_read)::bigint AS tokens_cache_read,'
++ + ' SUM(tokens_cache_write)::bigint AS tokens_cache_write, SUM(images)::bigint AS images,'
++ + ' SUM(audio_seconds) AS audio_seconds, SUM(video_seconds) AS video_seconds,'
++ + ' SUM(list_cost_usd) AS list_cost_usd, SUM(charged_usd) AS charged_usd, SUM(fee_usd) AS fee_usd, SUM(cache_saved_usd) AS cache_saved_usd';
++
++/** The usage rows of one group: 'day', 'site', 'model', 'system' or 'paid_by'. */
++async function usage(store, { from = null, to = null, group = 'day', subject = null } = {}) {
++ const column = GROUPS[group];
++ if (!column) throw new Error('group must be one of ' + Object.keys(GROUPS).join(', '));
++ const { from: fromDay, to: toDay } = bounds({ from, to });
++ const where = [];
++ const args = [];
++ if (fromDay) { where.push('day >= ?::date'); args.push(fromDay); }
++ if (toDay) { where.push('day <= ?::date'); args.push(toDay); }
++ if (subject) { where.push('subject = ?'); args.push(subject); }
++ const sql = 'SELECT ' + column + ' AS bucket, ' + MEASURES + ' FROM report_ai_usage_days'
++ + (where.length ? ' WHERE ' + where.join(' AND ') : '') + ' GROUP BY ' + column + ' ORDER BY ' + column;
++ const rows = await store.db.prepare(sql).all(...args);
++ return rows.map((r) => rowOf(group, r.bucket == null ? EMPTY : r.bucket, r));
++}
++
++/** The totals of the rows: the calls, the tokens and the money. */
++function totalsOf(rows) {
++ const t = { calls: 0, input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0, images: 0, audio_seconds: 0, video_seconds: 0, list_cost_usd: 0, charged_usd: 0, fee_usd: 0, cache_saved_usd: 0 };
++ for (const r of rows) {
++ t.calls += r.calls; t.input += r.input; t.output += r.output; t.reasoning += r.reasoning;
++ t.cache_read += r.cache_read; t.cache_write += r.cache_write; t.images += r.images;
++ t.audio_seconds += r.audio_seconds; t.video_seconds += r.video_seconds;
++ t.list_cost_usd += r.list_cost_usd; t.charged_usd += r.charged_usd; t.fee_usd += r.fee_usd; t.cache_saved_usd += r.cache_saved_usd;
++ }
++ for (const k of ['list_cost_usd', 'charged_usd', 'fee_usd', 'cache_saved_usd']) t[k] = money(t[k]);
++ return t;
++}
++
++/** The daily rows of a range. */
++async function daily(store, { from = null, to = null } = {}) {
++ const { from: fromDay, to: toDay } = bounds({ from, to });
++ const where = [];
++ const args = [];
++ if (fromDay) { where.push('day >= ?::date'); args.push(fromDay); }
++ if (toDay) { where.push('day <= ?::date'); args.push(toDay); }
++ const sel = 'SUM(calls)::bigint AS calls, SUM(tokens_input)::bigint AS tokens_input, SUM(tokens_output)::bigint AS tokens_output,'
++ + ' SUM(tokens_reasoning)::bigint AS tokens_reasoning, SUM(tokens_cache_read)::bigint AS tokens_cache_read,'
++ + ' SUM(tokens_cache_write)::bigint AS tokens_cache_write, SUM(list_cost_usd) AS list_cost_usd,'
++ + ' SUM(charged_usd) AS charged_usd, SUM(fee_usd) AS fee_usd, SUM(cache_saved_usd) AS cache_saved_usd';
++ const sql = 'SELECT day, ' + sel + ' FROM report_ai_usage_days' + (where.length ? ' WHERE ' + where.join(' AND ') : '') + ' GROUP BY day ORDER BY day';
++ return await store.db.prepare(sql).all(...args);
++}
++
++/**
++ * The public totals: the tokens, the cache's share, the own-key share and the spend by week. No person, no site
++ * and no key is in them.
++ */
++async function openTotals(store, { from = null, to = null } = {}) {
++ const sel = 'SUM(calls)::bigint AS calls, SUM(tokens_input)::bigint AS tokens_input, SUM(tokens_output)::bigint AS tokens_output,'
++ + ' SUM(tokens_reasoning)::bigint AS tokens_reasoning, SUM(tokens_cache_read)::bigint AS tokens_cache_read,'
++ + ' SUM(tokens_cache_write)::bigint AS tokens_cache_write, SUM(list_cost_usd) AS list_cost_usd,'
++ + ' SUM(charged_usd) AS charged_usd, SUM(fee_usd) AS fee_usd, SUM(cache_saved_usd) AS cache_saved_usd';
++ const total = (await store.db.prepare('SELECT ' + sel + ' FROM report_ai_usage_days').all())[0] || {};
++ const own = (await store.db.prepare('SELECT ' + sel + ' FROM report_ai_usage_days WHERE paid_by = ' + q + 'own_key' + q).all())[0] || {};
++ const calls = Number(total.calls) || 0;
++ const input = Number(total.tokens_input) || 0;
++ const output = Number(total.tokens_output) || 0;
++ const cacheRead = Number(total.tokens_cache_read) || 0;
++ const cacheWrite = Number(total.tokens_cache_write) || 0;
++ const prompt = input + cacheRead + cacheWrite;
++ const ownTokens = (Number(own.tokens_input) || 0) + (Number(own.tokens_output) || 0) + (Number(own.tokens_cache_read) || 0) + (Number(own.tokens_cache_write) || 0);
++ const allTokens = input + output + cacheRead + cacheWrite;
++ const weeks = new Map();
++ for (const r of await daily(store, { from, to })) {
++ const w = weekOf(String(r.day).slice(0, 10));
++ const cur = weeks.get(w) || { week: w, calls: 0, list_cost_usd: 0, charged_usd: 0, cache_saved_usd: 0 };
++ cur.calls += Number(r.calls) || 0;
++ cur.list_cost_usd = money(cur.list_cost_usd + (Number(r.list_cost_usd) || 0));
++ cur.charged_usd = money(cur.charged_usd + (Number(r.charged_usd) || 0));
++ cur.cache_saved_usd = money(cur.cache_saved_usd + (Number(r.cache_saved_usd) || 0));
++ weeks.set(w, cur);
++ }
++ return {
++ calls,
++ tokens: { input, output, cache_read: cacheRead, cache_write: cacheWrite },
++ cache_share: prompt > 0 ? Math.round((cacheRead / prompt) * 1e6) / 1e6 : 0,
++ cache_saved_usd: money(total.cache_saved_usd),
++ own_key_share: allTokens > 0 ? Math.round((ownTokens / allTokens) * 1e6) / 1e6 : 0,
++ own_key_calls: Number(own.calls) || 0,
++ list_cost_usd: money(total.list_cost_usd),
++ charged_usd: money(total.charged_usd),
++ fee_usd: money(total.fee_usd),
++ spend_by_week: [...weeks.values()].sort((a, b) => (a.week < b.week ? -1 : 1)),
++ };
++}
++
++/**
++ * ADR-033: a person's own records and roll-ups, for the export of their account. The rows leave with the account.
++ */
++async function exportFor(store, subject) {
++ if (!subject) throw new TypeError('exportFor needs a subject');
++ const records = await store.db.prepare('SELECT * FROM report_ai_usage WHERE subject = ? ORDER BY at').all(subject);
++ const days = await store.db.prepare('SELECT * FROM report_ai_usage_days WHERE subject = ? ORDER BY day').all(subject);
++ return {
++ subject,
++ exported_at: new Date().toISOString(),
++ records,
++ days,
++ };
++}
++
++/** ADR-033: the same rows are erased with the account. The roll-ups of other people are not touched. */
++async function eraseFor(store, subject) {
++ if (!subject) throw new TypeError('eraseFor needs a subject');
++ const records = await store.db.prepare('DELETE FROM report_ai_usage WHERE subject = ?').run(subject);
++ const days = await store.db.prepare('DELETE FROM report_ai_usage_days WHERE subject = ?').run(subject);
++ return { subject, records: Number(records && records.changes) || 0, days: Number(days && days.changes) || 0 };
++}
++
++/** The ledger as one object: the routes, the job and the account tools share it. */
++function createAiUsage({ store, now = () => Date.now(), log = console } = {}) {
++ if (!store) return null;
++ return {
++ store,
++ usage: (opts = {}) => usage(store, opts),
++ openTotals: (opts = {}) => openTotals(store, opts),
++ totalsOf,
++ rollup: (opts = {}) => rollup(store, opts),
++ prune: (opts = {}) => prune(store, { now, ...opts }),
++ exportFor: (subject) => exportFor(store, subject),
++ eraseFor: (subject) => eraseFor(store, subject),
++ recordUsage,
++ };
++}
++module.exports = { GROUPS, RAW_RETENTION_DAYS, PEOPLE_FLOOR, recordKey, recordUsage, rollup, rollupDay, prune, usage, totalsOf, daily, openTotals, exportFor, eraseFor, createAiUsage, weekOf, dayOf, money };
ddiff --git a/server/analytics/ingest.js b/server/analytics/ingest.js
iindex 551071e..490c908 100644
---- a/server/analytics/ingest.js
++++ b/server/analytics/ingest.js
@@@ -20,7 +20,7 @@ const { normaliseDays } = require('../data/source');
const INBOX = 'report-events';
const STATS_INBOX = 'network-stats';
const DAY = 86_400_000;
--const ALLOWED_TOPICS = new Set(['live.stream.ended', 'live.stream.started', 'network.frame.hit', 'network.frame.day', 'frame.hit']);
++const ALLOWED_TOPICS = new Set(['live.stream.ended', 'live.stream.started', 'network.frame.hit', 'network.frame.day', 'frame.hit', 'ai.call.recorded']);
const hashOf = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
@@@ -293,6 +293,8 @@ async function recordEvent(store, event, { now = () => Date.now() } = {}) {
await t.prepare('UPDATE report_inbox SET source = ?, topic = ?, payload_hash = ? WHERE consumer = ? AND event_id = ?')
.run(source, topic, hashOf(payload), INBOX, eventId);
if (topic === 'live.stream.ended') return await recordStream(t, e, payload, occurredAt, source);
++ // The AI meter (L40/L41): one record per AI call, stored once by the system and the run (or turn).
++ if (topic === 'ai.call.recorded') return await require('./ai-usage').recordUsage(t, payload, { occurredAt, source });
if (topic === 'network.frame.hit' || topic === 'network.frame.day' || topic === 'frame.hit') return await recordFrame(t, topic, payload, occurredAt, source);
return 'event:recorded';
});
ddiff --git a/server/app.js b/server/app.js
iindex 6a24746..6c7ca2a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -32,6 +32,7 @@ const { createLiveClient } = require('./data/live');
const { createBillingClient } = require('./data/billing');
const { createBenchClient } = require('./data/bench');
const { createAnalytics } = require('./analytics');
++const { createAiUsage } = require('./analytics/ai-usage');
const { createShadow } = require('./analytics/shadow');
const { createApi } = require('./http/api');
const { createPageRoutes, createPublicPages } = require('./http/pages');
@@@ -59,6 +60,8 @@ async function createApp(opts = {}) {
const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });
const bench = opts.bench || createBenchClient({ config, fetchImpl, log, now });
const analytics = opts.analytics || createAnalytics({ config, store, network, billing, now, log });
++ // The AI ledger (plan L40/L41): the records of every AI call on the network and their roll-ups.
++ const aiUsage = opts.aiUsage || createAiUsage({ store, now, log });
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
views.configure({ baseUrl: config.baseUrl });
@@@ -69,7 +72,7 @@ async function createApp(opts = {}) {
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
});
-- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso };
++ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage };
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
@@@ -153,7 +156,7 @@ async function createApp(opts = {}) {
legacyHeaders: false,
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
}));
-- app.use('/api/v1', createApi({ network, live, analytics }));
++ app.use('/api/v1', createApi({ network, live, analytics, aiUsage, config, now, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Crawl artifacts, then the pages ─────────────────────
ddiff --git a/server/auth.js b/server/auth.js
nnew file mode 100644
iindex 0000000..285f3d1
---- /dev/null
++++ b/server/auth.js
@@@ -0,0 +1,75 @@
++'use strict';
++/**
++ * A service token for Report's operator routes (plan L41).
++ *
++ * Callers present OpenVibe.Network service tokens (RS256 client-credentials JWTs, audience openvibe.report),
++ * verified with openvibe-contracts serviceAuth.verifyServiceToken (the claim schema, the issuer and the audience).
++ * The key comes from a pinned OV_NETWORK_PUBLIC_KEY PEM, or from the SDK's JWKS client (one per URL), which gives
++ * fresh keys and honours a rotation.
++ *
++ * The capability is report.ai_usage.read. It is checked against the token's own claim rather than against the
++ * pinned contracts release, because the release that carries the capability is the owner's to make.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++const CAPS = Object.freeze({ aiUsageRead: 'report.ai_usage.read' });
++
++/** The bearer token of a request, or null. */
++const bearer = (req) => {
++ const h = String((req && req.headers && req.headers.authorization) || '');
++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
++};
++
++/** The kid of a token header (the key the token was signed with). */
++const headerKid = (token) => {
++ try { return JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { return null; }
++};
++
++/** Does the token hold the capability (exactly, or through a report.* grant)? */
++const hasCap = (claims, id) => {
++ const granted = claims && Array.isArray(claims.cap) ? claims.cap : [];
++ return granted.some((c) => c === id || (String(c).endsWith('.*') && id.startsWith(String(c).slice(0, -1))));
++};
++
++/**
++ * The caller's service token, verified. Answers { ok: true, claims } or { ok: false, status, code, detail }.
++ * The verifier can be handed in (a test signs its own tokens), so the route never needs the network to be up.
++ */
++function createServiceAuth({ config = {}, now = () => Date.now(), verify = null } = {}) {
++ async function check(publicKey, token) {
++ return await serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: 'openvibe.report', now: now() });
++ }
++
++ async function verifyToken(token) {
++ if (verify) return await verify(token, { config, now });
++ if (config.networkPublicKey) return await check(config.networkPublicKey, token);
++ let keys;
++ try {
++ const { jwksClient } = require('openvibe-sdk/auth');
++ keys = await jwksClient(config.networkJwksUrl, { log: config.log }).keysForKid(headerKid(token));
++ } catch (err) {
++ return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
++ }
++ const kid = headerKid(token);
++ const byKid = kid ? keys.filter((k) => k.kid === kid) : [];
++ let last = { ok: false, code: 'token.unavailable', reason: 'no signing key' };
++ for (const k of byKid.length ? byKid : keys) {
++ last = await check(k.key, token);
++ if (last.ok || last.code !== 'token.bad_signature') return last;
++ }
++ return last;
++ }
++
++ /** The caller of a request, or the refusal to answer it. */
++ async function authenticate(req) {
++ const token = bearer(req);
++ if (!token) return { ok: false, status: 401, code: 'token.missing', detail: 'A service token is required.' };
++ const r = await verifyToken(token);
++ if (!r || !r.ok) return { ok: false, status: r && r.code === 'token.unavailable' ? 503 : 401, code: (r && r.code) || 'token.invalid', detail: (r && r.reason) || 'The token was refused.' };
++ return { ok: true, claims: r.claims };
++ }
++
++ return { authenticate, verifyToken, hasCap, bearer, CAPS };
++}
++
++module.exports = { createServiceAuth, hasCap, bearer, CAPS };
ddiff --git a/server/config.js b/server/config.js
iindex 29c755f..6a1a291 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -15,6 +15,8 @@ const trim = (s) => String(s || '').replace(/\/+$/, '');
const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
const bool = (v, def = false) => (v == null || v === '' ? def : !/^(0|false|no|off)$/i.test(String(v)));
const sourceOf = (v) => (String(v || '').toLowerCase() === 'report' ? 'report' : 'network');
++// A PEM in one line carries two-character escapes for its line breaks; this turns them back into newlines.
++const unescapePem = (v) => String(v || '').split(String.fromCharCode(92) + 'n').join('\n');
function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
@@@ -67,6 +69,15 @@ function load(env = process.env) {
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
},
++ // OpenVibe.Network's signing keys, for the operator routes that need a service token: a pinned PEM
++ // (OV_NETWORK_PUBLIC_KEY) or the JWKS the SDK's client fetches.
++ networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? unescapePem(env.OV_NETWORK_PUBLIC_KEY) : null,
++ networkJwksUrl: `${trim(env.OV_NETWORK_JWKS_URL || env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000')}/api/.well-known/jwks`,
++ issuer: trim(env.OV_NETWORK_ISSUER || networkPublicUrl),
++
++ // Drill mode: the jobs that would change numbers stay off while a drill runs.
++ drill: env.REPORT_DRILL === '1' || env.REPORT_DRILL === 'true',
++
// OpenVibe.Events → Report: live.stream.ended and the frame beacon. The secret signs a delivery (comma
// separated for rotation, 32+ characters each); unset makes POST /internal/events answer 503.
events: {
@@@ -81,6 +92,10 @@ function load(env = process.env) {
ingestWindows: [7, 30, 90],
shadowIntervalMs: Math.max(30_000, int(env.REPORT_SHADOW_INTERVAL_MS, 15 * 60_000)),
shadowWindowDays: [7, 30, 90].includes(Number(env.REPORT_SHADOW_WINDOW_DAYS)) ? Number(env.REPORT_SHADOW_WINDOW_DAYS) : 7,
++ // The AI meter (plan L40/L41): the daily roll-up of the usage records, and the pruning of the raw records.
++ aiUsageIntervalMs: Math.max(60_000, int(env.REPORT_AI_USAGE_INTERVAL_MS, 60 * 60_000)),
++ aiUsageRollupDays: Math.max(1, int(env.REPORT_AI_USAGE_ROLLUP_DAYS, 14)),
++ aiUsageRetentionDays: Math.max(1, int(env.REPORT_AI_USAGE_RETENTION_DAYS, 90)),
},
// One caller may make this many API requests a minute (express-rate-limit, keyed by address).
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 075813e..a914615 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -17,11 +17,15 @@
const express = require('express');
const contracts = require('openvibe-contracts');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
++const { createServiceAuth, CAPS } = require('../auth');
++const { GROUPS: AI_USAGE_GROUPS } = require('../analytics/ai-usage');
++const GROUPS = Object.keys(AI_USAGE_GROUPS);
const CACHE = 'public, max-age=120';
--function createApi({ network, live, analytics = null }) {
++function createApi({ network, live, analytics = null, aiUsage = null, config = {}, now = () => Date.now(), log = console }) {
const r = express.Router();
++ const serviceAuth = createServiceAuth({ config, now });
r.use((req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
@@@ -83,6 +87,45 @@ function createApi({ network, live, analytics = null }) {
return res.json(recaps);
});
++ // ── The AI meter (plan L40 and L41) ─────────────────────────────────────────────
++ //
++ // The public totals: the tokens, the cache's share, the own-key share and the spend by week. No person, no
++ // site and no key is in it.
++ r.get('/ai/usage/open', async (req, res) => {
++ if (!aiUsage) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');
++ try {
++ const body = await aiUsage.openTotals({ from: req.query.from || null, to: req.query.to || null });
++ return res.json(body);
++ } catch (err) {
++ log.error('[OpenVibe.Report] the open AI totals failed:', err && err.message ? String(err.message).slice(0, 200) : err);
++ return problem(req, res, 500, 'report.ai_usage_failed', 'The AI totals could not be read.');
++ }
++ });
++
++ // The meter across everyone: a service token with report.ai_usage.read, and nothing else.
++ r.get('/ai/usage', async (req, res) => {
++ if (!aiUsage) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');
++ const v = await serviceAuth.authenticate(req);
++ if (!v.ok) return problem(req, res, v.status, v.code, v.detail);
++ if (!serviceAuth.hasCap(v.claims, CAPS.aiUsageRead)) {
++ return problem(req, res, 403, 'capability.denied', 'report.ai_usage.read is not granted to this token.');
++ }
++ const group = String(req.query.group || 'day');
++ if (!GROUPS.includes(group)) return problem(req, res, 422, 'ai.invalid', 'group must be one of ' + GROUPS.join(', '));
++ try {
++ const rows = await aiUsage.usage({ from: req.query.from || null, to: req.query.to || null, group, subject: req.query.subject || null });
++ return res.set('Cache-Control', 'no-store').json({ group, usage: rows, totals: aiUsage.totalsOf(rows) });
++ } catch (err) {
++ log.error('[OpenVibe.Report] the AI usage rows failed:', err && err.message ? String(err.message).slice(0, 200) : err);
++ return problem(req, res, 500, 'report.ai_usage_failed', 'The AI usage rows could not be read.');
++ }
++ });
++
++ // The person's own rows: the route opens with Report's sign-in, which is the owner's next step. The shape is
++ // already fixed in the contracts (report.ai-usage-me-result@1), so the route does not have to change when it
++ // opens.
++ r.get('/me/ai/usage', (req, res) => problem(req, res, 501, 'report.not_implemented', 'This route opens with Report sign-in.'));
++
r.use((req, res) => notFound(req, res, 'route.not_found', 'No such API route.'));
return r;
}
ddiff --git a/server/index.js b/server/index.js
iindex 05c94c9..13f00c2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -6,6 +6,7 @@
*/
const { createApp } = require('./app');
const { createAnalyticsJobs } = require('./jobs/analytics');
++const { createAiUsageJobs } = require('./jobs/ai-usage');
const { gracefulStop } = require('openvibe-sdk/service');
/**
@@@ -32,8 +33,12 @@ async function start() {
const { config } = ctx;
const jobs = createAnalyticsJobs({ config, analytics: ctx.analytics, shadow: ctx.shadow, log: ctx.log });
-- ctx.jobs = jobs;
++ // The AI meter's job (plan L40/L41): the daily roll-ups, off under drill mode.
++ const aiUsageJobs = createAiUsageJobs({ config, store: ctx.store, now: ctx.now, log: ctx.log });
++ const allJobs = { ...jobs, stop: async () => { jobs.stop(); aiUsageJobs.stop(); } };
++ ctx.jobs = allJobs;
jobs.start();
++ aiUsageJobs.start();
// Socket activation (2026-10-31): systemd's .socket unit holds the listener across restarts, so a deploy
// queues requests in the kernel instead of answering 502. Without the socket unit, the usual listen.
ddiff --git a/server/jobs/ai-usage.js b/server/jobs/ai-usage.js
nnew file mode 100644
iindex 0000000..92661ea
---- /dev/null
++++ b/server/jobs/ai-usage.js
@@@ -0,0 +1,50 @@
++'use strict';
++/**
++ * The AI meter's job (plan L40 and L41): the daily roll-ups of the usage records, and the pruning of the raw records
++ * after 90 days. The roll-ups are kept for good.
++ *
++ * The job stays off under drill mode (REPORT_DRILL=1) and off when REPORT_JOBS=0, so a drill never changes the
++ * numbers. A failed pass logs one short line and the next pass retries; the last good roll-up stays in place.
++ */
++const aiUsage = require('../analytics/ai-usage');
++
++function createAiUsageJobs({ config = {}, store = null, now = () => Date.now(), log = console } = {}) {
++ const cfg = config.jobs || {};
++ let timer = null;
++ let running = false;
++
++ async function runOnce() {
++ if (!store) return { skipped: 'no_database' };
++ if (config.drill) return { skipped: 'drill' };
++ if (cfg.enabled === false) return { skipped: 'disabled' };
++ if (running) return { skipped: 'running' };
++ running = true;
++ try {
++ const rolled = await aiUsage.rollup(store, { days: cfg.aiUsageRollupDays || 14 });
++ const pruned = await aiUsage.prune(store, { now: now(), days: cfg.aiUsageRetentionDays || aiUsage.RAW_RETENTION_DAYS });
++ return { days: rolled.days, rows: rolled.rows, raw_pruned: pruned.raw };
++ } catch (err) {
++ const msg = String((err && err.message) || err).replace(/\s+/g, ' ').slice(0, 200);
++ log.warn('[OpenVibe.Report] the AI meter roll-up failed: ' + msg);
++ return { error: msg };
++ } finally { running = false; }
++ }
++
++ function start() {
++ if (timer) return timer;
++ if (config.drill) return null; // a drill never changes the numbers
++ if (cfg.enabled === false) return null;
++ const every = Math.max(60_000, Number(cfg.aiUsageIntervalMs) || 60 * 60_000);
++ timer = setInterval(() => { runOnce().catch(() => {}); }, every);
++ if (typeof timer.unref === 'function') timer.unref();
++ return timer;
++ }
++
++ function stop() {
++ if (timer) { clearInterval(timer); timer = null; }
++ }
++
++ return { start, stop, runOnce };
++}
++
++module.exports = { createAiUsageJobs };
ddiff --git a/test/ai-usage.test.js b/test/ai-usage.test.js
nnew file mode 100644
iindex 0000000..38d0279
---- /dev/null
++++ b/test/ai-usage.test.js
@@@ -0,0 +1,149 @@
++'use strict';
++/**
++ * The AI meter in Report (plan L40 and L41): the consumer of ai.call.recorded, the ledger, the roll-up job and the
++ * three routes.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { signDeliveryV2 } = require('openvibe-sdk/events');
++const { serviceAuth } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++const { createAiUsageJobs } = require('../server/jobs/ai-usage');
++
++const SECRET = 'test-report-events-secret-32-characters-long';
++const ISSUER = 'https://openvibe.network';
++const NOW = Date.parse('2026-10-31T12:00:00.000Z');
++const SUBJECT = 'usr_01J0000000000000000000000A';
++
++const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048,
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++});
++
++/** A service token for the operator route. */
++function token(cap) {
++ const now = Math.floor(NOW / 1000); // the app's own clock: the token is minted for it
++ return serviceAuth.signServiceToken({
++ iss: ISSUER, sub: 'svc:operator', actor_type: 'service', aud: ['openvibe.report'], cap, ns: ['*'],
++ iat: now, exp: now + 300, jti: 'tok_aiusage_' + String(now).slice(-6),
++ }, privateKey);
++}
++
++/** One ai.call.recorded event (the shape OpenVibe.AI publishes). */
++function usageEvent(over = {}) {
++ return {
++ event_id: 'evt_AIUSAGE001',
++ event_type: 'ai.call.recorded',
++ source: 'ai',
++ occurred_at: '2026-10-31T10:00:00.000Z',
++ payload: {
++ subject: SUBJECT, attribution: 'site:design', surface: 'ai.generate', system: 'ai',
++ provider: 'anthropic', model: 'claude-sonnet-4-5', effort: null,
++ paid_by: 'own_key', paid_by_detail: 'anthropic ...a1b2',
++ input: 200, output: 80, reasoning: 0, cache_read: 800, cache_write: 0, cache_write_ttl: null,
++ images: 0, audio_seconds: 0, video_seconds: 0,
++ list_cost_usd: 0.0021, charged_usd: 0, fee_usd: 0, cache_saved_usd: 0.0012,
++ price_source: 'models', price_date: '2026-10-31',
++ latency_ms: 1200, ttft_ms: 300, attempts: 1, outcome: 'succeeded',
++ run_id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ', turn_id: null, at: '2026-10-31T10:00:00.000Z',
++ ...over,
++ },
++ };
++}
++
++/** A signed delivery, the way OpenVibe.Events sends one. */
++function delivery(event) {
++ const raw = JSON.stringify({ event });
++ const ts = Math.floor(NOW / 1000);
++ return { raw, headers: { 'content-type': 'application/json', 'x-openvibe-timestamp': String(ts), 'x-openvibe-signature-v2': signDeliveryV2(raw, SECRET, ts) } };
++}
++
++(async () => {
++ const t = await boot({ db: true, now: () => NOW, env: { REPORT_EVENTS_SECRET: SECRET, OV_NETWORK_PUBLIC_KEY: publicKey } });
++ const db = t.ctx.store.db;
++ try {
++ await check('a signed ai.call.recorded is stored once, and a replay is a duplicate', async () => {
++ const d = delivery(usageEvent());
++ const first = await t.get('/internal/events', { method: 'POST', headers: d.headers, body: d.raw });
++ assert.strictEqual(first.status, 200, first.text);
++ assert.strictEqual(first.json().outcome, 'usage:recorded', first.text);
++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM report_ai_usage'), 1);
++ const again = await t.get('/internal/events', { method: 'POST', headers: d.headers, body: d.raw });
++ assert.strictEqual(again.status, 200, again.text);
++ assert.strictEqual(again.json().duplicate, true, again.text);
++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM report_ai_usage'), 1, 'one row');
++ });
++
++ await check('the same run under another event id is still one record', async () => {
++ const d = delivery(usageEvent({ input: 999 }));
++ const other = { ...usageEvent({ input: 999 }), event_id: 'evt_AIUSAGE002' };
++ const d2 = delivery(other);
++ const r = await t.get('/internal/events', { method: 'POST', headers: d2.headers, body: d2.raw });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().outcome, 'usage:duplicate', 'the run is counted once');
++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM report_ai_usage'), 1);
++ });
++
++ await check('the roll-up job writes the day rows, and the job is off under drill mode', async () => {
++ const jobs = createAiUsageJobs({ config: t.config, store: t.ctx.store, now: () => NOW });
++ const out = await jobs.runOnce();
++ assert.strictEqual(out.days, 1, 'one day rolled up');
++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM report_ai_usage_days'), 1);
++ const again = await jobs.runOnce();
++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM report_ai_usage_days'), 1, 'the second pass counts nothing twice');
++ const drilled = createAiUsageJobs({ config: { ...t.config, drill: true }, store: t.ctx.store, now: () => NOW });
++ assert.strictEqual((await drilled.runOnce()).skipped, 'drill', 'a drill never changes the numbers');
++ });
++
++ await check('the open route answers the totals and names nobody', async () => {
++ const r = await t.get('/api/v1/ai/usage/open');
++ assert.strictEqual(r.status, 200, r.text);
++ const body = r.json();
++ assert.strictEqual(body.calls, 1, 'one call');
++ assert.strictEqual(body.tokens.cache_read, 800, 'the cache read is counted');
++ assert.strictEqual(body.own_key_calls, 1, 'the own key paid for it');
++ assert.ok(body.spend_by_week.length >= 1, 'the spend by week is there');
++ const text = JSON.stringify(body);
++ for (const leak of ['usr_', 'site:design', 'anthropic', 'a1b2', SUBJECT]) {
++ assert.ok(!text.includes(leak), 'the open route names nobody: ' + leak);
++ }
++ });
++
++ await check('the operator route needs a service token with report.ai_usage.read', async () => {
++ const none = await t.get('/api/v1/ai/usage');
++ assert.strictEqual(none.status, 401, 'no token');
++ const wrong = await t.get('/api/v1/ai/usage', { headers: { authorization: 'Bearer ' + token(['report.other.read']) } });
++ assert.strictEqual(wrong.status, 403, 'the wrong capability');
++ const ok = await t.get('/api/v1/ai/usage?group=site', { headers: { authorization: 'Bearer ' + token(['report.ai_usage.read']) } });
++ assert.strictEqual(ok.status, 200, ok.text);
++ const body = ok.json();
++ assert.strictEqual(body.group, 'site');
++ assert.strictEqual(body.usage.length, 1, 'one site');
++ assert.strictEqual(body.usage[0].key, 'site:design');
++ assert.strictEqual(body.usage[0].people, 'fewer than 10', 'a group with fewer than ten people does not count them');
++ assert.strictEqual(body.totals.calls, 1);
++ });
++
++ await check('the route for a person is specified and answers 501 until Report has sign-in', async () => {
++ const r = await t.get('/api/v1/me/ai/usage');
++ assert.strictEqual(r.status, 501, r.text);
++ assert.strictEqual(r.json().code, 'report.not_implemented');
++ });
++
++ await check('ADR-033: the records and the roll-ups leave with the account', async () => {
++ const aiUsage = require('../server/analytics/ai-usage');
++ const store = t.ctx.store;
++ const exported = await aiUsage.exportFor(store, SUBJECT);
++ assert.strictEqual(exported.records.length, 1, 'the record is exported');
++ assert.strictEqual(exported.days.length, 1, 'the roll-up is exported');
++ assert.ok(!JSON.stringify(exported).includes('anthropic ...a1b2') === false, 'the export keeps what the record says');
++ const erased = await aiUsage.eraseFor(store, SUBJECT);
++ assert.strictEqual(erased.records, 1, 'the record is erased');
++ assert.strictEqual(erased.days, 1, 'the roll-up is erased');
++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM report_ai_usage'), 0);
++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM report_ai_usage_days'), 0);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });