/** * Org / project usage rollups from the UsageFact ledger (ADR-0021, ADR-0026). * Operational usage accounting, not payment collection. Organizations may use * either their own provider credentials or a distinct platform-managed * connection; commercial billing remains outside the pilot scope. * * The truth is in `UsageFact`; `AgentRun.costUsd / inputTokens / outputTokens` * are a derived rollup cache. This service reads `UsageFact` directly so that * external-capability consumption (PDF→MD, ASR, …) is attributed correctly, * not just the main model loop. A run with no cost-bearing fact is * `runsWithoutCost` — ADR-0022: missing cost ≠ zero. * * Breakdown buckets keep kind / provider / model / capability / unit, so the * admin UI can separate model tokens from non-token external meters instead of * collapsing everything into a single input/output token total. */ import type { Prisma, PrismaClient } from "@prisma/client"; export interface UsageTotals { readonly runCount: number; readonly runsWithCost: number; readonly runsWithoutCost: number; readonly inputTokens: number; readonly outputTokens: number; readonly costUsd: number | null; } export interface ProjectUsageRow extends UsageTotals { readonly projectId: string; readonly projectName: string; readonly folderId: string | null; } /** One ledger slice: kind + provider + model + capability + unit. */ export interface UsageBreakdownRow { readonly kind: string; readonly provider: string; readonly model: string | null; readonly capabilityId: string | null; readonly unit: string | null; readonly factCount: number; readonly factsWithCost: number; readonly factsWithoutCost: number; readonly inputTokens: number; readonly outputTokens: number; /** Sum of quantity when unit is non-null; null when this bucket is token-only. */ readonly quantity: number | null; readonly costUsd: number | null; } export interface UsageReport { readonly from: string | null; readonly to: string | null; readonly projects: readonly ProjectUsageRow[]; readonly totals: UsageTotals; readonly breakdown: readonly UsageBreakdownRow[]; } export interface ProjectUsageReport extends ProjectUsageRow { readonly from: string | null; readonly to: string | null; readonly breakdown: readonly UsageBreakdownRow[]; } type FactRow = { readonly kind: string; readonly provider: string; readonly model: string | null; readonly capabilityId: string | null; readonly unit: string | null; readonly quantity: unknown; readonly inputTokens: number | null; readonly outputTokens: number | null; readonly costUsd: unknown; }; type RunWithFacts = { readonly projectId: string; readonly usageFacts: readonly FactRow[]; }; type MutableTotals = { runCount: number; runsWithCost: number; runsWithoutCost: number; inputTokens: number; outputTokens: number; costUsd: number | null; }; type MutableBreakdown = { kind: string; provider: string; model: string | null; capabilityId: string | null; unit: string | null; factCount: number; factsWithCost: number; factsWithoutCost: number; inputTokens: number; outputTokens: number; quantity: number | null; costUsd: number | null; }; type MutableProjectRow = MutableTotals & { readonly projectId: string; readonly projectName: string; readonly folderId: string | null; }; const FACT_SELECT = { kind: true, provider: true, model: true, capabilityId: true, unit: true, quantity: true, inputTokens: true, outputTokens: true, costUsd: true, } as const; function factCostUsdToNumber(value: unknown): number | null { if (value === null || value === undefined) return null; const n = typeof value === "number" ? value : Number(value); return Number.isFinite(n) ? n : null; } function factQuantityToNumber(value: unknown): number | null { if (value === null || value === undefined) return null; const n = typeof value === "number" ? value : Number(value); return Number.isFinite(n) ? n : null; } function breakdownKey(f: FactRow): string { return [ f.kind, f.provider, f.model ?? "", f.capabilityId ?? "", f.unit ?? "", ].join("\u0000"); } function emptyMutableTotals(): MutableTotals { return { runCount: 0, runsWithCost: 0, runsWithoutCost: 0, inputTokens: 0, outputTokens: 0, costUsd: null, }; } function addCost(existing: number | null, add: number): number { return (existing ?? 0) + add; } function rollupRunTokensAndCost(facts: readonly FactRow[]): { readonly hasCost: boolean; readonly inputTokens: number; readonly outputTokens: number; readonly costUsd: number | null; } { let inputTokens = 0; let outputTokens = 0; let costUsd: number | null = null; let hasCost = false; for (const f of facts) { inputTokens += f.inputTokens ?? 0; outputTokens += f.outputTokens ?? 0; const c = factCostUsdToNumber(f.costUsd); if (c !== null) { hasCost = true; costUsd = addCost(costUsd, c); } } return { hasCost, inputTokens, outputTokens, costUsd }; } function accumulateBreakdown( buckets: Map, facts: readonly FactRow[], ): void { for (const f of facts) { const key = breakdownKey(f); let bucket = buckets.get(key); if (bucket === undefined) { bucket = { kind: f.kind, provider: f.provider, model: f.model, capabilityId: f.capabilityId, unit: f.unit, factCount: 0, factsWithCost: 0, factsWithoutCost: 0, inputTokens: 0, outputTokens: 0, quantity: null, costUsd: null, }; buckets.set(key, bucket); } bucket.factCount += 1; bucket.inputTokens += f.inputTokens ?? 0; bucket.outputTokens += f.outputTokens ?? 0; const qty = factQuantityToNumber(f.quantity); if (qty !== null) { bucket.quantity = (bucket.quantity ?? 0) + qty; } const c = factCostUsdToNumber(f.costUsd); if (c !== null) { bucket.factsWithCost += 1; bucket.costUsd = addCost(bucket.costUsd, c); } else { bucket.factsWithoutCost += 1; } } } function freezeBreakdown(buckets: Map): UsageBreakdownRow[] { return [...buckets.values()] .map((b) => ({ kind: b.kind, provider: b.provider, model: b.model, capabilityId: b.capabilityId, unit: b.unit, factCount: b.factCount, factsWithCost: b.factsWithCost, factsWithoutCost: b.factsWithoutCost, inputTokens: b.inputTokens, outputTokens: b.outputTokens, quantity: b.quantity, costUsd: b.costUsd, })) .sort((a, b) => { const kindCmp = a.kind.localeCompare(b.kind); if (kindCmp !== 0) return kindCmp; const provCmp = a.provider.localeCompare(b.provider); if (provCmp !== 0) return provCmp; const capA = a.capabilityId ?? ""; const capB = b.capabilityId ?? ""; const capCmp = capA.localeCompare(capB); if (capCmp !== 0) return capCmp; const modelA = a.model ?? ""; const modelB = b.model ?? ""; return modelA.localeCompare(modelB); }); } function freezeTotals(t: MutableTotals): UsageTotals { return { runCount: t.runCount, runsWithCost: t.runsWithCost, runsWithoutCost: t.runsWithoutCost, inputTokens: t.inputTokens, outputTokens: t.outputTokens, costUsd: t.costUsd, }; } function applyRunToTotals(totals: MutableTotals, facts: readonly FactRow[]): void { const roll = rollupRunTokensAndCost(facts); totals.runCount += 1; if (roll.hasCost) { totals.runsWithCost += 1; totals.costUsd = addCost(totals.costUsd, roll.costUsd ?? 0); } else { totals.runsWithoutCost += 1; } totals.inputTokens += roll.inputTokens; totals.outputTokens += roll.outputTokens; } function emptyReport(from?: Date, to?: Date): UsageReport { return { from: from?.toISOString() ?? null, to: to?.toISOString() ?? null, projects: [], totals: freezeTotals(emptyMutableTotals()), breakdown: [], }; } 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 projectIds = projects.map((p) => p.id); // Date range filters by run.startedAt, matching the pre-ADR-0026 semantics: // a run is in or out of the window based on when it started, and all of its // facts come along. Filtering facts by occurredAt independently would let a // run contribute partial cost to a window it doesn't belong to. const runs = await prisma.agentRun.findMany({ where: { projectId: { in: projectIds }, ...(input.from !== undefined || input.to !== undefined ? { startedAt: { ...(input.from !== undefined ? { gte: input.from } : {}), ...(input.to !== undefined ? { lte: input.to } : {}), }, } : {}), }, select: { projectId: true, usageFacts: { select: FACT_SELECT, orderBy: { occurredAt: "asc" }, }, }, }); const byProject = new Map(); for (const p of projects) { byProject.set(p.id, { projectId: p.id, projectName: p.name, folderId: p.folderId, ...emptyMutableTotals(), }); } const breakdownBuckets = new Map(); for (const run of runs as readonly RunWithFacts[]) { const row = byProject.get(run.projectId); if (row === undefined) continue; applyRunToTotals(row, run.usageFacts); accumulateBreakdown(breakdownBuckets, run.usageFacts); } const projectsOut: ProjectUsageRow[] = [...byProject.values()].map((r) => ({ projectId: r.projectId, projectName: r.projectName, folderId: r.folderId, ...freezeTotals(r), })); const totals = emptyMutableTotals(); for (const row of projectsOut) { totals.runCount += row.runCount; totals.runsWithCost += row.runsWithCost; totals.runsWithoutCost += row.runsWithoutCost; totals.inputTokens += row.inputTokens; totals.outputTokens += row.outputTokens; if (row.costUsd !== null) { totals.costUsd = addCost(totals.costUsd, row.costUsd); } } return { from: input.from?.toISOString() ?? null, to: input.to?.toISOString() ?? null, projects: projectsOut, totals: freezeTotals(totals), breakdown: freezeBreakdown(breakdownBuckets), }; } 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 runs = await prisma.agentRun.findMany({ where: { projectId: project.id, ...(input.from !== undefined || input.to !== undefined ? { startedAt: { ...(input.from !== undefined ? { gte: input.from } : {}), ...(input.to !== undefined ? { lte: input.to } : {}), }, } : {}), }, select: { usageFacts: { select: FACT_SELECT, orderBy: { occurredAt: "asc" }, }, }, }); const totals = emptyMutableTotals(); const breakdownBuckets = new Map(); for (const run of runs) { applyRunToTotals(totals, run.usageFacts as readonly FactRow[]); accumulateBreakdown(breakdownBuckets, run.usageFacts as readonly FactRow[]); } return { projectId: project.id, projectName: project.name, folderId: project.folderId, ...freezeTotals(totals), from: input.from?.toISOString() ?? null, to: input.to?.toISOString() ?? null, breakdown: freezeBreakdown(breakdownBuckets), }; }