import { Router, Request, Response } from 'express'; import type { Repository, LlmUsageHourlyRow } from '../db/repository.js'; import { logger } from '../logger.js'; /** * Per-user LLM usage dashboard API (v2). Reads the hour-grain llm_usage_hourly * ledger (gateway + direct, recorded at the OpenAICompatClient completion * boundary) and shapes a multi-series time series for the Usage tab. * * Two axes the caller controls: * - groupBy: which dimension becomes the chart's series — source | model | * route | user | org. Defaults to 'source' (gateway vs direct). * - tzOffset: viewer's local offset in minutes (-getTimezoneOffset(), so JST * is +540). UTC hours are re-bucketed into the viewer's local calendar * period so "today" matches the wall clock, not UTC. * * Visibility: admin (and the no-auth single-user local mode) see every user's * usage; a non-admin authenticated user sees only their own rows. This is a * separate lens from the gateway per-key billing view — the two are never * summed. * * Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md */ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; /** True only for a real calendar day in 'YYYY-MM-DD' (rejects 2026-99-99). */ function isValidDay(s: unknown): s is string { if (typeof s !== 'string' || !DAY_RE.test(s)) return false; const d = new Date(`${s}T00:00:00.000Z`); return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s; } const MAX_RANGE_DAYS = 800; // ~2y guard so a hand-crafted range can't scan unbounded const MAX_TZ_OFFSET = 14 * 60; // clamp to the real-world UTC-14..+14 envelope const MAX_SERIES = 12; // cap distinct series; the rest fold into an 'other' bucket type Granularity = 'hour' | 'day' | 'week' | 'month'; type GroupBy = 'source' | 'model' | 'route' | 'user' | 'org'; const GROUP_BYS: readonly GroupBy[] = ['source', 'model', 'route', 'user', 'org']; interface Counters { tokensIn: number; tokensOut: number; requests: number; } function emptyCounters(): Counters { return { tokensIn: 0, tokensOut: 0, requests: 0 }; } function addInto(target: Counters, row: { tokensIn: number; tokensOut: number; requests: number }): void { target.tokensIn += row.tokensIn; target.tokensOut += row.tokensOut; target.requests += row.requests; } function localTotal(c: Counters): number { return c.tokensIn + c.tokensOut; } /** Viewer's local 'today' as 'YYYY-MM-DD' given their tz offset (minutes). */ function localToday(tzOffsetMin: number): string { return new Date(Date.now() + tzOffsetMin * 60_000).toISOString().slice(0, 10); } /** day + deltaDays, as 'YYYY-MM-DD' (calendar arithmetic via UTC midnight). */ function shiftDay(day: string, deltaDays: number): string { const d = new Date(`${day}T00:00:00.000Z`); d.setUTCDate(d.getUTCDate() + deltaDays); return d.toISOString().slice(0, 10); } /** Inclusive day count between two 'YYYY-MM-DD'. */ function dayDiff(from: string, to: string): number { const a = Date.parse(`${from}T00:00:00.000Z`); const b = Date.parse(`${to}T00:00:00.000Z`); return Math.round((b - a) / 86_400_000); } /** ISO-8601 week key 'YYYY-Www' for a 'YYYY-MM-DD' day. */ function isoWeekKey(day: string): string { const d = new Date(`${day}T00:00:00.000Z`); // ISO week: Thursday of the current week decides the year. const dayNum = (d.getUTCDay() + 6) % 7; // Mon=0 .. Sun=6 d.setUTCDate(d.getUTCDate() - dayNum + 3); const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4)); const firstDayNum = (firstThursday.getUTCDay() + 6) % 7; firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3); const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86_400_000)); return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`; } function bucketKey(day: string, granularity: Granularity): string { if (granularity === 'month') return day.slice(0, 7); if (granularity === 'week') return isoWeekKey(day); return day; } /** * Local calendar day of a UTC hour 'YYYY-MM-DDTHH' for a viewer at tzOffsetMin. * Shifting the UTC instant by the offset and slicing the date component lands * on the viewer's wall-clock day (e.g. UTC 2026-06-10T15 + 540min → 2026-06-11). */ function localDayOf(utcHour: string, tzOffsetMin: number): string { const ms = Date.parse(`${utcHour}:00:00.000Z`) + tzOffsetMin * 60_000; return new Date(ms).toISOString().slice(0, 10); } /** * Local hour-of-day '00'..'23' of a UTC hour for a viewer at tzOffsetMin. Used * by granularity='hour' to fold every day in the range into a 24-bucket * time-of-day profile ("what time of day is the backend busy"). */ function localHourOfDay(utcHour: string, tzOffsetMin: number): string { const ms = Date.parse(`${utcHour}:00:00.000Z`) + tzOffsetMin * 60_000; return new Date(ms).toISOString().slice(11, 13); } function parseTzOffset(raw: unknown): number { const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN; if (!Number.isFinite(n)) return 0; return Math.max(-MAX_TZ_OFFSET, Math.min(MAX_TZ_OFFSET, n)); } /** * Human-friendly label for a usage owner id. Real users resolve to their name * (or email); the 'local' / 'system' sentinels are returned verbatim so the UI * can localize them. Falls back to the raw id when no user row exists. */ function resolveDisplayName(repo: Repository, userId: string): string { if (userId === 'local' || userId === 'system') return userId; const u = repo.getUserById(userId); return u?.name || u?.email || userId; } /** Series key (and its human label) for a row under the chosen groupBy. */ function dimensionOf( row: LlmUsageHourlyRow, groupBy: GroupBy, orgMap: Map, ): string { switch (groupBy) { case 'model': return row.model || 'unknown'; case 'route': return row.route || 'unknown'; case 'user': return row.userId; case 'org': return orgMap.get(row.userId) ?? 'no-org'; case 'source': default: // Known sources pass through verbatim (incl. 'gateway_downstream' for // external gateway consumers); anything unexpected folds into 'direct'. return row.source === 'gateway' || row.source === 'gateway_downstream' ? row.source : 'direct'; } } export function createUsageRouter(repo: Repository, opts: { authActive: boolean }): Router { const router = Router(); // GET /daily?from&to&granularity=day|week|month&groupBy=source|model|route|user|org&tzOffset= router.get('/daily', (req: Request, res: Response) => { try { const tzOffset = parseTzOffset(req.query['tzOffset']); const to = isValidDay(req.query['to']) ? req.query['to'] : localToday(tzOffset); const from = isValidDay(req.query['from']) ? req.query['from'] : shiftDay(to, -29); if (from > to) { res.status(400).json({ error: 'from must be on or before to' }); return; } if (dayDiff(from, to) > MAX_RANGE_DAYS) { res.status(400).json({ error: `range too large (max ${MAX_RANGE_DAYS} days)` }); return; } const gq = req.query['granularity']; const granularity: Granularity = gq === 'hour' || gq === 'week' || gq === 'month' ? gq : 'day'; const gbq = req.query['groupBy']; const groupBy: GroupBy = typeof gbq === 'string' && (GROUP_BYS as readonly string[]).includes(gbq) ? (gbq as GroupBy) : 'source'; // Visibility: a non-admin authenticated user is scoped to their own rows. // Admin and the no-auth local mode see everyone. const user = req.user as Express.User | undefined; const isAdmin = !opts.authActive || user?.role === 'admin'; const scopeUserId = isAdmin ? undefined : (user?.id ?? 'local'); // Widen the UTC scan by ±1 day so every hour that maps into a local day // within [from, to] is fetched, then filter precisely against localDay. const fromHour = `${shiftDay(from, -1)}T00`; const toHour = `${shiftDay(to, 1)}T23`; const rows = repo.queryLlmUsageHourly({ fromHour, toHour, userId: scopeUserId }); const orgMap = groupBy === 'org' ? repo.getUsageOrgMap() : new Map(); // First pass: accumulate per (bucket, dimension) and per-dimension totals, // plus the admin per-user table (independent of groupBy). const buckets = new Map>(); const totals = new Map(); const byUser = new Map(); for (const row of rows) { const localDay = localDayOf(row.hour, tzOffset); if (localDay < from || localDay > to) continue; // precise local-range filter // hour granularity folds the whole range into 24 local hour-of-day // buckets; the other granularities bucket the local calendar day. const bk = granularity === 'hour' ? localHourOfDay(row.hour, tzOffset) : bucketKey(localDay, granularity); const dim = dimensionOf(row, groupBy, orgMap); let seg = buckets.get(bk); if (!seg) { seg = new Map(); buckets.set(bk, seg); } let c = seg.get(dim); if (!c) { c = emptyCounters(); seg.set(dim, c); } addInto(c, row); let t = totals.get(dim); if (!t) { t = emptyCounters(); totals.set(dim, t); } addInto(t, row); if (isAdmin) { let u = byUser.get(row.userId); if (!u) { u = emptyCounters(); byUser.set(row.userId, u); } addInto(u, row); } } // Order series keys: source keeps a fixed gateway→direct order; every // other axis orders by total tokens desc. Beyond MAX_SERIES, fold the // tail into a visible 'other' bucket so the legend/palette stay sane. let keys: string[]; if (groupBy === 'source') { keys = ['gateway', 'direct', 'gateway_downstream'].filter((k) => totals.has(k)); } else { const ordered = Array.from(totals.keys()).sort( (a, b) => localTotal(totals.get(b)!) - localTotal(totals.get(a)!), ); keys = ordered; if (ordered.length > MAX_SERIES) { const keep = ordered.slice(0, MAX_SERIES - 1); const fold = new Set(ordered.slice(MAX_SERIES - 1)); // Re-key folded dimensions into 'other' across totals + every bucket. // A real dimension literally named 'other' may survive in `keep`; in // that case MERGE the folded tail into it (never overwrite) and avoid // a duplicate key, so the legend/totals stay consistent. const otherTotal = totals.get('other') ?? emptyCounters(); for (const k of fold) { addInto(otherTotal, totals.get(k)!); totals.delete(k); } totals.set('other', otherTotal); for (const seg of buckets.values()) { const otherSeg = emptyCounters(); let touched = false; for (const k of fold) { const c = seg.get(k); if (c) { addInto(otherSeg, c); seg.delete(k); touched = true; } } if (touched) { const existing = seg.get('other'); if (existing) addInto(existing, otherSeg); else seg.set('other', otherSeg); } } keys = keep.includes('other') ? keep : [...keep, 'other']; logger.info( `[usage-api] groupBy=${groupBy} folded ${fold.size} series into 'other' (cap=${MAX_SERIES})`, ); } } // labels: for the 'user' axis, opaque ids resolve to display names; every // other axis is already human-readable (UI localizes source/org/other). // The synthetic 'other' fold bucket is left unlabelled so the UI can // localize it (otherwise resolveDisplayName would echo the raw 'other'). const labels: Record = {}; if (groupBy === 'user') { for (const k of keys) if (k !== 'other') labels[k] = resolveDisplayName(repo, k); } const series = Array.from(buckets.entries()) .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) .map(([bucket, seg]) => { const segments: Record = {}; for (const k of keys) segments[k] = seg.get(k) ?? emptyCounters(); return { bucket, segments }; }); const totalsOut: Record = {}; for (const k of keys) totalsOut[k] = totals.get(k) ?? emptyCounters(); res.json({ from, to, granularity, groupBy, tzOffset, scope: isAdmin ? 'all' : 'self', keys, labels, series, totals: totalsOut, ...(isAdmin ? { byUser: Array.from(byUser.entries()) .map(([userId, c]) => ({ userId, displayName: resolveDisplayName(repo, userId), ...c })) .sort((a, b) => (b.tokensIn + b.tokensOut) - (a.tokensIn + a.tokensOut)), } : {}), }); } catch (e) { logger.error(`[usage-api] /daily failed: ${String(e)}`); res.status(500).json({ error: 'Failed to load usage' }); } }); return router; }