sync: update from private repo (bfcd4d5)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-11 15:12:40 +00:00
parent c5be399fdd
commit 641fe0177d
15 changed files with 988 additions and 133 deletions
+42
View File
@@ -92,6 +92,9 @@ describe('GET /api/skills/:name', () => {
expect(res.body.name).toBe('sys-skill');
expect(res.body.source).toBe('system');
expect(res.body.content).toContain('body');
// raw is the full file (incl. frontmatter) the editor must edit/save.
expect(res.body.raw).toContain('name: sys-skill');
expect(res.body.raw).toContain('body');
expect(res.body.files).toContain('SKILL.md');
expect(res.body).toHaveProperty('maxSeverity');
});
@@ -256,6 +259,45 @@ describe('PUT /api/skills/:name (update)', () => {
.send({ content: 'x' });
expect(res.status).toBe(400);
});
// Regression: editing the body-only `content` (frontmatter dropped) used to
// overwrite SKILL.md without frontmatter, making the skill vanish from the
// catalog. The PUT now rejects frontmatter-less content and leaves the file
// intact.
it('rejects content without valid frontmatter and leaves the skill intact', async () => {
addUserSkill('user-1', 'keepme');
const filePath = join(userRoot, 'user-1', 'skills', 'keepme', 'SKILL.md');
const before = readFileSync(filePath, 'utf-8');
const res = await request(makeApp(makeCatalog(), { id: 'user-1' }))
.put('/api/skills/keepme?scope=user')
.send({ content: '# keepme\njust the body, no frontmatter' });
expect(res.status).toBe(400);
// File untouched → still has frontmatter → still loadable.
expect(readFileSync(filePath, 'utf-8')).toBe(before);
const list = await request(makeApp(makeCatalog(), { id: 'user-1' })).get('/api/skills?scope=user');
expect(list.body.skills.map((s: { name: string }) => s.name)).toContain('keepme');
});
it('rejects a frontmatter name that does not match the skill (no rename via edit)', async () => {
addUserSkill('user-1', 'orig');
const res = await request(makeApp(makeCatalog(), { id: 'user-1' }))
.put('/api/skills/orig?scope=user')
.send({ content: SKILL_MD('renamed') });
expect(res.status).toBe(400);
expect(existsSync(join(userRoot, 'user-1', 'skills', 'orig', 'SKILL.md'))).toBe(true);
});
it('saves full content (frontmatter preserved) round-trip', async () => {
addUserSkill('user-1', 'rt');
const newFull = SKILL_MD('rt') + '\nmore body';
const res = await request(makeApp(makeCatalog(), { id: 'user-1' }))
.put('/api/skills/rt?scope=user')
.send({ content: newFull });
expect(res.status).toBe(200);
const saved = readFileSync(join(userRoot, 'user-1', 'skills', 'rt', 'SKILL.md'), 'utf-8');
expect(saved).toContain('name: rt'); // frontmatter kept
expect(saved).toContain('more body'); // body updated
});
});
describe('DELETE /api/skills/:name', () => {
+29
View File
@@ -5,6 +5,7 @@ import { randomBytes } from 'crypto';
import type { SkillCatalog, SkillEntry } from '../engine/skills.js';
import { VALID_SKILL_NAME } from '../engine/skills.js';
import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../engine/skills-scanner.js';
import matter from 'gray-matter';
import { logger } from '../logger.js';
import { handleInstallFromUrl } from './skills-git-install.js';
@@ -175,6 +176,10 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
source: entry.source,
hasDir: entry.dirPath !== null,
content,
// Full file incl. frontmatter — what the editor must load and save back.
// Editing/saving the body-only `content` would drop the frontmatter and
// make the skill unreadable (it vanishes from the catalog).
raw,
files,
findings,
maxSeverity: maxSeverity(findings),
@@ -320,6 +325,30 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
return;
}
// Guard against destroying the skill: the catalog identifies a skill by
// its frontmatter `name`. Saving content without a valid `name` (e.g. a
// body-only edit that dropped the frontmatter) would make it unreadable
// and vanish from the list. Require valid frontmatter matching the skill.
let fmName = '';
try {
const parsed = matter(content);
fmName = typeof parsed.data?.name === 'string' ? parsed.data.name : '';
} catch {
fmName = '';
}
if (!fmName || !VALID_SKILL_NAME.test(fmName)) {
res.status(400).json({
error: 'Content must begin with YAML frontmatter containing a valid "name" (otherwise the skill becomes unreadable). Edit the full SKILL.md, including the --- frontmatter --- block.',
});
return;
}
if (fmName !== name) {
res.status(400).json({
error: `Frontmatter name "${fmName}" must match the skill name "${name}". Renaming via edit is not supported.`,
});
return;
}
// Scan new content
const findings = scanSkillContent(content);
const severity = maxSeverity(findings);
+151 -6
View File
@@ -1,14 +1,17 @@
/**
* Usage dashboard API (GET /api/usage/daily) tests.
* Usage dashboard API v2 (GET /api/usage/daily) tests.
*
* Coverage:
* - admin sees all users + byUser breakdown; non-admin scoped to own rows
* - no-auth (authActive=false) sees everyone (scope 'all')
* - day / week / month bucketing collapses model/route correctly
* - default groupBy=source keeps gateway/direct totals; series carries segments
* - day / week / month bucketing
* - groupBy model / route / user / org produces the right series keys
* - tzOffset re-buckets UTC hours into the viewer's local calendar day
* - inclusive range, default range, from>to → 400, range-too-large → 400
* - invalid dates fall back to defaults (not 500)
*
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
* Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md
*/
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
@@ -26,10 +29,16 @@ function makeApp(repo: Repository, opts: { authActive: boolean; user?: { id: str
return app;
}
function seed(repo: Repository, rows: Array<{ day: string; userId: string; source: 'gateway' | 'direct'; model?: string; route?: string; tin: number; tout: number; req?: number }>) {
/** Seed at hour grain. `day` is expanded to noon UTC so it maps to the same
* local day under tzOffset=0 (the default the legacy assertions rely on). */
function seed(
repo: Repository,
rows: Array<{ day?: string; hour?: string; userId: string; source: 'gateway' | 'direct'; model?: string; route?: string; tin: number; tout: number; req?: number }>,
) {
for (const r of rows) {
repo.incrementLlmUsage({
day: r.day, userId: r.userId, source: r.source,
repo.incrementLlmUsageHourly({
hour: r.hour ?? `${r.day}T12`,
userId: r.userId, source: r.source,
model: r.model ?? 'm', route: r.route ?? 'r',
tokensIn: r.tin, tokensOut: r.tout, requests: r.req ?? 1,
});
@@ -59,6 +68,17 @@ describe('GET /api/usage/daily', () => {
expect(res.body.totals.direct).toMatchObject({ tokensIn: 10, tokensOut: 5, requests: 1 });
});
it('default groupBy is source with gateway→direct keys', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
expect(res.body.groupBy).toBe('source');
expect(res.body.keys).toEqual(['gateway', 'direct']);
// series carries per-key segments now
const b = res.body.series.find((x: { bucket: string }) => x.bucket === '2026-06-10');
expect(b.segments.gateway).toMatchObject({ tokensIn: 100, tokensOut: 40 });
expect(b.segments.direct).toMatchObject({ tokensIn: 10, tokensOut: 5 });
});
it('admin sees all users with a byUser breakdown', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
@@ -133,3 +153,128 @@ describe('GET /api/usage/daily', () => {
expect(res.body.to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
describe('GET /api/usage/daily — v2 group-by axes', () => {
let repo: Repository;
beforeEach(() => {
repo = new Repository(':memory:');
seed(repo, [
{ day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'big', route: 'pool-a', tin: 100, tout: 40 },
{ day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'small', route: 'pool-b', tin: 20, tout: 10 },
{ day: '2026-06-11', userId: 'u2', source: 'direct', model: 'big', route: 'pool-a', tin: 5, tout: 5 },
]);
});
it('groupBy=model splits series by model, ordered by total desc', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model');
expect(res.body.groupBy).toBe('model');
expect(res.body.keys).toEqual(['big', 'small']); // big 150 > small 30
expect(res.body.totals.big).toMatchObject({ tokensIn: 105, tokensOut: 45 });
expect(res.body.totals.small).toMatchObject({ tokensIn: 20, tokensOut: 10 });
});
it('groupBy=route splits series by backend route', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=route');
expect(res.body.keys.sort()).toEqual(['pool-a', 'pool-b']);
expect(res.body.totals['pool-a']).toMatchObject({ tokensIn: 105, tokensOut: 45 });
});
it('groupBy=user keys by id and resolves display labels', async () => {
const alice = repo.createUser({ email: 'a@x', name: 'Alice', role: 'user', status: 'active' });
seed(repo, [{ day: '2026-06-11', userId: alice.id, source: 'direct', tin: 1, tout: 1 }]);
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=user');
expect(res.body.keys).toContain(alice.id);
expect(res.body.labels[alice.id]).toBe('Alice');
});
it('folds the tail beyond 12 series into a single "other" bucket', async () => {
const r2 = new Repository(':memory:');
// 14 distinct models with descending totals m00 (highest) .. m13 (lowest).
for (let i = 0; i < 14; i++) {
seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: `m${String(i).padStart(2, '0')}`, tin: 100 - i, tout: 0 }]);
}
const app = makeApp(r2, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model');
expect(res.body.keys).toHaveLength(12); // 11 kept + 'other'
expect(res.body.keys[res.body.keys.length - 1]).toBe('other');
// folded tail = m11+m12+m13 inputs = (100-11)+(100-12)+(100-13) = 264
expect(res.body.totals.other).toMatchObject({ tokensIn: 264 });
// no key is duplicated
expect(new Set(res.body.keys).size).toBe(res.body.keys.length);
});
it('merges a real series literally named "other" with the folded tail (no dup key)', async () => {
const r2 = new Repository(':memory:');
// A high-volume model called 'other' that survives in the kept top-11…
seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: 'other', tin: 1000, tout: 0 }]);
// …plus 13 smaller models so folding still happens.
for (let i = 0; i < 13; i++) {
seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: `m${String(i).padStart(2, '0')}`, tin: 50 - i, tout: 0 }]);
}
const app = makeApp(r2, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model');
// 'other' appears exactly once
expect(res.body.keys.filter((k: string) => k === 'other')).toHaveLength(1);
expect(new Set(res.body.keys).size).toBe(res.body.keys.length);
// its total is the real 1000 PLUS the folded tail, not overwritten
expect(res.body.totals.other.tokensIn).toBeGreaterThan(1000);
});
it('groupBy=org maps users to their org and buckets the orgless under no-org', async () => {
const db = repo.getDb();
db.prepare("INSERT INTO users (id, email, name, role, status, created_at) VALUES ('u1','a@x','A','user','active',datetime('now'))").run();
db.prepare("INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES ('u1','g1','Acme')").run();
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=org');
// u1 → Acme, u2 has no org → no-org
expect(res.body.keys.sort()).toEqual(['Acme', 'no-org']);
expect(res.body.totals['Acme']).toMatchObject({ tokensIn: 120, tokensOut: 50 });
expect(res.body.totals['no-org']).toMatchObject({ tokensIn: 5, tokensOut: 5 });
});
});
describe('GET /api/usage/daily — tzOffset local re-bucketing', () => {
let repo: Repository;
beforeEach(() => {
repo = new Repository(':memory:');
// UTC 2026-06-10T20 → JST (+540) is 2026-06-11 05:00 → local day 06-11.
seed(repo, [{ hour: '2026-06-10T20', userId: 'u1', source: 'direct', tin: 9, tout: 1 }]);
});
it('UTC offset (tzOffset=0) lands the row on the UTC day', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=0');
expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10']);
expect(res.body.totals.direct).toMatchObject({ tokensIn: 9 });
});
it('JST offset (tzOffset=540) shifts the row onto the local next day', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&tzOffset=540');
expect(res.body.tzOffset).toBe(540);
expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-11']);
expect(res.body.totals.direct).toMatchObject({ tokensIn: 9 });
});
it('the same UTC row is absent from the UTC day once viewed in JST', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=540');
// local day for the row is 06-11, so the 06-10 window is empty
expect(res.body.series).toHaveLength(0);
});
it('a negative offset (US Eastern, -300) shifts an early-UTC row to the previous local day', async () => {
const r2 = new Repository(':memory:');
// UTC 2026-06-11T02 5h = 2026-06-10T21 → local day 06-10.
seed(r2, [{ hour: '2026-06-11T02', userId: 'u1', source: 'direct', tin: 7, tout: 0 }]);
const app = makeApp(r2, { authActive: false });
const onUtcDay = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&tzOffset=-300');
expect(onUtcDay.body.series).toHaveLength(0); // not on 06-11 local
const onLocalDay = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=-300');
expect(onLocalDay.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10']);
expect(onLocalDay.body.totals.direct).toMatchObject({ tokensIn: 7 });
});
});
+173 -41
View File
@@ -1,18 +1,25 @@
import { Router, Request, Response } from 'express';
import type { Repository, LlmUsageDailyAgg } from '../db/repository.js';
import type { Repository, LlmUsageHourlyRow } from '../db/repository.js';
import { logger } from '../logger.js';
/**
* Per-user LLM usage dashboard API. Reads the llm_usage_daily ledger
* (gateway + direct, recorded at the OpenAICompatClient completion
* boundary) and shapes a time series for the Usage tab.
* 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.
*
* 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.
* 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.
*
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
* 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}$/;
@@ -23,8 +30,14 @@ function isValidDay(s: unknown): s is string {
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 = 'day' | 'week' | 'month';
type GroupBy = 'source' | 'model' | 'route' | 'user' | 'org';
const GROUP_BYS: readonly GroupBy[] = ['source', 'model', 'route', 'user', 'org'];
interface Counters {
tokensIn: number;
@@ -36,31 +49,36 @@ function emptyCounters(): Counters {
return { tokensIn: 0, tokensOut: 0, requests: 0 };
}
function addInto(target: Counters, row: LlmUsageDailyAgg): void {
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 utcToday(): string {
return new Date().toISOString().slice(0, 10);
function localTotal(c: Counters): number {
return c.tokensIn + c.tokensOut;
}
/** day - n days, as 'YYYY-MM-DD' (UTC). */
/** 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' (UTC). */
/** 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 (UTC). */
/** 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.
@@ -80,10 +98,25 @@ function bucketKey(day: string, granularity: Granularity): string {
}
/**
* Human-friendly label for a usage owner id. Real users resolve to their
* name (or email) so the admin breakdown isn't a wall of opaque ids; the
* 'local' / 'system' sentinels are returned verbatim so the UI can localize
* them. Falls back to the raw id when no user row exists.
* 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);
}
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;
@@ -91,13 +124,35 @@ function resolveDisplayName(repo: Repository, userId: string): string {
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:
return row.source === 'gateway' ? 'gateway' : 'direct';
}
}
export function createUsageRouter(repo: Repository, opts: { authActive: boolean }): Router {
const router = Router();
// GET /daily?from=YYYY-MM-DD&to=YYYY-MM-DD&granularity=day|week|month
// 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 to = isValidDay(req.query['to']) ? req.query['to'] : utcToday();
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' });
@@ -108,33 +163,49 @@ export function createUsageRouter(repo: Repository, opts: { authActive: boolean
return;
}
const gq = req.query['granularity'];
const granularity: Granularity =
gq === 'week' || gq === 'month' ? gq : 'day';
const granularity: Granularity = 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.
// 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');
const rows = repo.queryLlmUsageDaily({ from, to, userId: scopeUserId });
// 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 });
// Bucket by (bucketKey, source). Buckets are sparse — only days with
// usage appear; the client fills gaps for the chart.
const buckets = new Map<string, { gateway: Counters; direct: Counters }>();
const totals = { gateway: emptyCounters(), direct: emptyCounters() };
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 key = bucketKey(row.day, granularity);
let b = buckets.get(key);
if (!b) {
b = { gateway: emptyCounters(), direct: emptyCounters() };
buckets.set(key, b);
}
const sourceKey = row.source === 'gateway' ? 'gateway' : 'direct';
addInto(b[sourceKey], row);
addInto(totals[sourceKey], row);
const localDay = localDayOf(row.hour, tzOffset);
if (localDay < from || localDay > to) continue; // precise local-range filter
const bk = 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); }
@@ -142,17 +213,78 @@ export function createUsageRouter(repo: Repository, opts: { authActive: boolean
}
}
// 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'].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, c]) => ({ bucket, gateway: c.gateway, direct: c.direct }));
.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,
totals: totalsOut,
...(isAdmin
? {
byUser: Array.from(byUser.entries())
+32
View File
@@ -103,6 +103,7 @@ export function runMigrations(db: Database.Database): void {
migrateGatewayVirtualKeys(db);
migratePushNotificationsTables(db);
migrateLlmUsageDaily(db);
migrateLlmUsageHourly(db);
}
/**
@@ -130,6 +131,37 @@ function migrateLlmUsageDaily(db: Database.Database): void {
`);
}
/**
* Usage dashboard v2: hour-grain ledger (supersedes llm_usage_daily as the
* write target). Idempotent; mirrors schema.sql + Repository.initSchema.
* Backfills the daily archive into the hourly table once (hour = day||'T00')
* via INSERT OR IGNORE so re-running the migration never double-counts.
* Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md.
*/
function migrateLlmUsageHourly(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS llm_usage_hourly (
hour TEXT NOT NULL,
user_id TEXT NOT NULL,
source TEXT NOT NULL,
model TEXT NOT NULL,
route TEXT NOT NULL,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
requests INTEGER NOT NULL DEFAULT 0,
last_updated_at TEXT NOT NULL,
PRIMARY KEY (hour, user_id, source, model, route)
);
CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour
ON llm_usage_hourly (user_id, hour);
INSERT OR IGNORE INTO llm_usage_hourly
(hour, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at)
SELECT day || 'T00', user_id, source, model, route,
tokens_in, tokens_out, requests, last_updated_at
FROM llm_usage_daily;
`);
}
/**
* Idempotent column addition helper. Checks PRAGMA table_info and runs the
* callback only when the column is missing.
+144
View File
@@ -0,0 +1,144 @@
/**
* Usage dashboard v2: hour-grain ledger (llm_usage_hourly) repository tests.
*
* Coverage:
* - incrementLlmUsageHourly UPSERTs on first call, accumulates on second
* - requests defaults to +1; a usage-less call still bumps requests
* - negative deltas clamp to zero
* - hour defaults to the UTC hour of `at`; boundary splits buckets
* - distinct (model)/(route)/(source) stay separate rows (uncollapsed grain)
* - queryLlmUsageHourly returns raw rows, inclusive range, userId scope
* - getUsageOrgMap unions gitea + local orgs, collapses multi-org via MIN
*
* Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { Repository } from './repository.js';
import { runMigrations } from './migrate.js';
function makeRepo(): Repository {
return new Repository(':memory:');
}
describe('llm_usage_hourly repository', () => {
let repo: Repository;
beforeEach(() => {
repo = makeRepo();
});
it('UPSERTs first call and accumulates on the same grain', () => {
const grain = { hour: '2026-06-11T08', userId: 'u1', source: 'direct' as const, model: 'm', route: 'r' };
repo.incrementLlmUsageHourly({ ...grain, tokensIn: 100, tokensOut: 40 });
repo.incrementLlmUsageHourly({ ...grain, tokensIn: 10, tokensOut: 5 });
const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
hour: '2026-06-11T08', userId: 'u1', source: 'direct', model: 'm', route: 'r',
tokensIn: 110, tokensOut: 45, requests: 2,
});
});
it('a usage-less call still bumps requests', () => {
repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u1', source: 'gateway', model: 'm', route: 'r' });
const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' });
expect(rows[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, requests: 1 });
});
it('clamps negative deltas to zero', () => {
repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: -5, tokensOut: -9, requests: -3 });
const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' });
expect(rows[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, requests: 0 });
});
it('derives the UTC hour from `at`; hour boundary splits buckets', () => {
const grain = { userId: 'u1', source: 'direct' as const, model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 };
repo.incrementLlmUsageHourly({ ...grain, at: '2026-06-11T08:59:59.000Z' });
repo.incrementLlmUsageHourly({ ...grain, at: '2026-06-11T09:00:01.000Z' });
const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' });
expect(rows.map((r) => r.hour)).toEqual(['2026-06-11T08', '2026-06-11T09']);
});
it('keeps model/route/source as distinct uncollapsed rows', () => {
const base = { hour: '2026-06-11T08', userId: 'u1', tokensIn: 1, tokensOut: 1 };
repo.incrementLlmUsageHourly({ ...base, source: 'direct', model: 'big', route: 'host-a' });
repo.incrementLlmUsageHourly({ ...base, source: 'direct', model: 'small', route: 'host-a' });
repo.incrementLlmUsageHourly({ ...base, source: 'gateway', model: 'big', route: 'host-a' });
const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' });
expect(rows).toHaveLength(3); // grain not collapsed
});
it('queryLlmUsageHourly honours an inclusive hour range', () => {
for (const hour of ['2026-06-11T06', '2026-06-11T07', '2026-06-11T08', '2026-06-11T09']) {
repo.incrementLlmUsageHourly({ hour, userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 });
}
const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T07', toHour: '2026-06-11T08' });
expect(rows.map((r) => r.hour)).toEqual(['2026-06-11T07', '2026-06-11T08']);
});
it('userId filter scopes the query to one user', () => {
repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 });
repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u2', source: 'direct', model: 'm', route: 'r', tokensIn: 9, tokensOut: 0 });
const mine = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23', userId: 'u1' });
expect(mine).toHaveLength(1);
expect(mine[0]).toMatchObject({ userId: 'u1', tokensIn: 1 });
});
});
describe('getUsageOrgMap', () => {
let repo: Repository;
beforeEach(() => {
repo = makeRepo();
});
it('maps users from the gitea org cache', () => {
const db = repo.getDb();
db.prepare("INSERT INTO users (id, email, name, role, status, created_at) VALUES ('u1','a@x','A','user','active',datetime('now'))").run();
db.prepare('INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES (?, ?, ?)').run('u1', 'g1', 'Acme');
const map = repo.getUsageOrgMap();
expect(map.get('u1')).toBe('Acme');
expect(map.has('u2')).toBe(false);
});
it('maps users from local orgs and collapses multi-org via MIN(name)', () => {
const db = repo.getDb();
db.prepare("INSERT INTO users (id, email, name, role, status, created_at) VALUES ('u1','a@x','A','user','active',datetime('now'))").run();
db.prepare("INSERT INTO local_orgs (id, name) VALUES ('o1','Zeta'), ('o2','Alpha')").run();
db.prepare("INSERT INTO local_org_members (org_id, user_id) VALUES ('o1','u1'), ('o2','u1')").run();
const map = repo.getUsageOrgMap();
expect(map.get('u1')).toBe('Alpha'); // MIN over {Zeta, Alpha}
});
it('unions gitea and local sources for the same user (MIN wins)', () => {
const db = repo.getDb();
db.prepare("INSERT INTO users (id, email, name, role, status, created_at) VALUES ('u1','a@x','A','user','active',datetime('now'))").run();
db.prepare("INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES ('u1','g1','Beta')").run();
db.prepare("INSERT INTO local_orgs (id, name) VALUES ('o1','Alpha')").run();
db.prepare("INSERT INTO local_org_members (org_id, user_id) VALUES ('o1','u1')").run();
const map = repo.getUsageOrgMap();
expect(map.get('u1')).toBe('Alpha'); // MIN over {Beta, Alpha}
});
});
describe('daily → hourly backfill (migrate)', () => {
it('backfills daily rows into the hourly table at hour T00, idempotently', () => {
const repo = new Repository(':memory:');
const db = repo.getDb();
// Simulate a pre-v2 production DB: a row only in the daily archive.
db.prepare(
`INSERT INTO llm_usage_daily
(day, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at)
VALUES ('2026-06-10','u1','direct','m','r',100,40,3,'2026-06-10T23:00:00Z')`,
).run();
runMigrations(db);
const after1 = repo.queryLlmUsageHourly({ fromHour: '2026-06-10T00', toHour: '2026-06-10T23' });
expect(after1).toHaveLength(1);
expect(after1[0]).toMatchObject({ hour: '2026-06-10T00', userId: 'u1', tokensIn: 100, tokensOut: 40, requests: 3 });
// Re-running the migration must not double-count (INSERT OR IGNORE).
runMigrations(db);
const after2 = repo.queryLlmUsageHourly({ fromHour: '2026-06-10T00', toHour: '2026-06-10T23' });
expect(after2).toHaveLength(1);
expect(after2[0]).toMatchObject({ tokensIn: 100, tokensOut: 40, requests: 3 });
});
});
+155
View File
@@ -367,6 +367,40 @@ export interface LlmUsageDailyAgg {
requests: number;
}
/** Hour-grain UPSERT input for the v2 usage ledger. */
export interface LlmUsageHourlyIncrement {
/** UTC hour bucket 'YYYY-MM-DDTHH'. Defaults to the current hour (UTC). */
hour?: string;
/** Owner id, or 'local' (no-auth) / 'system' (ownerless) sentinel. */
userId: string;
source: 'gateway' | 'direct';
/** Real model name (chunk.model), routing-key fallback, or 'unknown'. */
model: string;
/** Backend server name (gateway backendId / direct host), or 'unknown'. */
route: string;
tokensIn?: number;
tokensOut?: number;
requests?: number;
/** ISO timestamp for last_updated_at + hour default. Defaults to now. */
at?: string;
}
/**
* Raw hour-grain ledger row (no axis collapsed) for the usage API. The API
* re-buckets `hour` into the viewer's local calendar period and groups by
* whichever of source/model/route/user/org the request asked for.
*/
export interface LlmUsageHourlyRow {
hour: string;
userId: string;
source: string;
model: string;
route: string;
tokensIn: number;
tokensOut: number;
requests: number;
}
/**
* Coerce an optional limit (tokens_budget / rate_limit_rpm) to either
* a positive integer or null. Anything else (undefined, null, 0,
@@ -1189,6 +1223,28 @@ export class Repository {
CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day
ON llm_usage_daily (user_id, day);
`);
// Usage dashboard v2: hour-grain ledger (supersedes llm_usage_daily as the
// write target; daily kept as frozen archive). Mirrors schema.sql +
// migrate.ts (dual-path). Backfill of the daily archive lives in migrate.ts
// so it runs once on upgrade, not on every fresh initSchema.
// Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md
this.db.exec(`
CREATE TABLE IF NOT EXISTS llm_usage_hourly (
hour TEXT NOT NULL,
user_id TEXT NOT NULL,
source TEXT NOT NULL,
model TEXT NOT NULL,
route TEXT NOT NULL,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
requests INTEGER NOT NULL DEFAULT 0,
last_updated_at TEXT NOT NULL,
PRIMARY KEY (hour, user_id, source, model, route)
);
CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour
ON llm_usage_hourly (user_id, hour);
`);
}
private ensureColumn(tableName: string, columnName: string, definition: string): void {
@@ -3646,6 +3702,105 @@ export class Repository {
}));
}
/**
* v2 write path: UPSERT per-(hour, user, source, model, route) counters.
* `hour` defaults to the current UTC hour 'YYYY-MM-DDTHH'. Same contract as
* incrementLlmUsage (deltas clamped at zero; usage-less completions still
* bump `requests`). Supersedes incrementLlmUsage as the recorder target.
*/
incrementLlmUsageHourly(params: LlmUsageHourlyIncrement): void {
const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0));
const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0));
const reqs = Math.max(0, Math.floor(params.requests ?? 1));
const ts = params.at ?? new Date().toISOString();
const hour = params.hour ?? ts.slice(0, 13);
this.db
.prepare(
`INSERT INTO llm_usage_hourly
(hour, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (hour, user_id, source, model, route) DO UPDATE SET
tokens_in = tokens_in + excluded.tokens_in,
tokens_out = tokens_out + excluded.tokens_out,
requests = requests + excluded.requests,
last_updated_at = excluded.last_updated_at`,
)
.run(hour, params.userId, params.source, params.model, params.route, tIn, tOut, reqs, ts);
}
/**
* Raw hour-grain rows for the usage dashboard v2, no axis collapsed so the
* API can group by any of source/model/route/user/org and re-bucket into the
* viewer's local timezone. `userId` scopes a non-admin to their own rows.
* Inclusive `fromHour`/`toHour` are 'YYYY-MM-DDTHH' (UTC) — callers widen the
* UTC window by ±1 day before filtering precisely against local days.
*/
queryLlmUsageHourly(opts: {
fromHour: string;
toHour: string;
userId?: string;
}): LlmUsageHourlyRow[] {
const where = ['hour >= ?', 'hour <= ?'];
const args: unknown[] = [opts.fromHour, opts.toHour];
if (opts.userId !== undefined) {
where.push('user_id = ?');
args.push(opts.userId);
}
const rows = this.db
.prepare(
`SELECT hour, user_id, source, model, route, tokens_in, tokens_out, requests
FROM llm_usage_hourly
WHERE ${where.join(' AND ')}
ORDER BY hour ASC`,
)
.all(...args) as Array<{
hour: string;
user_id: string;
source: string;
model: string;
route: string;
tokens_in: number;
tokens_out: number;
requests: number;
}>;
return rows.map((r) => ({
hour: r.hour,
userId: r.user_id,
source: r.source,
model: r.model,
route: r.route,
tokensIn: r.tokens_in,
tokensOut: r.tokens_out,
requests: r.requests,
}));
}
/**
* Map every user to a single org label for the usage dashboard's "by org"
* breakdown. Unions Gitea org cache (user_gitea_orgs) and local orgs
* (local_org_members → local_orgs.name); a multi-org user collapses to the
* alphabetically-first org name (MIN) so the breakdown is deterministic.
* Users with no org (and the 'local'/'system' sentinels) are simply absent —
* the API buckets them under 'no-org'.
*/
getUsageOrgMap(): Map<string, string> {
const rows = this.db
.prepare(
`SELECT user_id, MIN(org_name) AS org_name FROM (
SELECT user_id, org_name FROM user_gitea_orgs
UNION ALL
SELECT m.user_id AS user_id, o.name AS org_name
FROM local_org_members m
JOIN local_orgs o ON o.id = m.org_id
)
GROUP BY user_id`,
)
.all() as Array<{ user_id: string; org_name: string }>;
const map = new Map<string, string>();
for (const r of rows) map.set(r.user_id, r.org_name);
return map;
}
/** Return the underlying Database instance (needed by migrate.ts and session store) */
getDb(): Database.Database {
return this.db;
+22
View File
@@ -613,6 +613,28 @@ CREATE TABLE IF NOT EXISTS llm_usage_daily (
CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day
ON llm_usage_daily (user_id, day);
-- Usage dashboard v2: hour-grain ledger so the dashboard can re-bucket UTC
-- usage into the viewer's local calendar day/week/month, and group by any of
-- source/model/route/user/org. This supersedes llm_usage_daily as the write
-- target; the daily table is kept as a frozen pre-migration archive and is
-- backfilled into here once (hour = day || 'T00'). user_id keeps the same
-- 'local'/'system' sentinel convention so ON CONFLICT never hits NULL!=NULL.
-- Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md
CREATE TABLE IF NOT EXISTS llm_usage_hourly (
hour TEXT NOT NULL, -- 'YYYY-MM-DDTHH' (UTC)
user_id TEXT NOT NULL, -- owner id / 'local' / 'system'
source TEXT NOT NULL, -- 'gateway' | 'direct'
model TEXT NOT NULL, -- real model name (chunk.model)
route TEXT NOT NULL, -- backend server (gateway backendId / direct host)
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
requests INTEGER NOT NULL DEFAULT 0,
last_updated_at TEXT NOT NULL,
PRIMARY KEY (hour, user_id, source, model, route)
);
CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour
ON llm_usage_hourly (user_id, hour);
-- ── Browser Notifications V2: Web Push subscriptions + per-user prefs ───
-- Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md
-- endpoint is globally UNIQUE so logging into a different user in the same
+1 -1
View File
@@ -33,7 +33,7 @@ let recorder: LlmUsageRecorder | null = null;
/**
* Install (or clear with null) the process-global usage recorder. Called
* once during bootstrap with a thin wrapper over Repository.incrementLlmUsage.
* once during bootstrap with a thin wrapper over Repository.incrementLlmUsageHourly.
*/
export function setLlmUsageRecorder(fn: LlmUsageRecorder | null): void {
recorder = fn;
+5 -4
View File
@@ -124,11 +124,12 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
// Install the process-global LLM usage recorder so every OpenAICompatClient
// completion (agent loop, title, classify, reflection — gateway + direct)
// lands in the per-user daily ledger. Best-effort: the recorder helper
// swallows write errors so a DB hiccup never kills an agent stream.
// Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
// lands in the per-user hour-grain ledger. Hour grain lets the dashboard
// re-bucket usage into the viewer's local calendar day. Best-effort: the
// recorder helper swallows write errors so a DB hiccup never kills a stream.
// Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md
setLlmUsageRecorder((event) => {
repo.incrementLlmUsage({
repo.incrementLlmUsageHourly({
userId: event.userId,
source: event.source,
model: event.model,