forked from bai/curriculum-project-hub
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.
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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<UsageReport> {
|
||||
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<string, MutableRow>();
|
||||
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<ProjectUsageRow> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user