Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions workers/api/src/lib/usage-admin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { aggregateAdminUsage, PLATFORM_PROVIDER, type AdminUsageRow } from "./usage.js";

function row(p: Partial<AdminUsageRow>): 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);
});
});
100 changes: 100 additions & 0 deletions workers/api/src/lib/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; userNames?: Record<string, string> } = {},
): AdminUsageSummary {
const totals = zeroTotals();
const maps = {
day: new Map<string, UsageBucket>(),
provider: new Map<string, UsageBucket>(),
model: new Map<string, UsageBucket>(),
kind: new Map<string, UsageBucket>(),
agent: new Map<string, UsageBucket>(),
user: new Map<string, UsageBucket>(),
};
const split = { platformPaid: zeroTotals(), byok: zeroTotals() };

const into = (m: Map<string, UsageBucket>, 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<string, UsageBucket>) =>
[...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[] = [];
Expand Down
56 changes: 52 additions & 4 deletions workers/api/src/routes/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("/v1/admin", adminRoutes);
app.onError((err, c) => {
Expand All @@ -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 ?? [] },
};
},
},
Expand Down Expand Up @@ -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");
});
});
91 changes: 91 additions & 0 deletions workers/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
@@ -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<AdminJoinedRow>()).results ?? [];
const agentNames: Record<string, string> = {};
const userNames: Record<string, string> = {};
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<string, number | undefined> = { "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
Expand Down Expand Up @@ -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.",
},
});
});