From 44f28d4e3e584f26a5ad43761fe28e029a9ae671 Mon Sep 17 00:00:00 2001 From: Serge Ivo Date: Sat, 1 Aug 2026 09:05:00 +1000 Subject: [PATCH] feat(admin): cross-user usage + spending API (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the operator money view over the ai_usage ledger, stacked on the admin foundation (#28): - GET /v1/admin/usage?range=7d|30d|90d|all — cross-user rollup by provider, model, kind, agent, user, and day, with a platform-paid vs BYOK split. - GET /v1/admin/spending — totals, BYOK spend (real, token-estimated), top spenders, top models, trend, plus platformAiEnabled and an honest platformPaid.metered=false caveat. - aggregateAdminUsage() in lib/usage.ts — pure, cross-user aggregation reusing the existing bucket helpers; adds byProvider + byUser + the split. - 14 tests (pure aggregation + route gate/shape); typecheck clean; no regression in the per-user usage aggregator. Scope note: platform-paid AI (embeddings/summaries/translation on env.AI) is not yet metered into the ledger — only provider="platform" rows are counted, so the split is honest rather than a fake $0. Write-path metering + Cloudflare billing-actuals + the runtime kill switch are tracked as #32 follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) --- workers/api/src/lib/usage-admin.test.ts | 66 ++++++++++++++++ workers/api/src/lib/usage.ts | 100 ++++++++++++++++++++++++ workers/api/src/routes/admin.test.ts | 56 ++++++++++++- workers/api/src/routes/admin.ts | 91 +++++++++++++++++++++ 4 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 workers/api/src/lib/usage-admin.test.ts diff --git a/workers/api/src/lib/usage-admin.test.ts b/workers/api/src/lib/usage-admin.test.ts new file mode 100644 index 00000000..0fdbdb81 --- /dev/null +++ b/workers/api/src/lib/usage-admin.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { aggregateAdminUsage, PLATFORM_PROVIDER, type AdminUsageRow } from "./usage.js"; + +function row(p: Partial): AdminUsageRow { + return { + user_id: "u1", + agent_id: null, + instance_id: null, + provider: "anthropic", + model: "claude-sonnet-4-6", + kind: "chat", + input_tokens: 1000, + output_tokens: 500, + cost_micros: 10500, + created_at: "2026-08-01 10:00:00", + ...p, + }; +} + +describe("aggregateAdminUsage", () => { + it("totals across all users and buckets by provider/model/kind/user", () => { + const s = aggregateAdminUsage([ + row({ user_id: "u1", cost_micros: 100 }), + row({ user_id: "u2", cost_micros: 300, model: "claude-opus-4" }), + row({ user_id: "u2", cost_micros: 50, kind: "apply" }), + ]); + expect(s.totals.calls).toBe(3); + expect(s.totals.costMicros).toBe(450); + // byUser sorted by cost: u2 (350) before u1 (100) + expect(s.byUser.map((b) => b.key)).toEqual(["u2", "u1"]); + expect(s.byUser[0].costMicros).toBe(350); + expect(s.byModel.map((b) => b.key)).toContain("claude-opus-4"); + expect(s.byKind.map((b) => b.key).sort()).toEqual(["apply", "chat"]); + }); + + it("splits platform-paid from BYOK by provider", () => { + const s = aggregateAdminUsage([ + row({ provider: "anthropic", cost_micros: 1000 }), + row({ provider: "cloudflare", cost_micros: 0 }), + row({ provider: PLATFORM_PROVIDER, cost_micros: 250, kind: "embedding" as AdminUsageRow["kind"] }), + ]); + expect(s.split.platformPaid.costMicros).toBe(250); + expect(s.split.platformPaid.calls).toBe(1); + expect(s.split.byok.costMicros).toBe(1000); + expect(s.split.byok.calls).toBe(2); + }); + + it("labels users and agents from the provided name maps", () => { + const s = aggregateAdminUsage([row({ user_id: "u1", agent_id: "a1" })], { + userNames: { u1: "alice" }, + agentNames: { a1: "Coder" }, + }); + expect(s.byUser[0].label).toBe("alice"); + expect(s.byAgent[0].label).toBe("Coder"); + }); + + it("produces a dense daily series over the window", () => { + const s = aggregateAdminUsage([row({ created_at: "2026-08-01 10:00:00" })], { + fromDay: "2026-07-30", + toDay: "2026-08-01", + }); + expect(s.daily.map((d) => d.date)).toEqual(["2026-07-30", "2026-07-31", "2026-08-01"]); + expect(s.daily[0].calls).toBe(0); + expect(s.daily[2].calls).toBe(1); + }); +}); diff --git a/workers/api/src/lib/usage.ts b/workers/api/src/lib/usage.ts index 5a91d29a..c400fb7e 100644 --- a/workers/api/src/lib/usage.ts +++ b/workers/api/src/lib/usage.ts @@ -201,6 +201,106 @@ export function aggregateUsage( }; } +// --------------------------------------------------------------------------- +// Admin aggregation (cross-user) — pure, unit-tested. Powers /v1/admin/usage +// and /v1/admin/spending. Adds the by-provider and by-user dimensions the +// per-user page doesn't need, plus a platform-paid-vs-BYOK split. +// --------------------------------------------------------------------------- + +/** A ledger row with its owning user — the per-user view filters by user, admin needs it. */ +export interface AdminUsageRow extends UsageRow { + user_id: string; +} + +export interface BucketTotals { + inputTokens: number; + outputTokens: number; + costMicros: number; + calls: number; +} + +export interface AdminUsageSummary { + totals: BucketTotals; + daily: UsageSummary["daily"]; + byProvider: UsageBucket[]; + byModel: UsageBucket[]; + byKind: UsageBucket[]; + byAgent: UsageBucket[]; + byUser: UsageBucket[]; + /** Platform-paid (provider === "platform", billed to us) vs BYOK (everything else). */ + split: { platformPaid: BucketTotals; byok: BucketTotals }; +} + +/** Rows whose cost the platform pays are marked with this provider by the metering layer. */ +export const PLATFORM_PROVIDER = "platform"; + +const zeroTotals = (): BucketTotals => ({ inputTokens: 0, outputTokens: 0, costMicros: 0, calls: 0 }); + +function addInto(t: BucketTotals, r: UsageRow) { + t.inputTokens += r.input_tokens || 0; + t.outputTokens += r.output_tokens || 0; + t.costMicros += r.cost_micros || 0; + t.calls += 1; +} + +/** + * Roll cross-user ledger rows into admin breakdowns. `names` maps ids → display + * labels (agentNames for agent_id, userNames for user_id). + */ +export function aggregateAdminUsage( + rows: AdminUsageRow[], + opts: { fromDay?: string; toDay?: string; agentNames?: Record; userNames?: Record } = {}, +): AdminUsageSummary { + const totals = zeroTotals(); + const maps = { + day: new Map(), + provider: new Map(), + model: new Map(), + kind: new Map(), + agent: new Map(), + user: new Map(), + }; + const split = { platformPaid: zeroTotals(), byok: zeroTotals() }; + + const into = (m: Map, key: string, r: UsageRow) => { + let b = m.get(key); + if (!b) { b = emptyBucket(key); m.set(key, b); } + bump(b, r); + }; + + for (const r of rows) { + addInto(totals, r); + into(maps.day, usageDay(r.created_at), r); + into(maps.provider, r.provider || "unknown", r); + into(maps.model, r.model || "unknown", r); + into(maps.kind, r.kind || "unknown", r); + into(maps.agent, r.agent_id || "unassigned", r); + into(maps.user, r.user_id || "unknown", r); + addInto(r.provider === PLATFORM_PROVIDER ? split.platformPaid : split.byok, r); + } + + const daily: UsageSummary["daily"] = []; + const days = opts.fromDay && opts.toDay ? denseDays(opts.fromDay, opts.toDay) : [...maps.day.keys()].sort(); + for (const date of days) { + const b = maps.day.get(date); + daily.push({ date, inputTokens: b?.inputTokens || 0, outputTokens: b?.outputTokens || 0, costMicros: b?.costMicros || 0, calls: b?.calls || 0 }); + } + + const sortByCost = (m: Map) => + [...m.values()].sort((a, b) => b.costMicros - a.costMicros || (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens)); + + return { + totals, + daily, + byProvider: sortByCost(maps.provider), + byModel: sortByCost(maps.model), + byKind: sortByCost(maps.kind), + byAgent: sortByCost(maps.agent).map((b) => ({ ...b, label: b.key === "unassigned" ? "Unassigned" : opts.agentNames?.[b.key] || b.key })), + byUser: sortByCost(maps.user).map((b) => ({ ...b, label: opts.userNames?.[b.key] || b.key })), + split, + }; +} + /** Inclusive list of "YYYY-MM-DD" strings from → to (UTC), capped to avoid runaway. */ export function denseDays(fromDay: string, toDay: string): string[] { const out: string[] = []; diff --git a/workers/api/src/routes/admin.test.ts b/workers/api/src/routes/admin.test.ts index 91ac54c3..3977c5b5 100644 --- a/workers/api/src/routes/admin.test.ts +++ b/workers/api/src/routes/admin.test.ts @@ -11,7 +11,7 @@ const TEST_SECRET = "test-secret"; * returns for the live-role check; `allowlist` seeds ADMIN_ALLOWLIST; `audit` is * the rows the audit query returns. */ -function testApp(opts: { dbRoles?: string | null; allowlist?: string; audit?: unknown[] } = {}) { +function testApp(opts: { dbRoles?: string | null; allowlist?: string; audit?: unknown[]; usageRows?: unknown[]; platformAiEnabled?: boolean } = {}) { const app = new Hono(); app.route("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/v1/admin", adminRoutes); app.onError((err, c) => { @@ -21,17 +21,28 @@ function testApp(opts: { dbRoles?: string | null; allowlist?: string; audit?: un const env = { SESSION_SIGNING_KEY: TEST_SECRET, ADMIN_ALLOWLIST: opts.allowlist, + PLATFORM_AI_ENABLED: opts.platformAiEnabled ? "true" : "false", DB: { prepare(sql: string) { + // requireAdmin's live-role check reads FROM users with a single-row .first(); + // the usage/audit queries return result sets via .all(). + const isRoleLookup = sql.includes("SELECT roles FROM users"); return { bind() { return { - first: async () => - sql.includes("FROM users") ? { roles: opts.dbRoles ?? null } : null, - all: async () => ({ results: opts.audit ?? [] }), + first: async () => (isRoleLookup ? { roles: opts.dbRoles ?? null } : null), + all: async () => + sql.includes("FROM ai_usage") + ? { results: opts.usageRows ?? [] } + : { results: opts.audit ?? [] }, run: async () => ({}), }; }, + first: async () => (isRoleLookup ? { roles: opts.dbRoles ?? null } : null), + all: async () => + sql.includes("FROM ai_usage") + ? { results: opts.usageRows ?? [] } + : { results: opts.audit ?? [] }, }; }, }, @@ -98,3 +109,40 @@ describe("GET /v1/admin/audit", () => { expect(await res.json()).toEqual({ count: 1, audit }); }); }); + +const USAGE_ROWS = [ + { user_id: "u1", agent_id: null, instance_id: null, provider: "anthropic", model: "claude-sonnet-4-6", kind: "chat", input_tokens: 1000, output_tokens: 500, cost_micros: 10500, created_at: "2026-08-01 10:00:00", agent_name: null, user_login: "alice" }, + { user_id: "u2", agent_id: null, instance_id: null, provider: "platform", model: "@cf/baai/bge-base-en-v1.5", kind: "embedding", input_tokens: 200, output_tokens: 0, cost_micros: 40, created_at: "2026-08-01 11:00:00", agent_name: null, user_login: "bob" }, +]; + +describe("GET /v1/admin/usage", () => { + it("403s a non-admin", async () => { + const { app, env } = testApp({ dbRoles: '["user"]' }); + const res = await req(app, env, "/v1/admin/usage", await token("u2", ["user"])); + expect(res.status).toBe(403); + }); + + it("returns a cross-user rollup for an admin", async () => { + const { app, env } = testApp({ usageRows: USAGE_ROWS }); + const res = await req(app, env, "/v1/admin/usage?range=30d", await token("u1", ["admin"])); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.totals.calls).toBe(2); + expect(body.byUser.map((b: any) => b.label).sort()).toEqual(["alice", "bob"]); + expect(body.split.platformPaid.calls).toBe(1); + expect(body.split.byok.calls).toBe(1); + }); +}); + +describe("GET /v1/admin/spending", () => { + it("surfaces BYOK spend, top spenders, and the platform-paid caveat", async () => { + const { app, env } = testApp({ usageRows: USAGE_ROWS, platformAiEnabled: true }); + const res = await req(app, env, "/v1/admin/spending", await token("u1", ["admin"])); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.byok.costMicros).toBe(10500); + expect(body.platformAiEnabled).toBe(true); + expect(body.platformPaid.metered).toBe(false); + expect(body.topSpenders[0].label).toBe("alice"); + }); +}); diff --git a/workers/api/src/routes/admin.ts b/workers/api/src/routes/admin.ts index 600d21ea..2639efd0 100644 --- a/workers/api/src/routes/admin.ts +++ b/workers/api/src/routes/admin.ts @@ -1,8 +1,52 @@ import { Hono } from "hono"; import { isAdmin, requireAdmin, requireUser } from "../lib/auth.js"; import { listAdminAudit } from "../lib/admin.js"; +import { aggregateAdminUsage, type AdminUsageRow } from "../lib/usage.js"; import type { Env } from "../types.js"; +/** UTC "YYYY-MM-DD" for `daysAgo` days before today (0 = today). */ +function dayUtc(daysAgo: number): string { + return new Date(Date.now() - daysAgo * 86_400_000).toISOString().slice(0, 10); +} + +interface AdminJoinedRow extends AdminUsageRow { + agent_name: string | null; + user_login: string | null; +} + +/** + * Pull cross-user ledger rows for the window and roll them up. Shared by + * /usage and /spending. `days` undefined = all-time. + */ +async function loadAdminUsage(env: Env, days: number | undefined) { + const where = days ? "WHERE u.created_at >= ?1" : ""; + const stmt = env.DB.prepare( + `SELECT u.user_id, COALESCE(u.agent_id, i.agent_id) AS agent_id, u.instance_id, + u.provider, u.model, u.kind, u.input_tokens, u.output_tokens, u.cost_micros, u.created_at, + a.name AS agent_name, us.github_login AS user_login + FROM ai_usage u + LEFT JOIN agent_instances i ON i.id = u.instance_id + LEFT JOIN agents a ON a.id = COALESCE(u.agent_id, i.agent_id) + LEFT JOIN users us ON us.id = u.user_id + ${where} + ORDER BY u.created_at ASC`, + ); + const bound = days ? stmt.bind(`${dayUtc(days - 1)} 00:00:00`) : stmt; + const rows = (await bound.all()).results ?? []; + const agentNames: Record = {}; + const userNames: Record = {}; + for (const r of rows) { + if (r.agent_id && r.agent_name) agentNames[r.agent_id] = r.agent_name; + if (r.user_id && r.user_login) userNames[r.user_id] = r.user_login; + } + return aggregateAdminUsage( + rows, + days ? { fromDay: dayUtc(days - 1), toDay: dayUtc(0), agentNames, userNames } : { agentNames, userNames }, + ); +} + +const RANGE_DAYS: Record = { "7d": 7, "30d": 30, "90d": 90, all: undefined }; + /** * Admin/operator portal API (epic: PAGS Admin Portal). Every route here is behind * the admin gate. The network perimeter (Cloudflare Access) is applied as @@ -37,3 +81,50 @@ adminRoutes.get("/audit", async (c) => { }); return c.json({ count: rows.length, audit: rows }); }); + +/** + * GET /v1/admin/usage?range=7d|30d|90d|all — cross-user usage + cost rolled up by + * provider, model, kind, agent, user, and day, with a platform-paid vs BYOK split. + * Cost is the same BYOK estimate as the per-user page (tokens × list price). See + * /spending for the caveat on platform-paid metering. + */ +adminRoutes.get("/usage", async (c) => { + await requireAdmin(c); + const range = c.req.query("range") || "30d"; + const days = range in RANGE_DAYS ? RANGE_DAYS[range] : 30; + const summary = await loadAdminUsage(c.env, days); + return c.json({ range, ...summary }); +}); + +/** + * GET /v1/admin/spending?range=30d — the money view: BYOK spend (real, estimated + * from tokens) + top spenders/models + trend, plus the platform-paid picture. + * + * IMPORTANT: platform-paid AI (embeddings / summaries / translation run on the + * platform's Workers AI when PLATFORM_AI_ENABLED) is NOT fully metered into the + * ledger yet — only rows tagged provider="platform" are counted. Until the + * write-path metering + Cloudflare billing-actuals integration land (see the + * follow-up issues), `platformPaid.metered` is false and the authoritative number + * for platform Workers-AI spend is the Cloudflare dashboard. `platformAiEnabled` + * reports whether the platform is currently allowed to pay for internal AI. + */ +adminRoutes.get("/spending", async (c) => { + await requireAdmin(c); + const range = c.req.query("range") || "30d"; + const days = range in RANGE_DAYS ? RANGE_DAYS[range] : 30; + const s = await loadAdminUsage(c.env, days); + return c.json({ + range, + totals: s.totals, + daily: s.daily, + byok: s.split.byok, + topSpenders: s.byUser.slice(0, 10), + topModels: s.byModel.slice(0, 10), + platformAiEnabled: c.env.PLATFORM_AI_ENABLED === "true", + platformPaid: { + ...s.split.platformPaid, + metered: false, + note: "Platform-paid Workers AI (embeddings/summaries/translation) is not yet fully metered into the ledger; see the CF dashboard for authoritative neuron spend.", + }, + }); +});