323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
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, string>,
|
|
): 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=<min>
|
|
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<string, string>();
|
|
|
|
// First pass: accumulate per (bucket, dimension) and per-dimension totals,
|
|
// plus the admin per-user table (independent of groupBy).
|
|
const buckets = new Map<string, Map<string, Counters>>();
|
|
const totals = new Map<string, Counters>();
|
|
const byUser = new Map<string, Counters>();
|
|
|
|
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<string, string> = {};
|
|
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<string, Counters> = {};
|
|
for (const k of keys) segments[k] = seg.get(k) ?? emptyCounters();
|
|
return { bucket, segments };
|
|
});
|
|
|
|
const totalsOut: Record<string, Counters> = {};
|
|
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;
|
|
}
|