openvibers/openvibe.report Commits

Your own AI usage: GET /api/v1/me/ai/usage (L41 R1) (#35)

OP OpenVibers committed 13 days ago · 88739cf60f · 7 files, +463 −15

The person's own ledger rows, the subject from Report's sso session and never the query. Groups by day, week, month, provider, model, surface and paid_by; totals, by_paid_by and two or three plain lines worked out from the numbers. The last 30 days by default, 400 at most; raw records inside the 90-day retention and the roll-ups beyond it, named in source. Private, no-store, with its own tighter per-address limit. The roll-ups gain the surface so the surface group works beyond the raw records.

Changes

ddiff --git a/.env.example b/.env.example
iindex 476cec2..7f059e2 100644
---- a/.env.example
++++ b/.env.example
@@@ -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.
ddiff --git a/migrations/0003_ai_usage_surface.sql b/migrations/0003_ai_usage_surface.sql
nnew file mode 100644
iindex 0000000..9dc27e2
---- /dev/null
++++ b/migrations/0003_ai_usage_surface.sql
@@@ -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);
ddiff --git a/server/analytics/ai-usage.js b/server/analytics/ai-usage.js
iindex 1fc04fa..f2b46d7 100644
---- a/server/analytics/ai-usage.js
++++ b/server/analytics/ai-usage.js
@@@ -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,
++};
ddiff --git a/server/config.js b/server/config.js
iindex 6a1a291..a47ac86 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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)),
ddiff --git a/server/http/api.js b/server/http/api.js
iindex a914615..f4da000 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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;
ddiff --git a/test/ai-usage-me.test.js b/test/ai-usage-me.test.js
nnew file mode 100644
iindex 0000000..395467b
---- /dev/null
++++ b/test/ai-usage-me.test.js
@@@ -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); });
ddiff --git a/test/ai-usage.test.js b/test/ai-usage.test.js
iindex 38d0279..949e1a4 100644
---- a/test/ai-usage.test.js
++++ b/test/ai-usage.test.js
@@@ -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 () => {