forked from EduCraft/curriculum-project-hub
008c8bcd09
List project agent sessions/runs and aggregate AgentRun cost and token usage by project and optional folder for org admins.
204 lines
5.2 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|