From 008c8bcd09b2a1ae21259b134e25375942b7aae3 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 10 Jul 2026 00:55:43 +0800 Subject: [PATCH] feat(hub): add org sessions and usage admin APIs List project agent sessions/runs and aggregate AgentRun cost and token usage by project and optional folder for org admins. --- hub/src/admin/routes/orgRoutes.ts | 5 + hub/src/admin/routes/sessionsRoutes.ts | 80 ++++++++++ hub/src/org/sessions.ts | 114 ++++++++++++++ hub/src/org/usage.ts | 203 +++++++++++++++++++++++++ 4 files changed, 402 insertions(+) create mode 100644 hub/src/admin/routes/sessionsRoutes.ts create mode 100644 hub/src/org/sessions.ts create mode 100644 hub/src/org/usage.ts diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts index 0446546..b971179 100644 --- a/hub/src/admin/routes/orgRoutes.ts +++ b/hub/src/admin/routes/orgRoutes.ts @@ -12,6 +12,7 @@ import { import { registerAccessRoutes } from "./accessRoutes.js"; import { registerExplorerRoutes } from "./explorerRoutes.js"; import { registerMembersRoutes } from "./membersRoutes.js"; +import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js"; import { registerTeamsRoutes } from "./teamsRoutes.js"; export interface OrgRouteConfig { @@ -96,4 +97,8 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo prisma: config.prisma, sessionSecret: config.sessionSecret, }); + await registerSessionsAndUsageRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + }); } diff --git a/hub/src/admin/routes/sessionsRoutes.ts b/hub/src/admin/routes/sessionsRoutes.ts new file mode 100644 index 0000000..784b699 --- /dev/null +++ b/hub/src/admin/routes/sessionsRoutes.ts @@ -0,0 +1,80 @@ +import type { PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; +import { getSessionDetail, listProjectSessions } from "../../org/sessions.js"; +import { getOrgUsage, getProjectUsage } from "../../org/usage.js"; +import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { handleRouteError } from "../errors.js"; + +export async function registerSessionsAndUsageRoutes( + app: FastifyInstance, + config: { readonly prisma: PrismaClient; readonly sessionSecret: string }, +): Promise { + const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; + + app.get("/api/org/:orgSlug/projects/:projectId/sessions", async (request, reply) => { + try { + const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const q = request.query as { limit?: string }; + const limit = q.limit !== undefined ? Number(q.limit) : undefined; + return { + sessions: await listProjectSessions(config.prisma, { + organizationId: auth.organization.id, + projectId, + ...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}), + }), + }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.get("/api/org/:orgSlug/sessions/:sessionId", async (request, reply) => { + try { + const { orgSlug, sessionId } = request.params as { orgSlug: string; sessionId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return await getSessionDetail(config.prisma, { + organizationId: auth.organization.id, + sessionId, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.get("/api/org/:orgSlug/usage", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const q = request.query as { from?: string; to?: string; folderId?: string }; + return await getOrgUsage(config.prisma, { + organizationId: auth.organization.id, + ...(q.from !== undefined && q.from !== "" ? { from: new Date(q.from) } : {}), + ...(q.to !== undefined && q.to !== "" ? { to: new Date(q.to) } : {}), + ...(q.folderId !== undefined && q.folderId !== "" ? { folderId: q.folderId } : {}), + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.get("/api/org/:orgSlug/projects/:projectId/usage", async (request, reply) => { + try { + const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const q = request.query as { from?: string; to?: string }; + return await getProjectUsage(config.prisma, { + organizationId: auth.organization.id, + projectId, + ...(q.from !== undefined && q.from !== "" ? { from: new Date(q.from) } : {}), + ...(q.to !== undefined && q.to !== "" ? { to: new Date(q.to) } : {}), + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); +} diff --git a/hub/src/org/sessions.ts b/hub/src/org/sessions.ts new file mode 100644 index 0000000..342c1f0 --- /dev/null +++ b/hub/src/org/sessions.ts @@ -0,0 +1,114 @@ +/** + * Agent session / run listing for org admin. + */ +import type { PrismaClient } from "@prisma/client"; + +export async function listProjectSessions( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly projectId: string; + readonly limit?: number | undefined; + }, +) { + const project = await prisma.project.findFirst({ + where: { id: input.projectId, organizationId: input.organizationId }, + select: { id: true }, + }); + if (project === null) { + throw new Error(`project not found: ${input.projectId}`); + } + const limit = Math.min(Math.max(input.limit ?? 50, 1), 200); + const sessions = await prisma.agentSession.findMany({ + where: { projectId: project.id, archivedAt: null }, + select: { + id: true, + provider: true, + roleId: true, + model: true, + title: true, + createdAt: true, + updatedAt: true, + _count: { select: { runs: true } }, + }, + orderBy: { updatedAt: "desc" }, + take: limit, + }); + return sessions.map((s) => ({ + id: s.id, + provider: s.provider, + roleId: s.roleId, + model: s.model, + title: s.title, + runCount: s._count.runs, + createdAt: s.createdAt.toISOString(), + updatedAt: s.updatedAt.toISOString(), + })); +} + +export async function getSessionDetail( + prisma: PrismaClient, + input: { readonly organizationId: string; readonly sessionId: string }, +) { + const session = await prisma.agentSession.findUnique({ + where: { id: input.sessionId }, + select: { + id: true, + provider: true, + roleId: true, + model: true, + title: true, + createdAt: true, + updatedAt: true, + archivedAt: true, + project: { + select: { id: true, name: true, organizationId: true }, + }, + runs: { + select: { + id: true, + status: true, + model: true, + provider: true, + inputTokens: true, + outputTokens: true, + costUsd: true, + startedAt: true, + finishedAt: true, + error: true, + }, + orderBy: { startedAt: "desc" }, + take: 100, + }, + }, + }); + if (session === null || session.project.organizationId !== input.organizationId) { + throw new Error(`session not found: ${input.sessionId}`); + } + return { + id: session.id, + provider: session.provider, + roleId: session.roleId, + model: session.model, + title: session.title, + createdAt: session.createdAt.toISOString(), + updatedAt: session.updatedAt.toISOString(), + archivedAt: session.archivedAt?.toISOString() ?? null, + project: { + id: session.project.id, + name: session.project.name, + }, + runs: session.runs.map((r) => ({ + id: r.id, + status: r.status, + model: r.model, + provider: r.provider, + inputTokens: r.inputTokens, + outputTokens: r.outputTokens, + costUsd: r.costUsd === null ? null : Number(r.costUsd), + startedAt: r.startedAt.toISOString(), + finishedAt: r.finishedAt?.toISOString() ?? null, + error: r.error, + })), + }; +} diff --git a/hub/src/org/usage.ts b/hub/src/org/usage.ts new file mode 100644 index 0000000..4050e51 --- /dev/null +++ b/hub/src/org/usage.ts @@ -0,0 +1,203 @@ +/** + * Org / project usage rollups from AgentRun cost facts (ADR-0021). + * Not payment collection — customers may supply their own provider keys. + */ +import type { Prisma, PrismaClient } from "@prisma/client"; + +export interface ProjectUsageRow { + readonly projectId: string; + readonly projectName: string; + readonly folderId: string | null; + readonly runCount: number; + readonly runsWithCost: number; + readonly runsWithoutCost: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly costUsd: number | null; +} + +export interface UsageReport { + readonly from: string | null; + readonly to: string | null; + readonly projects: readonly ProjectUsageRow[]; + readonly totals: { + readonly runCount: number; + readonly runsWithCost: number; + readonly runsWithoutCost: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly costUsd: number | null; + }; +} + +export async function getOrgUsage( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly from?: Date | undefined; + readonly to?: Date | undefined; + readonly folderId?: string | undefined; + }, +): Promise { + const projectWhere: Prisma.ProjectWhereInput = { + organizationId: input.organizationId, + ...(input.folderId !== undefined ? { folderId: input.folderId } : {}), + }; + const projects = await prisma.project.findMany({ + where: projectWhere, + select: { id: true, name: true, folderId: true }, + orderBy: { name: "asc" }, + }); + if (projects.length === 0) { + return emptyReport(input.from, input.to); + } + + const runWhere: Prisma.AgentRunWhereInput = { + projectId: { in: projects.map((p) => p.id) }, + ...(input.from !== undefined || input.to !== undefined + ? { + startedAt: { + ...(input.from !== undefined ? { gte: input.from } : {}), + ...(input.to !== undefined ? { lte: input.to } : {}), + }, + } + : {}), + }; + + const runs = await prisma.agentRun.findMany({ + where: runWhere, + select: { + projectId: true, + inputTokens: true, + outputTokens: true, + costUsd: true, + }, + }); + + type MutableRow = { + projectId: string; + projectName: string; + folderId: string | null; + runCount: number; + runsWithCost: number; + runsWithoutCost: number; + inputTokens: number; + outputTokens: number; + costUsd: number | null; + }; + + const byProject = new Map(); + for (const p of projects) { + byProject.set(p.id, { + projectId: p.id, + projectName: p.name, + folderId: p.folderId, + runCount: 0, + runsWithCost: 0, + runsWithoutCost: 0, + inputTokens: 0, + outputTokens: 0, + costUsd: null, + }); + } + + for (const run of runs) { + const row = byProject.get(run.projectId); + if (row === undefined) continue; + row.runCount += 1; + row.inputTokens += run.inputTokens ?? 0; + row.outputTokens += run.outputTokens ?? 0; + if (run.costUsd === null) { + row.runsWithoutCost += 1; + } else { + row.runsWithCost += 1; + const cost = Number(run.costUsd); + row.costUsd = (row.costUsd ?? 0) + cost; + } + } + + const projectsOut: ProjectUsageRow[] = [...byProject.values()]; + let runCount = 0; + let runsWithCost = 0; + let runsWithoutCost = 0; + let inputTokens = 0; + let outputTokens = 0; + let costUsd: number | null = null; + for (const row of projectsOut) { + runCount += row.runCount; + runsWithCost += row.runsWithCost; + runsWithoutCost += row.runsWithoutCost; + inputTokens += row.inputTokens; + outputTokens += row.outputTokens; + if (row.costUsd !== null) { + costUsd = (costUsd ?? 0) + row.costUsd; + } + } + + return { + from: input.from?.toISOString() ?? null, + to: input.to?.toISOString() ?? null, + projects: projectsOut, + totals: { + runCount, + runsWithCost, + runsWithoutCost, + inputTokens, + outputTokens, + costUsd, + }, + }; +} + +export async function getProjectUsage( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly projectId: string; + readonly from?: Date | undefined; + readonly to?: Date | undefined; + }, +): Promise { + const project = await prisma.project.findFirst({ + where: { id: input.projectId, organizationId: input.organizationId }, + select: { id: true, name: true, folderId: true }, + }); + if (project === null) { + throw new Error(`project not found: ${input.projectId}`); + } + const report = await getOrgUsage(prisma, { + organizationId: input.organizationId, + from: input.from, + to: input.to, + }); + const row = report.projects.find((p) => p.projectId === project.id); + return ( + row ?? { + projectId: project.id, + projectName: project.name, + folderId: project.folderId, + runCount: 0, + runsWithCost: 0, + runsWithoutCost: 0, + inputTokens: 0, + outputTokens: 0, + costUsd: null, + } + ); +} + +function emptyReport(from?: Date, to?: Date): UsageReport { + return { + from: from?.toISOString() ?? null, + to: to?.toISOString() ?? null, + projects: [], + totals: { + runCount: 0, + runsWithCost: 0, + runsWithoutCost: 0, + inputTokens: 0, + outputTokens: 0, + costUsd: null, + }, + }; +}