Files
curriculum-project-hub/hub/src/org/usage.ts
T
hongjr03 008c8bcd09 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.
2026-07-10 00:55:43 +08:00

204 lines
5.2 KiB
TypeScript

/**
* 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,
},
};
}