openvibers/openvibe.report Commits
Your own AI usage: GET /api/v1/me/ai/usage (L41 R1) (#35)
Changes
@@@ -46,8 +46,10 @@ REPORT_EVENTS_SECRET=#REPORT_SHADOW_WINDOW_DAYS=7#REPORT_SHADOW_INTERVAL_MS=900000--# Optional: the per-address API limit (default 120 a minute) and the readiness probe timeout (default 2500 ms).++# Optional: the per-address API limit (default 120 a minute), the personal routes' own limit (default 60) and the++# readiness probe timeout (default 2500 ms).#REPORT_API_RATE_LIMIT_PER_MIN=120++#REPORT_ME_RATE_LIMIT_PER_MIN=60#REPORT_READY_TIMEOUT_MS=2500# Optional: the deployed commit for /release.json. Without it the release is read from git in the checkout.nnew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- The AI meter's roll-ups gain the surface (plan L41 R1): a person can group their own calls by the product surface++-- (design.generate, codes.turn) beyond the 90 days of raw records. The roll-up's key grows by it; the rows written++-- before this migration keep the empty surface, and a roll-up recomputed from raw records fills it in.++ALTER TABLE report_ai_usage_days ADD COLUMN surface text COLLATE "C" NOT NULL DEFAULT '';++ALTER TABLE report_ai_usage_days DROP CONSTRAINT IF EXISTS report_ai_usage_days_pkey;++ALTER TABLE report_ai_usage_days ADD PRIMARY KEY (day, subject, attribution, system, surface, provider, model, paid_by);@@@ -8,6 +8,9 @@** The questions:* usage() the rows of one group (day, site, model, system, paid_by), for one person or for everyone++ * personUsage() one person's own rows (day, week, month, provider, model, surface, paid_by), their totals, what++ * each way of paying cost, and two or three plain lines about them. The person is the one their++ * session names, never a query parameter.* 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.*@@@ -15,7 +18,12 @@*/const GROUPS = { day: 'day', site: 'attribution', model: 'model', system: 'system', paid_by: 'paid_by' };++/** The groups a person may ask for their own rows. */++const PERSON_GROUPS = Object.freeze(['day', 'week', 'month', 'provider', 'model', 'surface', 'paid_by']);const RAW_RETENTION_DAYS = 90;++const DEFAULT_RANGE_DAYS = 30;++const MAX_RANGE_DAYS = 400;++const DAY_MS = 86400000;const PEOPLE_FLOOR = 10;const EMPTY = '';// The empty string as SQL writes it (two quotes), and one quote on its own.@@@ -66,23 +74,23 @@ async function recordUsage(t, payload, { occurredAt = null, source = 'events' }/** 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,'++ const sql = 'SELECT day, COALESCE(subject, ' + SQL_EMPTY + ') AS subject, attribution, system, surface, 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';++ + ' FROM report_ai_usage WHERE day = ?::date GROUP BY day, subject, attribution, system, surface, 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,'++ const insert = 'INSERT INTO report_ai_usage_days (day, subject, attribution, system, surface, 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())';++ + ' 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,++ .run(r.day, r.subject, r.attribution, r.system, r.surface, 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);}@@@ -250,11 +258,203 @@ async function eraseFor(store, subject) {return { subject, records: Number(records && records.changes) || 0, days: Number(days && days.changes) || 0 };}++// ── One person's own rows (plan L41 R1) ────────────────────────────────────────────────────────────────────────++++const dayRegex = /^\d{4}-\d{2}-\d{2}$/;++++/** A day (YYYY-MM-DD, or the day of an ISO timestamp), or null when it is not a real date. */++function parsePersonDay(value) {++ const s = String(value == null ? '' : value).slice(0, 10);++ if (!dayRegex.test(s)) return null;++ const d = new Date(`${s}T00:00:00Z`);++ return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s ? null : s;++}++++/** A day a number of days after (or before) another. */++const addDays = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * DAY_MS).toISOString().slice(0, 10);++const daysBetween = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / DAY_MS);++++/**++ * The range of a personal answer: the last 30 days by default, and never more than 400. A from/to pair may be a++ * day or a timestamp; an impossible one throws a RangeError the route answers as 422.++ */++function personRange({ from = null, to = null, now = Date.now() } = {}) {++ const today = dayOf(now);++ const toDay = to == null || to === '' ? today : parsePersonDay(to);++ if (!toDay) throw new RangeError('to must be a day (YYYY-MM-DD)');++ let fromDay = from == null || from === '' ? addDays(toDay, -(DEFAULT_RANGE_DAYS - 1)) : parsePersonDay(from);++ if (!fromDay) throw new RangeError('from must be a day (YYYY-MM-DD)');++ if (fromDay > toDay) throw new RangeError('from must be on or before to');++ // At most 400 days: an older from is raised to the first day of a 400-day window ending at to.++ const earliest = addDays(toDay, -(MAX_RANGE_DAYS - 1));++ if (fromDay < earliest) fromDay = earliest;++ return { from: fromDay, to: toDay, days: daysBetween(fromDay, toDay) + 1 };++}++++/** The column each of a person's groups buckets by. The time groups read the day and are rolled up here. */++const PERSON_BUCKETS = {++ day: 'day::text', week: 'day::text', month: 'day::text',++ provider: 'COALESCE(provider, ' + SQL_EMPTY + ')',++ model: 'COALESCE(model, ' + SQL_EMPTY + ')',++ surface: 'surface',++ paid_by: 'paid_by',++};++++/** The same measures from the raw records (one row per call) and from the roll-ups (one row per day and group). */++const PERSON_MEASURES_RAW = 'COUNT(*)::bigint AS calls, SUM(tokens_input)::bigint AS input, SUM(tokens_output)::bigint AS output,'++ + ' SUM(tokens_cache_read)::bigint AS cached, SUM(tokens_cache_write)::bigint AS cache_write,'++ + ' SUM(list_cost_usd) AS cost, SUM(fee_usd) AS fee, SUM(charged_usd) AS charged, SUM(cache_saved_usd) AS saved';++const PERSON_MEASURES_DAYS = 'SUM(calls)::bigint AS calls, SUM(tokens_input)::bigint AS input, SUM(tokens_output)::bigint AS output,'++ + ' SUM(tokens_cache_read)::bigint AS cached, SUM(tokens_cache_write)::bigint AS cache_write,'++ + ' SUM(list_cost_usd) AS cost, SUM(fee_usd) AS fee, SUM(charged_usd) AS charged, SUM(cache_saved_usd) AS saved';++++/** One person's rows of one bucket from one table (the caller passes an internal table and measure). */++async function personBuckets(store, { table, measure, bucket, subject, from, to }) {++ const sql = 'SELECT ' + bucket + ' AS bucket, ' + measure + ' FROM ' + table++ + ' WHERE subject = ? AND day >= ?::date AND day <= ?::date'++ + ' GROUP BY ' + bucket + ' ORDER BY ' + bucket;++ return await store.db.prepare(sql).all(subject, from, to);++}++++const emptyPersonMeasure = () => ({ calls: 0, input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, cost_usd: 0, fee_usd: 0, charged_usd: 0, saved_usd: 0 });++++/** One SQL row as the person's measures. */++function personMeasure(r) {++ return {++ calls: int(r.calls), input_tokens: int(r.input), output_tokens: int(r.output),++ cached_tokens: int(r.cached), cache_write_tokens: int(r.cache_write),++ cost_usd: num(r.cost), fee_usd: num(r.fee), charged_usd: num(r.charged), saved_usd: num(r.saved),++ };++}++++/** Add one measure into another (the first is changed). */++function addPersonMeasure(total, m) {++ total.calls += m.calls;++ total.input_tokens += m.input_tokens;++ total.output_tokens += m.output_tokens;++ total.cached_tokens += m.cached_tokens;++ total.cache_write_tokens += m.cache_write_tokens;++ total.cost_usd += m.cost_usd;++ total.fee_usd += m.fee_usd;++ total.charged_usd += m.charged_usd;++ total.saved_usd += m.saved_usd;++ return total;++}++++/** The measure as it is answered: whole counts, money to six decimals. */++function roundedPersonMeasure(m) {++ return {++ calls: int(m.calls),++ input_tokens: int(m.input_tokens),++ output_tokens: int(m.output_tokens),++ cached_tokens: int(m.cached_tokens),++ cache_write_tokens: int(m.cache_write_tokens),++ cost_usd: money(m.cost_usd),++ fee_usd: money(m.fee_usd),++ charged_usd: money(m.charged_usd),++ saved_usd: money(m.saved_usd),++ };++}++++/** The key of a bucket: the Monday for a week, the month for a month, 'unknown' for an empty provider or surface. */++function personKey(group, bucket) {++ const value = bucket == null ? EMPTY : String(bucket);++ if (group === 'week') return weekOf(value);++ if (group === 'month') return value.slice(0, 7);++ return value || 'unknown';++}++++/** What each way of paying cost over the same split of raw and roll-up tables. */++async function paidByTotals(store, splits, subject) {++ const map = new Map();++ for (const split of splits) {++ for (const r of await personBuckets(store, { ...split, bucket: 'paid_by', subject })) {++ const key = String(r.bucket == null ? EMPTY : r.bucket) || 'unknown';++ let m = map.get(key);++ if (!m) { m = emptyPersonMeasure(); map.set(key, m); }++ addPersonMeasure(m, personMeasure(r));++ }++ }++ const out = {};++ for (const [key, m] of [...map.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) out[key] = roundedPersonMeasure(m);++ return out;++}++++/** $0.41, or 'less than $0.01' — never a saving printed as $0.00. */++function usdText(v) {++ const n = money(v);++ if (n <= 0) return '$0.00';++ return n < 0.01 ? 'less than $0.01' : `$${n.toFixed(2)}`;++}++++/** The words a range's length deserves in a sentence. */++function periodText(days) {++ if (days <= 1) return 'today';++ if (days <= 7) return 'this week';++ if (days <= 31) return 'this month';++ return `in the last ${days} days`;++}++++/**++ * Two or three plain lines worked out from the numbers — rules, never an AI call. With no calls there is one.++ */++function personInsights({ totals, by_paid_by, days }) {++ if (!totals.calls) return ['No AI calls in this window.'];++ const own = by_paid_by.own_key || { calls: 0 };++ const share = Math.round((own.calls / totals.calls) * 100);++ const lines = [];++ if (share >= 100) lines.push('All your calls ran on your own keys, so no fee.');++ else if (share > 0) lines.push(`${share}% of your calls ran on your own keys, so no fee on those.`);++ else lines.push('None of your calls ran on your own keys.');++ if (totals.saved_usd > 0) lines.push(`The cache saved you ${usdText(totals.saved_usd)} ${periodText(days)}.`);++ else lines.push('No cache savings in this window.');++ if (totals.fee_usd > 0) lines.push(`Your calls cost ${usdText(totals.charged_usd)}, including ${usdText(totals.fee_usd)} in fees.`);++ else if (totals.charged_usd > 0) lines.push(`Your calls cost ${usdText(totals.charged_usd)}, with no fee.`);++ else lines.push('OpenVibe charged you nothing for these calls.');++ return lines;++}++++/**++ * One person's own usage (plan L41 R1): their rows of one group, the totals, what each way of paying cost and two++ * or three plain lines. Raw records answer a range inside the 90-day retention; the roll-ups answer the part++ * beyond it, and `source` says which was read. A subject is required and is never taken from the query.++ */++async function personUsage(store, { subject, from = null, to = null, group = 'day', now = Date.now() } = {}) {++ if (!subject) throw new TypeError('personUsage needs a subject');++ if (!PERSON_GROUPS.includes(group)) throw new RangeError('group must be one of ' + PERSON_GROUPS.join(', '));++ const range = personRange({ from, to, now });++ // The raw records run from the retention floor (the day prune keeps) to today; the roll-ups answer before it.++ const floor = addDays(dayOf(now), -RAW_RETENTION_DAYS);++ const splits = [];++ if (range.from < floor) {++ splits.push({ table: 'report_ai_usage_days', measure: PERSON_MEASURES_DAYS, kind: 'rollups', from: range.from, to: range.to < addDays(floor, -1) ? range.to : addDays(floor, -1) });++ }++ if (range.to >= floor) {++ splits.push({ table: 'report_ai_usage', measure: PERSON_MEASURES_RAW, kind: 'raw', from: range.from > floor ? range.from : floor, to: range.to });++ }++ const source = splits.length === 2 ? 'raw+rollups' : splits[0].kind;++ const map = new Map();++ for (const split of splits) {++ for (const r of await personBuckets(store, { ...split, bucket: PERSON_BUCKETS[group], subject })) {++ const key = personKey(group, r.bucket);++ let m = map.get(key);++ if (!m) { m = emptyPersonMeasure(); map.set(key, m); }++ addPersonMeasure(m, personMeasure(r));++ }++ }++ const rows = [...map.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([key, m]) => ({ key, ...roundedPersonMeasure(m) }));++ const totals = roundedPersonMeasure(rows.reduce((t, r) => addPersonMeasure(t, r), emptyPersonMeasure()));++ const by_paid_by = await paidByTotals(store, splits, subject);++ return { group, rows, totals, by_paid_by, insights: personInsights({ totals, by_paid_by, days: range.days }), source };++}++/** 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,++ groups: PERSON_GROUPS,++ forPerson: (opts = {}) => personUsage(store, { now: now(), ...opts }),usage: (opts = {}) => usage(store, opts),openTotals: (opts = {}) => openTotals(store, opts),totalsOf,@@@ -265,4 +465,8 @@ function createAiUsage({ store, now = () => Date.now(), log = console } = {}) {recordUsage,};}--module.exports = { GROUPS, RAW_RETENTION_DAYS, PEOPLE_FLOOR, recordKey, recordUsage, rollup, rollupDay, prune, usage, totalsOf, daily, openTotals, exportFor, eraseFor, createAiUsage, weekOf, dayOf, money };++module.exports = {++ GROUPS, PERSON_GROUPS, RAW_RETENTION_DAYS, DEFAULT_RANGE_DAYS, MAX_RANGE_DAYS, PEOPLE_FLOOR,++ recordKey, recordUsage, rollup, rollupDay, prune, usage, personUsage, personRange, totalsOf, daily, openTotals,++ exportFor, eraseFor, createAiUsage, weekOf, dayOf, money, usdText, periodText, personInsights,++};@@@ -100,6 +100,8 @@ function load(env = process.env) {// One caller may make this many API requests a minute (express-rate-limit, keyed by address).apiRateLimitPerMinute: Math.max(1, int(env.REPORT_API_RATE_LIMIT_PER_MIN, 120)),++ // A person's own narrow routes (GET /api/v1/me/ai/usage) have their own, tighter per-address limit.++ meRateLimitPerMinute: Math.max(1, int(env.REPORT_ME_RATE_LIMIT_PER_MIN, 60)),// How long a readiness probe may take before the source is reported as not answering.readyTimeoutMs: Math.max(200, int(env.REPORT_READY_TIMEOUT_MS, 2500)),@@@ -13,12 +13,16 @@* A bad parameter is checked here, before the source is asked, and is a 404 problem+json, exactly like a source* that answered nothing. Every /api/v1 answer is CORS-open and public for two minutes; the app mounts the 120/min* per-address limit in front of this router.++ *++ * GET /api/v1/me/ai/usage is the exception: it answers only the person their session names, so it is private, never++ * cached, and has its own tighter per-address limit.*/const express = require('express');++const rateLimit = require('express-rate-limit');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: AI_USAGE_GROUPS, PERSON_GROUPS } = require('../analytics/ai-usage');const GROUPS = Object.keys(AI_USAGE_GROUPS);const CACHE = 'public, max-age=120';@@@ -37,6 +41,19 @@ function createApi({ network, live, analytics = null, aiUsage = null, config = {return next();});++ // The personal routes: private and never cached, even when they refuse the caller, and their own limit.++ const personalLimit = rateLimit({++ windowMs: 60_000,++ limit: Math.max(1, Number(config.meRateLimitPerMinute) || 60),++ standardHeaders: true,++ legacyHeaders: false,++ handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),++ });++ r.use('/me', (req, res, next) => {++ res.set('Cache-Control', 'private, no-store');++ next();++ }, personalLimit);++function problem(req, res, status, code, detail) {return contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });}@@@ -121,10 +138,26 @@ function createApi({ network, live, analytics = null, aiUsage = null, config = {}});-- // 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.'));++ // The person's own rows (plan L41 R1). The subject is the signed-in person from Report's sso session (cookie or++ // bearer), never the query; signed out is 401. The last 30 days by default, 400 at most; raw records answer a++ // range inside the 90-day retention and the roll-ups the part beyond it. Private: the /me middleware above sets++ // private, no-store and the tighter limit, and the answer adds the lines worked out from its own numbers.++ r.get('/me/ai/usage', async (req, res) => {++ const user = req.user;++ const subject = user && typeof user.subject_id === 'string' ? user.subject_id.trim() : '';++ if (!subject) return problem(req, res, 401, 'auth.sign_in_required', 'Sign in to see your own AI usage.');++ if (!aiUsage) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');++ const group = String(req.query.group || 'day');++ if (!PERSON_GROUPS.includes(group)) return problem(req, res, 422, 'ai.invalid', 'group must be one of ' + PERSON_GROUPS.join(', '));++ try {++ const body = await aiUsage.forPerson({ from: req.query.from || null, to: req.query.to || null, group, subject, now: now() });++ return res.json(body);++ } catch (err) {++ if (err instanceof RangeError) return problem(req, res, 422, 'ai.invalid', err.message);++ log.error('[OpenVibe.Report] the personal AI usage rows failed:', err && err.message ? String(err.message).slice(0, 200) : err);++ return problem(req, res, 500, 'report.ai_usage_failed', 'Your AI usage could not be read.');++ }++ });r.use((req, res) => notFound(req, res, 'route.not_found', 'No such API route.'));return r;nnew file mode 100644@@@ -0,0 +1,199 @@++'use strict';++/**++ * A person's own AI usage (plan L41 R1): GET /api/v1/me/ai/usage.++ *++ * The subject is the signed-in person and only their session names it; signed out is 401, a service principal is++ * not a session and another person's records never appear. The groups, the totals, what each way of paying cost,++ * the two or three plain lines, the 30-day default and the 400-day cap, the raw records inside the 90-day++ * retention and the roll-ups beyond it, the private headers and the tighter personal limit.++ */++const assert = require('assert');++const crypto = require('crypto');++const { createSsoClient } = require('openvibe-sdk/sso');++const { boot, check, done } = require('./helpers/boot');++const ledger = require('../server/analytics/ai-usage');++++const NOW = Date.parse('2026-10-31T12:00:00.000Z');++const A = 'usr_01JTESTPERSONA0000000000000';++const B = 'usr_01JTESTPERSONB0000000000000';++const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++/** A person's access token, the way OpenVibe.Network signs one. */++function token(claims) {++ const now = Math.floor(Date.now() / 1000);++ const head = b64({ alg: 'RS256', typ: 'JWT', kid: 'test' });++ const body = b64({ iss: 'https://openvibe.network', aud: ['openvibe.network', 'openvibe.report'], iat: now, exp: now + 3600, ...claims });++ const sig = crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), privateKey).toString('base64url');++ return `${head}.${body}.${sig}`;++}++const alice = token({ sub: '1', subject_id: A, username: 'alice', display_name: 'Alice' });++const bob = token({ sub: '2', subject_id: B, username: 'bob', display_name: 'Bob' });++const service = token({ sub: 'svc:operator', actor_type: 'service', subject_id: A, username: 'operator' });++++const sso = createSsoClient({++ site: 'report', baseUrl: 'https://openvibe.report', clientId: 'report', clientSecret: 'x',++ networkUrl: 'https://openvibe.network', publicKey: publicKey.export({ type: 'spki', format: 'pem' }),++ log: { warn() {}, info() {}, log() {}, error() {} },++});++++const MEASURES = ['calls', 'input_tokens', 'output_tokens', 'cached_tokens', 'cache_write_tokens', 'cost_usd', 'fee_usd', 'charged_usd', 'saved_usd'];++++(async () => {++ const t = await boot({ db: true, now: () => NOW, sso });++ const store = t.ctx.store;++ let seq = 0;++ async function addCall(p = {}) {++ seq += 1;++ await store.tx((tx) => ledger.recordUsage(tx, {++ system: 'ai', run_id: `run_${String(seq).padStart(4, '0')}`, subject: A,++ attribution: 'site:design', surface: 'ai.generate', provider: 'anthropic', model: 'claude-sonnet-4-5',++ paid_by: 'own_key', input: 100, output: 10, reasoning: 0, cache_read: 0, cache_write: 0,++ list_cost_usd: 0.01, charged_usd: 0, fee_usd: 0, cache_saved_usd: 0,++ price_source: 'models', outcome: 'succeeded', at: '2026-10-31T10:00:00.000Z',++ ...p,++ }));++ }++ try {++ // Alice: three October calls on three days plus a September one, one of them on the OpenVibe key; an older call++ // (March 2026), the first day a 400-day window reaching back from 2026-10-31 keeps (2025-09-27), and the day++ // before it. Bob has one call of his own, and must never be in Alice's answer.++ await addCall({ at: '2026-10-31T10:00:00.000Z', input: 200, output: 80, cache_read: 800, list_cost_usd: 0.0021, cache_saved_usd: 0.0012 });++ await addCall({ at: '2026-10-30T10:00:00.000Z', provider: 'openai', model: 'gpt-5', surface: 'codes.turn', paid_by: 'openvibe_key', input: 1000, output: 500, cache_write: 100, list_cost_usd: 0.05, charged_usd: 0.06, fee_usd: 0.01 });++ await addCall({ at: '2026-10-26T10:00:00.000Z', input: 50, output: 10, list_cost_usd: 0.001, cache_saved_usd: 0.0005 });++ await addCall({ at: '2026-09-15T10:00:00.000Z', provider: 'openai', model: 'gpt-5', input: 300, output: 30, cache_read: 100, list_cost_usd: 0.004, cache_saved_usd: 0.0002 });++ await addCall({ at: '2026-03-01T10:00:00.000Z', provider: 'oldprov', model: 'old-model', surface: 'old.surface', input: 7, output: 3, list_cost_usd: 0.0007 });++ await addCall({ at: '2025-09-27T10:00:00.000Z', provider: 'capprov', model: 'cap-model', surface: 'cap.surface', input: 11, output: 1, list_cost_usd: 0.0011 });++ await addCall({ at: '2025-09-26T10:00:00.000Z', provider: 'beyondprov', model: 'beyond-model', surface: 'beyond.surface', input: 13, output: 1, list_cost_usd: 0.0013 });++ await addCall({ subject: B, at: '2026-10-31T11:00:00.000Z', provider: 'google', model: 'gemini-bob', surface: 'bobs.surface', paid_by: 'org_key', input: 9000, output: 9000, list_cost_usd: 9, charged_usd: 9, fee_usd: 9 });++ // Roll the records up, then prune the raw ones the 90-day retention drops: the old range is answered from++ // the roll-ups, and the recent one from the raw records.++ await ledger.rollup(store, { days: 500 });++ await ledger.prune(store, { now: NOW });++++ await check('signed out is a private 401, and a service token is not a session', async () => {++ const out = await t.get('/api/v1/me/ai/usage');++ assert.strictEqual(out.status, 401, out.text);++ assert.strictEqual(out.json().code, 'auth.sign_in_required');++ assert.strictEqual(out.headers.get('cache-control'), 'private, no-store');++ const svc = await t.get('/api/v1/me/ai/usage', { headers: { authorization: 'Bearer ' + service } });++ assert.strictEqual(svc.status, 401, 'a service principal is not a person');++ });++++ await check('the last 30 days by default, only the person in the session, raw records', async () => {++ const r = await t.get('/api/v1/me/ai/usage', { headers: { cookie: `ov_token=${alice}` } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ const body = r.json();++ assert.strictEqual(body.group, 'day');++ assert.strictEqual(body.source, 'raw', 'a recent range reads the raw records');++ assert.deepStrictEqual(body.rows.map((x) => x.key), ['2026-10-26', '2026-10-30', '2026-10-31'], 'the default window starts on 2026-10-02');++ assert.strictEqual(body.totals.calls, 3, 'three calls in the last 30 days');++ const text = JSON.stringify(body);++ for (const leak of ['gemini-bob', 'org_key', 'bobs.surface', 'google', B]) assert.ok(!text.includes(leak), 'another person must not be named: ' + leak);++ const asked = await t.get('/api/v1/me/ai/usage?subject=' + B, { headers: { cookie: `ov_token=${alice}` } });++ assert.strictEqual(asked.status, 200, asked.text);++ assert.strictEqual(asked.json().totals.calls, 3, 'the query cannot name another subject');++ const bearer = await t.get('/api/v1/me/ai/usage', { headers: { authorization: 'Bearer ' + alice } });++ assert.strictEqual(bearer.status, 200, 'a bearer session is a session too');++ assert.deepStrictEqual(bearer.json().totals, body.totals);++ const other = await t.get('/api/v1/me/ai/usage', { headers: { cookie: `ov_token=${bob}` } });++ assert.strictEqual(other.json().totals.calls, 1, 'Bob reads only Bob');++ assert.strictEqual(other.json().rows[0].key, '2026-10-31');++ });++++ await check('the seven groups bucket the person’s own rows', async () => {++ const expected = {++ day: ['2026-09-15', '2026-10-26', '2026-10-30', '2026-10-31'],++ week: ['2026-09-14', '2026-10-26'],++ month: ['2026-09', '2026-10'],++ provider: ['anthropic', 'openai'],++ model: ['claude-sonnet-4-5', 'gpt-5'],++ surface: ['ai.generate', 'codes.turn'],++ paid_by: ['openvibe_key', 'own_key'],++ };++ for (const [group, keys] of Object.entries(expected)) {++ const r = await t.get(`/api/v1/me/ai/usage?group=${group}&from=2026-09-01&to=2026-10-31`, { headers: { cookie: `ov_token=${alice}` } });++ assert.strictEqual(r.status, 200, group + ': ' + r.text);++ const body = r.json();++ assert.strictEqual(body.group, group);++ assert.deepStrictEqual(body.rows.map((x) => x.key), keys, group);++ for (const row of body.rows) for (const k of MEASURES) assert.strictEqual(typeof row[k], 'number', `${group}.${k}`);++ }++ });++++ await check('the totals and by_paid_by add up to the rows', async () => {++ for (const group of ['day', 'week', 'month', 'provider', 'model', 'surface', 'paid_by', 'week']) {++ const body = (await t.get(`/api/v1/me/ai/usage?group=${group}&from=2026-09-01&to=2026-10-31`, { headers: { cookie: `ov_token=${alice}` } })).json();++ for (const k of MEASURES) {++ const sum = body.rows.reduce((n, row) => n + row[k], 0);++ assert.ok(Math.abs(sum - body.totals[k]) < 1e-6, `${group}.${k}: ${sum} vs ${body.totals[k]}`);++ const paid = Object.values(body.by_paid_by).reduce((n, row) => n + row[k], 0);++ assert.ok(Math.abs(paid - body.totals[k]) < 1e-6, `by_paid_by.${k}: ${paid} vs ${body.totals[k]}`);++ }++ }++ });++++ await check('two or three plain lines, worked out from the numbers', async () => {++ const body = (await t.get('/api/v1/me/ai/usage?from=2026-09-01&to=2026-10-31', { headers: { cookie: `ov_token=${alice}` } })).json();++ const lines = body.insights;++ assert.ok(Array.isArray(lines) && lines.length >= 2 && lines.length <= 3, JSON.stringify(lines));++ assert.ok(lines.some((l) => /75% of your calls ran on your own keys/.test(l)), lines.join(' | '));++ assert.ok(lines.some((l) => /cache saved you/.test(l)), lines.join(' | '));++ assert.ok(lines.some((l) => /cost|charged you/.test(l)), lines.join(' | '));++ const empty = (await t.get('/api/v1/me/ai/usage?from=2026-01-01&to=2026-01-02', { headers: { cookie: `ov_token=${alice}` } })).json();++ assert.deepStrictEqual(empty.insights, ['No AI calls in this window.']);++ assert.deepStrictEqual(empty.rows, []);++ });++++ await check('the roll-ups answer a range beyond 90 days, and the split is named', async () => {++ const old = (await t.get('/api/v1/me/ai/usage?group=surface&from=2026-01-01&to=2026-06-30', { headers: { cookie: `ov_token=${alice}` } })).json();++ assert.strictEqual(old.source, 'rollups');++ assert.deepStrictEqual(old.rows.map((x) => x.key), ['old.surface'], 'the roll-up keeps the surface');++ assert.strictEqual(old.totals.calls, 1);++ const straddle = (await t.get('/api/v1/me/ai/usage?group=provider&from=2026-01-01&to=2026-10-31', { headers: { cookie: `ov_token=${alice}` } })).json();++ assert.strictEqual(straddle.source, 'raw+rollups');++ assert.deepStrictEqual(straddle.rows.map((x) => x.key), ['anthropic', 'oldprov', 'openai']);++ assert.strictEqual(straddle.totals.calls, 5, 'the March call from the roll-ups and the four raw calls since');++ const weeks = (await t.get('/api/v1/me/ai/usage?group=week&from=2026-01-01&to=2026-10-31', { headers: { cookie: `ov_token=${alice}` } })).json();++ assert.strictEqual(weeks.source, 'raw+rollups');++ assert.deepStrictEqual(weeks.rows.map((x) => x.key), ['2026-02-23', '2026-09-14', '2026-10-26'], 'a week combines both sources');++ assert.strictEqual(weeks.totals.calls, 5);++ });++++ await check('at most 400 days back: the first day in, the day before out', async () => {++ const body = (await t.get('/api/v1/me/ai/usage?group=day&from=2024-01-01&to=2026-10-31', { headers: { cookie: `ov_token=${alice}` } })).json();++ assert.strictEqual(body.source, 'raw+rollups');++ const keys = body.rows.map((x) => x.key);++ assert.ok(keys.includes('2025-09-27'), 'the first day of the 400-day window is in');++ assert.ok(!keys.includes('2025-09-26'), 'the day before it is out');++ assert.strictEqual(body.totals.calls, 6, 'the cap row, March, September and the three October calls');++ });++++ await check('an impossible range or group is a 422 problem+json', async () => {++ for (const q of ['group=year', 'from=2026-02-31&to=2026-10-31', 'from=2026-11-01&to=2026-10-01', 'from=nope']) {++ const r = await t.get('/api/v1/me/ai/usage?' + q, { headers: { cookie: `ov_token=${alice}` } });++ assert.strictEqual(r.status, 422, q);++ assert.match(r.headers.get('content-type') || '', /^application\/problem\+json/);++ assert.strictEqual(r.json().code, 'ai.invalid', q);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ }++ });++ } finally { await t.close(); }++++ await check('the personal route has its own limit, then a 429 problem', async () => {++ const limited = await boot({ db: true, now: () => NOW, sso, env: { REPORT_ME_RATE_LIMIT_PER_MIN: '2' } });++ try {++ const headers = { cookie: `ov_token=${alice}` };++ assert.strictEqual((await limited.get('/api/v1/me/ai/usage', { headers })).status, 200);++ assert.strictEqual((await limited.get('/api/v1/me/ai/usage', { headers })).status, 200);++ const third = await limited.get('/api/v1/me/ai/usage', { headers });++ assert.strictEqual(third.status, 429);++ assert.match(third.headers.get('content-type') || '', /^application\/problem\+json/);++ assert.strictEqual(third.json().code, 'rate_limited');++ assert.strictEqual(third.headers.get('cache-control'), 'private, no-store');++ } finally { await limited.close(); }++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -125,10 +125,11 @@ function delivery(event) {assert.strictEqual(body.totals.calls, 1);});-- await check('the route for a person is specified and answers 501 until Report has sign-in', async () => {++ await check('the route for a person refuses a caller with no session, privately', 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');++ assert.strictEqual(r.status, 401, r.text);++ assert.strictEqual(r.json().code, 'auth.sign_in_required');++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');});await check('ADR-033: the records and the roll-ups leave with the account', async () => {
@@ -46,8 +46,10 @@ REPORT_EVENTS_SECRET=
@@ -46,8 +46,10 @@ REPORT_EVENTS_SECRET=
#REPORT_SHADOW_WINDOW_DAYS=7
#REPORT_SHADOW_WINDOW_DAYS=7
#REPORT_SHADOW_INTERVAL_MS=900000
#REPORT_SHADOW_INTERVAL_MS=900000
-# Optional: the per-address API limit (default 120 a minute) and the readiness probe timeout (default 2500 ms).
+# Optional: the per-address API limit (default 120 a minute), the personal routes' own limit (default 60) and the
+# readiness probe timeout (default 2500 ms).
#REPORT_API_RATE_LIMIT_PER_MIN=120
#REPORT_API_RATE_LIMIT_PER_MIN=120
+#REPORT_ME_RATE_LIMIT_PER_MIN=60
#REPORT_READY_TIMEOUT_MS=2500
#REPORT_READY_TIMEOUT_MS=2500
# Optional: the deployed commit for /release.json. Without it the release is read from git in the checkout.
# Optional: the deployed commit for /release.json. Without it the release is read from git in the checkout.
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+-- The AI meter's roll-ups gain the surface (plan L41 R1): a person can group their own calls by the product surface
+-- (design.generate, codes.turn) beyond the 90 days of raw records. The roll-up's key grows by it; the rows written
+-- before this migration keep the empty surface, and a roll-up recomputed from raw records fills it in.
+ALTER TABLE report_ai_usage_days ADD COLUMN surface text COLLATE "C" NOT NULL DEFAULT '';
+ALTER TABLE report_ai_usage_days DROP CONSTRAINT IF EXISTS report_ai_usage_days_pkey;
+ALTER TABLE report_ai_usage_days ADD PRIMARY KEY (day, subject, attribution, system, surface, provider, model, paid_by);
@@ -8,6 +8,9 @@
@@ -8,6 +8,9 @@
*
*
* The questions:
* The questions:
* usage() the rows of one group (day, site, model, system, paid_by), for one person or for everyone
* usage() the rows of one group (day, site, model, system, paid_by), for one person or for everyone
+ * personUsage() one person's own rows (day, week, month, provider, model, surface, paid_by), their totals, what
+ * each way of paying cost, and two or three plain lines about them. The person is the one their
+ * session names, never a query parameter.
* openTotals() the public totals: the tokens, the cache's share, the own-key share and the spend by week.
* 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.
* No person, no site and no key is in them: the rows are summed before they are answered.
*
*
@@ -15,7 +18,12 @@
@@ -15,7 +18,12 @@
*/
*/
const GROUPS = { day: 'day', site: 'attribution', model: 'model', system: 'system', paid_by: 'paid_by' };
const GROUPS = { day: 'day', site: 'attribution', model: 'model', system: 'system', paid_by: 'paid_by' };
+/** The groups a person may ask for their own rows. */
+const PERSON_GROUPS = Object.freeze(['day', 'week', 'month', 'provider', 'model', 'surface', 'paid_by']);
const RAW_RETENTION_DAYS = 90;
const RAW_RETENTION_DAYS = 90;
+const DEFAULT_RANGE_DAYS = 30;
+const MAX_RANGE_DAYS = 400;
+const DAY_MS = 86400000;
const PEOPLE_FLOOR = 10;
const PEOPLE_FLOOR = 10;
const EMPTY = '';
const EMPTY = '';
// The empty string as SQL writes it (two quotes), and one quote on its own.
// The empty string as SQL writes it (two quotes), and one quote on its own.
@@ -66,23 +74,23 @@ async function recordUsage(t, payload, { occurredAt = null, source = 'events' }
@@ -66,23 +74,23 @@ async function recordUsage(t, payload, { occurredAt = null, source = 'events' }
/** The rows of one day, recomputed from the raw records (delete-then-insert, so a second pass counts nothing twice). */
/** 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) {
async function rollupDay(store, day) {
- const sql = 'SELECT day, COALESCE(subject, ' + SQL_EMPTY + ') AS subject, attribution, system, COALESCE(provider, ' + SQL_EMPTY + ') AS provider,'
+ const sql = 'SELECT day, COALESCE(subject, ' + SQL_EMPTY + ') AS subject, attribution, system, surface, 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,'
+ ' 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_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(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(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(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'
+ ' 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';
+ + ' FROM report_ai_usage WHERE day = ?::date GROUP BY day, subject, attribution, system, surface, provider, model, paid_by';
const rows = await store.db.prepare(sql).all(day);
const rows = await store.db.prepare(sql).all(day);
await store.db.prepare('DELETE FROM report_ai_usage_days WHERE day = ?::date').run(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,'
+ const insert = 'INSERT INTO report_ai_usage_days (day, subject, attribution, system, surface, 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,'
+ ' 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)'
+ ' latency_ms, attempts, updated_at)'
- + ' VALUES (?::date, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())';
+ + ' VALUES (?::date, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())';
for (const r of rows) {
for (const r of rows) {
await store.db.prepare(insert)
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,
+ .run(r.day, r.subject, r.attribution, r.system, r.surface, 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,
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);
money(r.list_cost_usd), money(r.charged_usd), money(r.fee_usd), money(r.cache_saved_usd), r.latency_ms, r.attempts);
}
}
@@ -250,11 +258,203 @@ async function eraseFor(store, subject) {
@@ -250,11 +258,203 @@ async function eraseFor(store, subject) {
return { subject, records: Number(records && records.changes) || 0, days: Number(days && days.changes) || 0 };
return { subject, records: Number(records && records.changes) || 0, days: Number(days && days.changes) || 0 };
}
}
+// ── One person's own rows (plan L41 R1) ────────────────────────────────────────────────────────────────────────
+
+const dayRegex = /^\d{4}-\d{2}-\d{2}$/;
+
+/** A day (YYYY-MM-DD, or the day of an ISO timestamp), or null when it is not a real date. */
+function parsePersonDay(value) {
+ const s = String(value == null ? '' : value).slice(0, 10);
+ if (!dayRegex.test(s)) return null;
+ const d = new Date(`${s}T00:00:00Z`);
+ return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s ? null : s;
+}
+
+/** A day a number of days after (or before) another. */
+const addDays = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * DAY_MS).toISOString().slice(0, 10);
+const daysBetween = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / DAY_MS);
+
+/**
+ * The range of a personal answer: the last 30 days by default, and never more than 400. A from/to pair may be a
+ * day or a timestamp; an impossible one throws a RangeError the route answers as 422.
+ */
+function personRange({ from = null, to = null, now = Date.now() } = {}) {
+ const today = dayOf(now);
+ const toDay = to == null || to === '' ? today : parsePersonDay(to);
+ if (!toDay) throw new RangeError('to must be a day (YYYY-MM-DD)');
+ let fromDay = from == null || from === '' ? addDays(toDay, -(DEFAULT_RANGE_DAYS - 1)) : parsePersonDay(from);
+ if (!fromDay) throw new RangeError('from must be a day (YYYY-MM-DD)');
+ if (fromDay > toDay) throw new RangeError('from must be on or before to');
+ // At most 400 days: an older from is raised to the first day of a 400-day window ending at to.
+ const earliest = addDays(toDay, -(MAX_RANGE_DAYS - 1));
+ if (fromDay < earliest) fromDay = earliest;
+ return { from: fromDay, to: toDay, days: daysBetween(fromDay, toDay) + 1 };
+}
+
+/** The column each of a person's groups buckets by. The time groups read the day and are rolled up here. */
+const PERSON_BUCKETS = {
+ day: 'day::text', week: 'day::text', month: 'day::text',
+ provider: 'COALESCE(provider, ' + SQL_EMPTY + ')',
+ model: 'COALESCE(model, ' + SQL_EMPTY + ')',
+ surface: 'surface',
+ paid_by: 'paid_by',
+};
+
+/** The same measures from the raw records (one row per call) and from the roll-ups (one row per day and group). */
+const PERSON_MEASURES_RAW = 'COUNT(*)::bigint AS calls, SUM(tokens_input)::bigint AS input, SUM(tokens_output)::bigint AS output,'
+ + ' SUM(tokens_cache_read)::bigint AS cached, SUM(tokens_cache_write)::bigint AS cache_write,'
+ + ' SUM(list_cost_usd) AS cost, SUM(fee_usd) AS fee, SUM(charged_usd) AS charged, SUM(cache_saved_usd) AS saved';
+const PERSON_MEASURES_DAYS = 'SUM(calls)::bigint AS calls, SUM(tokens_input)::bigint AS input, SUM(tokens_output)::bigint AS output,'
+ + ' SUM(tokens_cache_read)::bigint AS cached, SUM(tokens_cache_write)::bigint AS cache_write,'
+ + ' SUM(list_cost_usd) AS cost, SUM(fee_usd) AS fee, SUM(charged_usd) AS charged, SUM(cache_saved_usd) AS saved';
+
+/** One person's rows of one bucket from one table (the caller passes an internal table and measure). */
+async function personBuckets(store, { table, measure, bucket, subject, from, to }) {
+ const sql = 'SELECT ' + bucket + ' AS bucket, ' + measure + ' FROM ' + table
+ + ' WHERE subject = ? AND day >= ?::date AND day <= ?::date'
+ + ' GROUP BY ' + bucket + ' ORDER BY ' + bucket;
+ return await store.db.prepare(sql).all(subject, from, to);
+}
+
+const emptyPersonMeasure = () => ({ calls: 0, input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, cost_usd: 0, fee_usd: 0, charged_usd: 0, saved_usd: 0 });
+
+/** One SQL row as the person's measures. */
+function personMeasure(r) {
+ return {
+ calls: int(r.calls), input_tokens: int(r.input), output_tokens: int(r.output),
+ cached_tokens: int(r.cached), cache_write_tokens: int(r.cache_write),
+ cost_usd: num(r.cost), fee_usd: num(r.fee), charged_usd: num(r.charged), saved_usd: num(r.saved),
+ };
+}
+
+/** Add one measure into another (the first is changed). */
+function addPersonMeasure(total, m) {
+ total.calls += m.calls;
+ total.input_tokens += m.input_tokens;
+ total.output_tokens += m.output_tokens;
+ total.cached_tokens += m.cached_tokens;
+ total.cache_write_tokens += m.cache_write_tokens;
+ total.cost_usd += m.cost_usd;
+ total.fee_usd += m.fee_usd;
+ total.charged_usd += m.charged_usd;
+ total.saved_usd += m.saved_usd;
+ return total;
+}
+
+/** The measure as it is answered: whole counts, money to six decimals. */
+function roundedPersonMeasure(m) {
+ return {
+ calls: int(m.calls),
+ input_tokens: int(m.input_tokens),
+ output_tokens: int(m.output_tokens),
+ cached_tokens: int(m.cached_tokens),
+ cache_write_tokens: int(m.cache_write_tokens),
+ cost_usd: money(m.cost_usd),
+ fee_usd: money(m.fee_usd),
+ charged_usd: money(m.charged_usd),
+ saved_usd: money(m.saved_usd),
+ };
+}
+
+/** The key of a bucket: the Monday for a week, the month for a month, 'unknown' for an empty provider or surface. */
+function personKey(group, bucket) {
+ const value = bucket == null ? EMPTY : String(bucket);
+ if (group === 'week') return weekOf(value);
+ if (group === 'month') return value.slice(0, 7);
+ return value || 'unknown';
+}
+
+/** What each way of paying cost over the same split of raw and roll-up tables. */
+async function paidByTotals(store, splits, subject) {
+ const map = new Map();
+ for (const split of splits) {
+ for (const r of await personBuckets(store, { ...split, bucket: 'paid_by', subject })) {
+ const key = String(r.bucket == null ? EMPTY : r.bucket) || 'unknown';
+ let m = map.get(key);
+ if (!m) { m = emptyPersonMeasure(); map.set(key, m); }
+ addPersonMeasure(m, personMeasure(r));
+ }
+ }
+ const out = {};
+ for (const [key, m] of [...map.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) out[key] = roundedPersonMeasure(m);
+ return out;
+}
+
+/** $0.41, or 'less than $0.01' — never a saving printed as $0.00. */
+function usdText(v) {
+ const n = money(v);
+ if (n <= 0) return '$0.00';
+ return n < 0.01 ? 'less than $0.01' : `$${n.toFixed(2)}`;
+}
+
+/** The words a range's length deserves in a sentence. */
+function periodText(days) {
+ if (days <= 1) return 'today';
+ if (days <= 7) return 'this week';
+ if (days <= 31) return 'this month';
+ return `in the last ${days} days`;
+}
+
+/**
+ * Two or three plain lines worked out from the numbers — rules, never an AI call. With no calls there is one.
+ */
+function personInsights({ totals, by_paid_by, days }) {
+ if (!totals.calls) return ['No AI calls in this window.'];
+ const own = by_paid_by.own_key || { calls: 0 };
+ const share = Math.round((own.calls / totals.calls) * 100);
+ const lines = [];
+ if (share >= 100) lines.push('All your calls ran on your own keys, so no fee.');
+ else if (share > 0) lines.push(`${share}% of your calls ran on your own keys, so no fee on those.`);
+ else lines.push('None of your calls ran on your own keys.');
+ if (totals.saved_usd > 0) lines.push(`The cache saved you ${usdText(totals.saved_usd)} ${periodText(days)}.`);
+ else lines.push('No cache savings in this window.');
+ if (totals.fee_usd > 0) lines.push(`Your calls cost ${usdText(totals.charged_usd)}, including ${usdText(totals.fee_usd)} in fees.`);
+ else if (totals.charged_usd > 0) lines.push(`Your calls cost ${usdText(totals.charged_usd)}, with no fee.`);
+ else lines.push('OpenVibe charged you nothing for these calls.');
+ return lines;
+}
+
+/**
+ * One person's own usage (plan L41 R1): their rows of one group, the totals, what each way of paying cost and two
+ * or three plain lines. Raw records answer a range inside the 90-day retention; the roll-ups answer the part
+ * beyond it, and `source` says which was read. A subject is required and is never taken from the query.
+ */
+async function personUsage(store, { subject, from = null, to = null, group = 'day', now = Date.now() } = {}) {
+ if (!subject) throw new TypeError('personUsage needs a subject');
+ if (!PERSON_GROUPS.includes(group)) throw new RangeError('group must be one of ' + PERSON_GROUPS.join(', '));
+ const range = personRange({ from, to, now });
+ // The raw records run from the retention floor (the day prune keeps) to today; the roll-ups answer before it.
+ const floor = addDays(dayOf(now), -RAW_RETENTION_DAYS);
+ const splits = [];
+ if (range.from < floor) {
+ splits.push({ table: 'report_ai_usage_days', measure: PERSON_MEASURES_DAYS, kind: 'rollups', from: range.from, to: range.to < addDays(floor, -1) ? range.to : addDays(floor, -1) });
+ }
+ if (range.to >= floor) {
+ splits.push({ table: 'report_ai_usage', measure: PERSON_MEASURES_RAW, kind: 'raw', from: range.from > floor ? range.from : floor, to: range.to });
+ }
+ const source = splits.length === 2 ? 'raw+rollups' : splits[0].kind;
+ const map = new Map();
+ for (const split of splits) {
+ for (const r of await personBuckets(store, { ...split, bucket: PERSON_BUCKETS[group], subject })) {
+ const key = personKey(group, r.bucket);
+ let m = map.get(key);
+ if (!m) { m = emptyPersonMeasure(); map.set(key, m); }
+ addPersonMeasure(m, personMeasure(r));
+ }
+ }
+ const rows = [...map.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([key, m]) => ({ key, ...roundedPersonMeasure(m) }));
+ const totals = roundedPersonMeasure(rows.reduce((t, r) => addPersonMeasure(t, r), emptyPersonMeasure()));
+ const by_paid_by = await paidByTotals(store, splits, subject);
+ return { group, rows, totals, by_paid_by, insights: personInsights({ totals, by_paid_by, days: range.days }), source };
+}
+
/** The ledger as one object: the routes, the job and the account tools share it. */
/** The ledger as one object: the routes, the job and the account tools share it. */
function createAiUsage({ store, now = () => Date.now(), log = console } = {}) {
function createAiUsage({ store, now = () => Date.now(), log = console } = {}) {
if (!store) return null;
if (!store) return null;
return {
return {
store,
store,
+ groups: PERSON_GROUPS,
+ forPerson: (opts = {}) => personUsage(store, { now: now(), ...opts }),
usage: (opts = {}) => usage(store, opts),
usage: (opts = {}) => usage(store, opts),
openTotals: (opts = {}) => openTotals(store, opts),
openTotals: (opts = {}) => openTotals(store, opts),
totalsOf,
totalsOf,
@@ -265,4 +465,8 @@ function createAiUsage({ store, now = () => Date.now(), log = console } = {}) {
@@ -265,4 +465,8 @@ function createAiUsage({ store, now = () => Date.now(), log = console } = {}) {
recordUsage,
recordUsage,
};
};
}
}
-module.exports = { GROUPS, RAW_RETENTION_DAYS, PEOPLE_FLOOR, recordKey, recordUsage, rollup, rollupDay, prune, usage, totalsOf, daily, openTotals, exportFor, eraseFor, createAiUsage, weekOf, dayOf, money };
+module.exports = {
+ GROUPS, PERSON_GROUPS, RAW_RETENTION_DAYS, DEFAULT_RANGE_DAYS, MAX_RANGE_DAYS, PEOPLE_FLOOR,
+ recordKey, recordUsage, rollup, rollupDay, prune, usage, personUsage, personRange, totalsOf, daily, openTotals,
+ exportFor, eraseFor, createAiUsage, weekOf, dayOf, money, usdText, periodText, personInsights,
+};
@@ -100,6 +100,8 @@ function load(env = process.env) {
@@ -100,6 +100,8 @@ function load(env = process.env) {
// One caller may make this many API requests a minute (express-rate-limit, keyed by address).
// One caller may make this many API requests a minute (express-rate-limit, keyed by address).
apiRateLimitPerMinute: Math.max(1, int(env.REPORT_API_RATE_LIMIT_PER_MIN, 120)),
apiRateLimitPerMinute: Math.max(1, int(env.REPORT_API_RATE_LIMIT_PER_MIN, 120)),
+ // A person's own narrow routes (GET /api/v1/me/ai/usage) have their own, tighter per-address limit.
+ meRateLimitPerMinute: Math.max(1, int(env.REPORT_ME_RATE_LIMIT_PER_MIN, 60)),
// How long a readiness probe may take before the source is reported as not answering.
// How long a readiness probe may take before the source is reported as not answering.
readyTimeoutMs: Math.max(200, int(env.REPORT_READY_TIMEOUT_MS, 2500)),
readyTimeoutMs: Math.max(200, int(env.REPORT_READY_TIMEOUT_MS, 2500)),
@@ -13,12 +13,16 @@
@@ -13,12 +13,16 @@
* A bad parameter is checked here, before the source is asked, and is a 404 problem+json, exactly like a source
* A bad parameter is checked here, before the source is asked, and is a 404 problem+json, exactly like a source
* that answered nothing. Every /api/v1 answer is CORS-open and public for two minutes; the app mounts the 120/min
* that answered nothing. Every /api/v1 answer is CORS-open and public for two minutes; the app mounts the 120/min
* per-address limit in front of this router.
* per-address limit in front of this router.
+ *
+ * GET /api/v1/me/ai/usage is the exception: it answers only the person their session names, so it is private, never
+ * cached, and has its own tighter per-address limit.
*/
*/
const express = require('express');
const express = require('express');
+const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
const { createServiceAuth, CAPS } = require('../auth');
const { createServiceAuth, CAPS } = require('../auth');
-const { GROUPS: AI_USAGE_GROUPS } = require('../analytics/ai-usage');
+const { GROUPS: AI_USAGE_GROUPS, PERSON_GROUPS } = require('../analytics/ai-usage');
const GROUPS = Object.keys(AI_USAGE_GROUPS);
const GROUPS = Object.keys(AI_USAGE_GROUPS);
const CACHE = 'public, max-age=120';
const CACHE = 'public, max-age=120';
@@ -37,6 +41,19 @@ function createApi({ network, live, analytics = null, aiUsage = null, config = {
@@ -37,6 +41,19 @@ function createApi({ network, live, analytics = null, aiUsage = null, config = {
return next();
return next();
});
});
+ // The personal routes: private and never cached, even when they refuse the caller, and their own limit.
+ const personalLimit = rateLimit({
+ windowMs: 60_000,
+ limit: Math.max(1, Number(config.meRateLimitPerMinute) || 60),
+ standardHeaders: true,
+ legacyHeaders: false,
+ handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
+ });
+ r.use('/me', (req, res, next) => {
+ res.set('Cache-Control', 'private, no-store');
+ next();
+ }, personalLimit);
+
function problem(req, res, status, code, detail) {
function problem(req, res, status, code, detail) {
return contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
return contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
}
}
@@ -121,10 +138,26 @@ function createApi({ network, live, analytics = null, aiUsage = null, config = {
@@ -121,10 +138,26 @@ function createApi({ network, live, analytics = null, aiUsage = null, config = {
}
}
});
});
- // 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.'));
+ // The person's own rows (plan L41 R1). The subject is the signed-in person from Report's sso session (cookie or
+ // bearer), never the query; signed out is 401. The last 30 days by default, 400 at most; raw records answer a
+ // range inside the 90-day retention and the roll-ups the part beyond it. Private: the /me middleware above sets
+ // private, no-store and the tighter limit, and the answer adds the lines worked out from its own numbers.
+ r.get('/me/ai/usage', async (req, res) => {
+ const user = req.user;
+ const subject = user && typeof user.subject_id === 'string' ? user.subject_id.trim() : '';
+ if (!subject) return problem(req, res, 401, 'auth.sign_in_required', 'Sign in to see your own AI usage.');
+ if (!aiUsage) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');
+ const group = String(req.query.group || 'day');
+ if (!PERSON_GROUPS.includes(group)) return problem(req, res, 422, 'ai.invalid', 'group must be one of ' + PERSON_GROUPS.join(', '));
+ try {
+ const body = await aiUsage.forPerson({ from: req.query.from || null, to: req.query.to || null, group, subject, now: now() });
+ return res.json(body);
+ } catch (err) {
+ if (err instanceof RangeError) return problem(req, res, 422, 'ai.invalid', err.message);
+ log.error('[OpenVibe.Report] the personal AI usage rows failed:', err && err.message ? String(err.message).slice(0, 200) : err);
+ return problem(req, res, 500, 'report.ai_usage_failed', 'Your AI usage could not be read.');
+ }
+ });
r.use((req, res) => notFound(req, res, 'route.not_found', 'No such API route.'));
r.use((req, res) => notFound(req, res, 'route.not_found', 'No such API route.'));
return r;
return r;
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -0,0 +1,199 @@
+'use strict';
+/**
+ * A person's own AI usage (plan L41 R1): GET /api/v1/me/ai/usage.
+ *
+ * The subject is the signed-in person and only their session names it; signed out is 401, a service principal is
+ * not a session and another person's records never appear. The groups, the totals, what each way of paying cost,
+ * the two or three plain lines, the 30-day default and the 400-day cap, the raw records inside the 90-day
+ * retention and the roll-ups beyond it, the private headers and the tighter personal limit.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { createSsoClient } = require('openvibe-sdk/sso');
+const { boot, check, done } = require('./helpers/boot');
+const ledger = require('../server/analytics/ai-usage');
+
+const NOW = Date.parse('2026-10-31T12:00:00.000Z');
+const A = 'usr_01JTESTPERSONA0000000000000';
+const B = 'usr_01JTESTPERSONB0000000000000';
+const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+/** A person's access token, the way OpenVibe.Network signs one. */
+function token(claims) {
+ const now = Math.floor(Date.now() / 1000);
+ const head = b64({ alg: 'RS256', typ: 'JWT', kid: 'test' });
+ const body = b64({ iss: 'https://openvibe.network', aud: ['openvibe.network', 'openvibe.report'], iat: now, exp: now + 3600, ...claims });
+ const sig = crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), privateKey).toString('base64url');
+ return `${head}.${body}.${sig}`;
+}
+const alice = token({ sub: '1', subject_id: A, username: 'alice', display_name: 'Alice' });
+const bob = token({ sub: '2', subject_id: B, username: 'bob', display_name: 'Bob' });
+const service = token({ sub: 'svc:operator', actor_type: 'service', subject_id: A, username: 'operator' });
+
+const sso = createSsoClient({
+ site: 'report', baseUrl: 'https://openvibe.report', clientId: 'report', clientSecret: 'x',
+ networkUrl: 'https://openvibe.network', publicKey: publicKey.export({ type: 'spki', format: 'pem' }),
+ log: { warn() {}, info() {}, log() {}, error() {} },
+});
+
+const MEASURES = ['calls', 'input_tokens', 'output_tokens', 'cached_tokens', 'cache_write_tokens', 'cost_usd', 'fee_usd', 'charged_usd', 'saved_usd'];
+
+(async () => {
+ const t = await boot({ db: true, now: () => NOW, sso });
+ const store = t.ctx.store;
+ let seq = 0;
+ async function addCall(p = {}) {
+ seq += 1;
+ await store.tx((tx) => ledger.recordUsage(tx, {
+ system: 'ai', run_id: `run_${String(seq).padStart(4, '0')}`, subject: A,
+ attribution: 'site:design', surface: 'ai.generate', provider: 'anthropic', model: 'claude-sonnet-4-5',
+ paid_by: 'own_key', input: 100, output: 10, reasoning: 0, cache_read: 0, cache_write: 0,
+ list_cost_usd: 0.01, charged_usd: 0, fee_usd: 0, cache_saved_usd: 0,
+ price_source: 'models', outcome: 'succeeded', at: '2026-10-31T10:00:00.000Z',
+ ...p,
+ }));
+ }
+ try {
+ // Alice: three October calls on three days plus a September one, one of them on the OpenVibe key; an older call
+ // (March 2026), the first day a 400-day window reaching back from 2026-10-31 keeps (2025-09-27), and the day
+ // before it. Bob has one call of his own, and must never be in Alice's answer.
+ await addCall({ at: '2026-10-31T10:00:00.000Z', input: 200, output: 80, cache_read: 800, list_cost_usd: 0.0021, cache_saved_usd: 0.0012 });
+ await addCall({ at: '2026-10-30T10:00:00.000Z', provider: 'openai', model: 'gpt-5', surface: 'codes.turn', paid_by: 'openvibe_key', input: 1000, output: 500, cache_write: 100, list_cost_usd: 0.05, charged_usd: 0.06, fee_usd: 0.01 });
+ await addCall({ at: '2026-10-26T10:00:00.000Z', input: 50, output: 10, list_cost_usd: 0.001, cache_saved_usd: 0.0005 });
+ await addCall({ at: '2026-09-15T10:00:00.000Z', provider: 'openai', model: 'gpt-5', input: 300, output: 30, cache_read: 100, list_cost_usd: 0.004, cache_saved_usd: 0.0002 });
+ await addCall({ at: '2026-03-01T10:00:00.000Z', provider: 'oldprov', model: 'old-model', surface: 'old.surface', input: 7, output: 3, list_cost_usd: 0.0007 });
+ await addCall({ at: '2025-09-27T10:00:00.000Z', provider: 'capprov', model: 'cap-model', surface: 'cap.surface', input: 11, output: 1, list_cost_usd: 0.0011 });
+ await addCall({ at: '2025-09-26T10:00:00.000Z', provider: 'beyondprov', model: 'beyond-model', surface: 'beyond.surface', input: 13, output: 1, list_cost_usd: 0.0013 });
+ await addCall({ subject: B, at: '2026-10-31T11:00:00.000Z', provider: 'google', model: 'gemini-bob', surface: 'bobs.surface', paid_by: 'org_key', input: 9000, output: 9000, list_cost_usd: 9, charged_usd: 9, fee_usd: 9 });
+ // Roll the records up, then prune the raw ones the 90-day retention drops: the old range is answered from
+ // the roll-ups, and the recent one from the raw records.
+ await ledger.rollup(store, { days: 500 });
+ await ledger.prune(store, { now: NOW });
+
+ await check('signed out is a private 401, and a service token is not a session', async () => {
+ const out = await t.get('/api/v1/me/ai/usage');
+ assert.strictEqual(out.status, 401, out.text);
+ assert.strictEqual(out.json().code, 'auth.sign_in_required');
+ assert.strictEqual(out.headers.get('cache-control'), 'private, no-store');
+ const svc = await t.get('/api/v1/me/ai/usage', { headers: { authorization: 'Bearer ' + service } });
+ assert.strictEqual(svc.status, 401, 'a service principal is not a person');
+ });
+
+ await check('the last 30 days by default, only the person in the session, raw records', async () => {
+ const r = await t.get('/api/v1/me/ai/usage', { headers: { cookie: `ov_token=${alice}` } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ const body = r.json();
+ assert.strictEqual(body.group, 'day');
+ assert.strictEqual(body.source, 'raw', 'a recent range reads the raw records');
+ assert.deepStrictEqual(body.rows.map((x) => x.key), ['2026-10-26', '2026-10-30', '2026-10-31'], 'the default window starts on 2026-10-02');
+ assert.strictEqual(body.totals.calls, 3, 'three calls in the last 30 days');
+ const text = JSON.stringify(body);
+ for (const leak of ['gemini-bob', 'org_key', 'bobs.surface', 'google', B]) assert.ok(!text.includes(leak), 'another person must not be named: ' + leak);
+ const asked = await t.get('/api/v1/me/ai/usage?subject=' + B, { headers: { cookie: `ov_token=${alice}` } });
+ assert.strictEqual(asked.status, 200, asked.text);
+ assert.strictEqual(asked.json().totals.calls, 3, 'the query cannot name another subject');
+ const bearer = await t.get('/api/v1/me/ai/usage', { headers: { authorization: 'Bearer ' + alice } });
+ assert.strictEqual(bearer.status, 200, 'a bearer session is a session too');
+ assert.deepStrictEqual(bearer.json().totals, body.totals);
+ const other = await t.get('/api/v1/me/ai/usage', { headers: { cookie: `ov_token=${bob}` } });
+ assert.strictEqual(other.json().totals.calls, 1, 'Bob reads only Bob');
+ assert.strictEqual(other.json().rows[0].key, '2026-10-31');
+ });
+
+ await check('the seven groups bucket the person’s own rows', async () => {
+ const expected = {
+ day: ['2026-09-15', '2026-10-26', '2026-10-30', '2026-10-31'],
+ week: ['2026-09-14', '2026-10-26'],
+ month: ['2026-09', '2026-10'],
+ provider: ['anthropic', 'openai'],
+ model: ['claude-sonnet-4-5', 'gpt-5'],
+ surface: ['ai.generate', 'codes.turn'],
+ paid_by: ['openvibe_key', 'own_key'],
+ };
+ for (const [group, keys] of Object.entries(expected)) {
+ const r = await t.get(`/api/v1/me/ai/usage?group=${group}&from=2026-09-01&to=2026-10-31`, { headers: { cookie: `ov_token=${alice}` } });
+ assert.strictEqual(r.status, 200, group + ': ' + r.text);
+ const body = r.json();
+ assert.strictEqual(body.group, group);
+ assert.deepStrictEqual(body.rows.map((x) => x.key), keys, group);
+ for (const row of body.rows) for (const k of MEASURES) assert.strictEqual(typeof row[k], 'number', `${group}.${k}`);
+ }
+ });
+
+ await check('the totals and by_paid_by add up to the rows', async () => {
+ for (const group of ['day', 'week', 'month', 'provider', 'model', 'surface', 'paid_by', 'week']) {
+ const body = (await t.get(`/api/v1/me/ai/usage?group=${group}&from=2026-09-01&to=2026-10-31`, { headers: { cookie: `ov_token=${alice}` } })).json();
+ for (const k of MEASURES) {
+ const sum = body.rows.reduce((n, row) => n + row[k], 0);
+ assert.ok(Math.abs(sum - body.totals[k]) < 1e-6, `${group}.${k}: ${sum} vs ${body.totals[k]}`);
+ const paid = Object.values(body.by_paid_by).reduce((n, row) => n + row[k], 0);
+ assert.ok(Math.abs(paid - body.totals[k]) < 1e-6, `by_paid_by.${k}: ${paid} vs ${body.totals[k]}`);
+ }
+ }
+ });
+
+ await check('two or three plain lines, worked out from the numbers', async () => {
+ const body = (await t.get('/api/v1/me/ai/usage?from=2026-09-01&to=2026-10-31', { headers: { cookie: `ov_token=${alice}` } })).json();
+ const lines = body.insights;
+ assert.ok(Array.isArray(lines) && lines.length >= 2 && lines.length <= 3, JSON.stringify(lines));
+ assert.ok(lines.some((l) => /75% of your calls ran on your own keys/.test(l)), lines.join(' | '));
+ assert.ok(lines.some((l) => /cache saved you/.test(l)), lines.join(' | '));
+ assert.ok(lines.some((l) => /cost|charged you/.test(l)), lines.join(' | '));
+ const empty = (await t.get('/api/v1/me/ai/usage?from=2026-01-01&to=2026-01-02', { headers: { cookie: `ov_token=${alice}` } })).json();
+ assert.deepStrictEqual(empty.insights, ['No AI calls in this window.']);
+ assert.deepStrictEqual(empty.rows, []);
+ });
+
+ await check('the roll-ups answer a range beyond 90 days, and the split is named', async () => {
+ const old = (await t.get('/api/v1/me/ai/usage?group=surface&from=2026-01-01&to=2026-06-30', { headers: { cookie: `ov_token=${alice}` } })).json();
+ assert.strictEqual(old.source, 'rollups');
+ assert.deepStrictEqual(old.rows.map((x) => x.key), ['old.surface'], 'the roll-up keeps the surface');
+ assert.strictEqual(old.totals.calls, 1);
+ const straddle = (await t.get('/api/v1/me/ai/usage?group=provider&from=2026-01-01&to=2026-10-31', { headers: { cookie: `ov_token=${alice}` } })).json();
+ assert.strictEqual(straddle.source, 'raw+rollups');
+ assert.deepStrictEqual(straddle.rows.map((x) => x.key), ['anthropic', 'oldprov', 'openai']);
+ assert.strictEqual(straddle.totals.calls, 5, 'the March call from the roll-ups and the four raw calls since');
+ const weeks = (await t.get('/api/v1/me/ai/usage?group=week&from=2026-01-01&to=2026-10-31', { headers: { cookie: `ov_token=${alice}` } })).json();
+ assert.strictEqual(weeks.source, 'raw+rollups');
+ assert.deepStrictEqual(weeks.rows.map((x) => x.key), ['2026-02-23', '2026-09-14', '2026-10-26'], 'a week combines both sources');
+ assert.strictEqual(weeks.totals.calls, 5);
+ });
+
+ await check('at most 400 days back: the first day in, the day before out', async () => {
+ const body = (await t.get('/api/v1/me/ai/usage?group=day&from=2024-01-01&to=2026-10-31', { headers: { cookie: `ov_token=${alice}` } })).json();
+ assert.strictEqual(body.source, 'raw+rollups');
+ const keys = body.rows.map((x) => x.key);
+ assert.ok(keys.includes('2025-09-27'), 'the first day of the 400-day window is in');
+ assert.ok(!keys.includes('2025-09-26'), 'the day before it is out');
+ assert.strictEqual(body.totals.calls, 6, 'the cap row, March, September and the three October calls');
+ });
+
+ await check('an impossible range or group is a 422 problem+json', async () => {
+ for (const q of ['group=year', 'from=2026-02-31&to=2026-10-31', 'from=2026-11-01&to=2026-10-01', 'from=nope']) {
+ const r = await t.get('/api/v1/me/ai/usage?' + q, { headers: { cookie: `ov_token=${alice}` } });
+ assert.strictEqual(r.status, 422, q);
+ assert.match(r.headers.get('content-type') || '', /^application\/problem\+json/);
+ assert.strictEqual(r.json().code, 'ai.invalid', q);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ }
+ });
+ } finally { await t.close(); }
+
+ await check('the personal route has its own limit, then a 429 problem', async () => {
+ const limited = await boot({ db: true, now: () => NOW, sso, env: { REPORT_ME_RATE_LIMIT_PER_MIN: '2' } });
+ try {
+ const headers = { cookie: `ov_token=${alice}` };
+ assert.strictEqual((await limited.get('/api/v1/me/ai/usage', { headers })).status, 200);
+ assert.strictEqual((await limited.get('/api/v1/me/ai/usage', { headers })).status, 200);
+ const third = await limited.get('/api/v1/me/ai/usage', { headers });
+ assert.strictEqual(third.status, 429);
+ assert.match(third.headers.get('content-type') || '', /^application\/problem\+json/);
+ assert.strictEqual(third.json().code, 'rate_limited');
+ assert.strictEqual(third.headers.get('cache-control'), 'private, no-store');
+ } finally { await limited.close(); }
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -125,10 +125,11 @@ function delivery(event) {
@@ -125,10 +125,11 @@ function delivery(event) {
assert.strictEqual(body.totals.calls, 1);
assert.strictEqual(body.totals.calls, 1);
});
});
- await check('the route for a person is specified and answers 501 until Report has sign-in', async () => {
+ await check('the route for a person refuses a caller with no session, privately', async () => {
const r = await t.get('/api/v1/me/ai/usage');
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');
+ assert.strictEqual(r.status, 401, r.text);
+ assert.strictEqual(r.json().code, 'auth.sign_in_required');
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
});
});
await check('ADR-033: the records and the roll-ups leave with the account', async () => {
await check('ADR-033: the records and the roll-ups leave with the account', async () => {