forked from EduCraft/curriculum-project-hub
eb0be43eac
Expose UsageFact kind/capability rollups on org and project usage reports, and add admin pages that separate model tokens from external-capability meters. Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
437 lines
12 KiB
TypeScript
437 lines
12 KiB
TypeScript
/**
|
|
* 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<string, MutableBreakdown>,
|
|
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<string, MutableBreakdown>): 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<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 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<string, MutableProjectRow>();
|
|
for (const p of projects) {
|
|
byProject.set(p.id, {
|
|
projectId: p.id,
|
|
projectName: p.name,
|
|
folderId: p.folderId,
|
|
...emptyMutableTotals(),
|
|
});
|
|
}
|
|
|
|
const breakdownBuckets = new Map<string, MutableBreakdown>();
|
|
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<ProjectUsageReport> {
|
|
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<string, MutableBreakdown>();
|
|
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),
|
|
};
|
|
}
|