feat(hub): usage fact ledger for run-scoped cost attribution (ADR-0026) (#4)

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
This commit is contained in:
2026-07-18 14:46:41 +08:00
committed by 洪佳荣
parent 97f7972cc5
commit aaa098bb8b
11 changed files with 760 additions and 46 deletions
@@ -0,0 +1,64 @@
-- ADR-0026: append-only UsageFact ledger. AgentRun.costUsd/inputTokens/
-- outputTokens become a derived rollup cache; the truth is in UsageFact.
CREATE TABLE "UsageFact" (
"id" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL,
"kind" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"model" TEXT,
"inputTokens" INTEGER,
"outputTokens" INTEGER,
"quantity" DECIMAL(18, 6),
"unit" TEXT,
"costUsd" DECIMAL(18, 8),
"costSource" TEXT NOT NULL,
"capabilityId" TEXT,
"correlationId" TEXT,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UsageFact_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "UsageFact_runId_occurredAt_idx" ON "UsageFact"("runId", "occurredAt");
CREATE INDEX "UsageFact_runId_kind_idx" ON "UsageFact"("runId", "kind");
CREATE INDEX "UsageFact_provider_model_occurredAt_idx"
ON "UsageFact"("provider", "model", "occurredAt");
CREATE INDEX "UsageFact_capabilityId_occurredAt_idx"
ON "UsageFact"("capabilityId", "occurredAt");
ALTER TABLE "UsageFact"
ADD CONSTRAINT "UsageFact_runId_fkey"
FOREIGN KEY ("runId") REFERENCES "AgentRun"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- Backfill: one synthetic model_completion fact per AgentRun that has any
-- recorded usage (cost or tokens). correlationId = run id marks these as
-- backfill artefacts (real facts use an external correlation id or null).
-- Runs with no recorded usage stay fact-less and remain runsWithoutCost,
-- matching ADR-0022's "missing cost ≠ zero" rule and the pre-existing
-- migration 20260709143000_agent_run_cost_tracking's "no backfill for the
-- truly unrecorded" stance.
INSERT INTO "UsageFact" (
"id", "runId", "occurredAt", "kind", "provider", "model",
"inputTokens", "outputTokens", "costUsd", "costSource",
"correlationId", "metadata"
)
SELECT
'usagefact_backfill_' || "AgentRun"."id",
"AgentRun"."id",
COALESCE("AgentRun"."finishedAt", "AgentRun"."startedAt", CURRENT_TIMESTAMP),
'model_completion',
"AgentRun"."provider",
"AgentRun"."model",
"AgentRun"."inputTokens",
"AgentRun"."outputTokens",
"AgentRun"."costUsd",
COALESCE("AgentRun"."costSource", 'unknown'),
"AgentRun"."id",
'{}'::jsonb
FROM "AgentRun"
WHERE "AgentRun"."costUsd" IS NOT NULL
OR "AgentRun"."inputTokens" IS NOT NULL
OR "AgentRun"."outputTokens" IS NOT NULL;
+54 -2
View File
@@ -596,9 +596,13 @@ model AgentRun {
summary String?
inputTokens Int?
outputTokens Int?
/// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact.
/// ADR-0026: derived rollup cache of UsageFact rows for this run. The truth
/// is in UsageFact; this column is convenience for existing readers. Null
/// means this run has no trusted cost fact (ADR-0022: missing cost ≠ zero).
costUsd Decimal? @db.Decimal(18, 8)
/// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback.
/// ADR-0026: semantic source of the rolled-up costUsd (provider_reported |
/// pricebook_derived | unknown). Mirrors the dominant CostSource of the
/// run's facts; not a pricing-estimate fallback.
costSource String?
metadata Json
error String?
@@ -612,6 +616,7 @@ model AgentRun {
projectLock ProjectAgentLock?
messages AgentMessage[] @relation("runMessages")
fileChanges AgentFileChange[] @relation("runFileChanges")
usageFacts UsageFact[] @relation("runUsageFacts")
@@index([projectId, status])
@@index([projectId, finishedAt])
@@ -817,3 +822,50 @@ model AgentFileChange {
@@index([projectId])
@@index([path])
}
// --- Usage fact ledger (ADR-0026) ----------------------------------------
/// ADR-0026: one billable consumption event inside an AgentRun. Append-only;
/// the run's AgentRun.costUsd/inputTokens/outputTokens are a derived rollup
/// of these rows, not the truth. kind=external_capability carries capabilityId
/// for media transforms (PDF→MD, audio/video→text, …). costUsd=null means
/// unknown, not zero (ADR-0022). The kind set is OPEN: new kinds must be
/// surfaced, not silently folded in.
model UsageFact {
id String @id @default(cuid())
runId String
/// When the consumption happened. Pricebook derivation uses this, not
/// AgentRun.finishedAt, because an external capability may finish before
/// the run ends.
occurredAt DateTime
/// model_completion | external_capability | tool_proxy. OPEN set.
kind String
/// e.g. openrouter, mineru, openai_whisper.
provider String
model String?
inputTokens Int?
outputTokens Int?
/// Non-token meter (pages, audio_seconds, invocations). Coexists with tokens.
quantity Decimal? @db.Decimal(18, 6)
unit String?
/// USD. Null = unknown, NOT zero (ADR-0022). When null, costSource=unknown.
costUsd Decimal? @db.Decimal(18, 8)
/// provider_reported | pricebook_derived | unknown. Required even when
/// costUsd is null, so a reader can distinguish "reported zero" from
/// "no report at all".
costSource String
/// For kind=external_capability, the registered capability id
/// (e.g. pdf_to_md_bundle, audio_video_to_text). Null for model_completion.
capabilityId String?
/// External request id for reconciliation / idempotency. Not an aggregation key.
correlationId String?
metadata Json
createdAt DateTime @default(now())
run AgentRun @relation("runUsageFacts", fields: [runId], references: [id], onDelete: Cascade)
@@index([runId, occurredAt])
@@index([runId, kind])
@@index([provider, model, occurredAt])
@@index([capabilityId, occurredAt])
}
+54 -24
View File
@@ -92,7 +92,18 @@ export function createSlashCommandRegistry(
finishedAt: { not: null },
},
orderBy: { finishedAt: "asc" },
select: { model: true, provider: true, inputTokens: true, outputTokens: true, costUsd: true },
select: {
usageFacts: {
select: {
provider: true,
model: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
},
orderBy: { occurredAt: "asc" },
},
},
});
await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions);
},
@@ -118,18 +129,24 @@ async function currentRoleSessionIds(
return sessions.map((session) => session.id);
}
interface UsageRun {
readonly model: string;
readonly provider: string;
readonly inputTokens: number | null;
readonly outputTokens: number | null;
readonly costUsd: unknown;
interface UsageRunWithFacts {
readonly usageFacts: readonly {
readonly provider: string;
readonly model: string | null;
readonly inputTokens: number | null;
readonly outputTokens: number | null;
readonly costUsd: unknown;
}[];
}
function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "project"): string {
function formatUsageReport(runs: readonly UsageRunWithFacts[], scope: "current" | "project"): string {
if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`;
// Bucket by (fact.provider, fact.model) — ADR-0026: external capabilities
// carry their own provider/model and contribute their own meter, so a run
// with a main loop + an external call lands in two buckets. A run with no
// cost-bearing fact is "unrecorded" (ADR-0022: missing cost ≠ zero).
const buckets = new Map<string, {
provider: string; model: string; runs: number; inputTokens: number; outputTokens: number; costUsd: number;
provider: string; model: string; facts: number; inputTokens: number; outputTokens: number; costUsd: number;
}>();
let recordedRuns = 0;
let unrecordedRuns = 0;
@@ -137,21 +154,34 @@ function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "projec
let totalOutputTokens = 0;
let totalCostUsd = 0;
for (const run of runs) {
const costUsd = decimalToNumberOrNull(run.costUsd);
if (costUsd === null) { unrecordedRuns++; continue; }
const facts = run.usageFacts;
if (facts.length === 0 || !facts.some((f) => f.costUsd !== null && f.costUsd !== undefined)) {
unrecordedRuns++;
// Tokens from unrecorded runs still count toward totals (matches pre-0026).
for (const f of facts) {
totalInputTokens += f.inputTokens ?? 0;
totalOutputTokens += f.outputTokens ?? 0;
}
continue;
}
recordedRuns++;
const key = `${run.provider}\u0000${run.model}`;
const bucket = buckets.get(key) ?? {
provider: run.provider, model: run.model, runs: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
};
bucket.runs++;
bucket.inputTokens += run.inputTokens ?? 0;
bucket.outputTokens += run.outputTokens ?? 0;
bucket.costUsd += costUsd;
buckets.set(key, bucket);
totalInputTokens += run.inputTokens ?? 0;
totalOutputTokens += run.outputTokens ?? 0;
totalCostUsd += costUsd;
for (const f of facts) {
const costUsd = decimalToNumberOrNull(f.costUsd);
if (costUsd === null) continue;
const model = f.model ?? "(unknown model)";
const key = `${f.provider}\u0000${model}`;
const bucket = buckets.get(key) ?? {
provider: f.provider, model, facts: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
};
bucket.facts += 1;
bucket.inputTokens += f.inputTokens ?? 0;
bucket.outputTokens += f.outputTokens ?? 0;
bucket.costUsd += costUsd;
buckets.set(key, bucket);
totalInputTokens += f.inputTokens ?? 0;
totalOutputTokens += f.outputTokens ?? 0;
totalCostUsd += costUsd;
}
}
const lines = [
`${scope === "current" ? "当前角色会话" : "当前项目"}用量`,
@@ -160,7 +190,7 @@ function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "projec
];
if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`);
for (const bucket of buckets.values()) {
lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}`);
lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.facts)} , ${formatUsd(bucket.costUsd)}`);
}
return lines.join("\n");
}
+29 -2
View File
@@ -604,6 +604,32 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
data: { metadata: mergeSessionMetadata(session.metadata, metadataPatch) },
});
}
// ADR-0026: the UsageFact ledger is the truth; AgentRun.costUsd /
// inputTokens / outputTokens are a derived rollup cache for existing
// readers. We write the fact first, then mirror it onto the run.
// They are separate statements (not one transaction) so the AgentRun
// row lock is held for the shortest possible window and concurrent
// workspace teardown cannot deadlock on the FK ShareLock. A crash
// between the two leaves the cache stale, but the cache is derived
// (the usage service re-reads facts), so staleness is recoverable;
// the reverse order would lose the truth and is unrecoverable.
const finishedAt = new Date();
const reportedCost = result.costUsd;
const costSource = reportedCost !== undefined ? "provider_reported" : "unknown";
await deps.prisma.usageFact.create({
data: {
runId: run.id,
occurredAt: finishedAt,
kind: "model_completion",
provider: providerId,
model,
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
costUsd: reportedCost ?? null,
costSource,
metadata: {},
},
});
await deps.prisma.agentRun.update({
where: { id: run.id },
data: {
@@ -618,9 +644,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
: "FAILED",
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
...(result.costUsd !== undefined ? { costUsd: result.costUsd, costSource: "provider_reported" } : {}),
costUsd: reportedCost ?? null,
costSource,
error: result.error ?? null,
finishedAt: new Date(),
finishedAt,
},
});
await writeAudit(deps.prisma, {
+35
View File
@@ -73,9 +73,28 @@ export async function getSessionDetail(
inputTokens: true,
outputTokens: true,
costUsd: true,
costSource: true,
startedAt: true,
finishedAt: true,
error: true,
usageFacts: {
select: {
id: true,
occurredAt: true,
kind: true,
provider: true,
model: true,
inputTokens: true,
outputTokens: true,
quantity: true,
unit: true,
costUsd: true,
costSource: true,
capabilityId: true,
correlationId: true,
},
orderBy: { occurredAt: "asc" },
},
},
orderBy: { startedAt: "desc" },
take: 100,
@@ -106,9 +125,25 @@ export async function getSessionDetail(
inputTokens: r.inputTokens,
outputTokens: r.outputTokens,
costUsd: r.costUsd === null ? null : Number(r.costUsd),
costSource: r.costSource,
startedAt: r.startedAt.toISOString(),
finishedAt: r.finishedAt?.toISOString() ?? null,
error: r.error,
usageFacts: r.usageFacts.map((f) => ({
id: f.id,
occurredAt: f.occurredAt.toISOString(),
kind: f.kind,
provider: f.provider,
model: f.model,
inputTokens: f.inputTokens,
outputTokens: f.outputTokens,
quantity: f.quantity === null ? null : Number(f.quantity),
unit: f.unit,
costUsd: f.costUsd === null ? null : Number(f.costUsd),
costSource: f.costSource,
capabilityId: f.capabilityId,
correlationId: f.correlationId,
})),
})),
};
}
+87 -17
View File
@@ -1,8 +1,14 @@
/**
* Org / project usage rollups from AgentRun cost facts (ADR-0021).
* 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.
*/
import type { Prisma, PrismaClient } from "@prisma/client";
@@ -32,6 +38,53 @@ export interface UsageReport {
};
}
type FactRow = {
readonly inputTokens: number | null;
readonly outputTokens: number | null;
readonly costUsd: unknown;
};
type RunWithFacts = {
readonly projectId: string;
readonly usageFacts: readonly FactRow[];
};
/** A run is "recorded with cost" if any of its facts carries a known costUsd. */
function runHasRecordedCost(facts: readonly FactRow[]): boolean {
return facts.some((f) => f.costUsd !== null && f.costUsd !== undefined);
}
/** Decimal→number for Prisma Decimal; null/undefined → null. */
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;
}
interface RunRollup {
readonly hasCost: boolean;
readonly inputTokens: number;
readonly outputTokens: number;
readonly costUsd: number | null;
}
function rollupRun(facts: readonly FactRow[]): RunRollup {
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 = (costUsd ?? 0) + c;
}
}
return { hasCost, inputTokens, outputTokens, costUsd };
}
export async function getOrgUsage(
prisma: PrismaClient,
input: {
@@ -54,8 +107,9 @@ export async function getOrgUsage(
return emptyReport(input.from, input.to);
}
const projectIds = projects.map((p) => p.id);
const runWhere: Prisma.AgentRunWhereInput = {
projectId: { in: projects.map((p) => p.id) },
projectId: { in: projectIds },
...(input.from !== undefined || input.to !== undefined
? {
startedAt: {
@@ -66,20 +120,25 @@ export async function getOrgUsage(
: {}),
};
// 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: runWhere,
select: {
projectId: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
usageFacts: {
select: { inputTokens: true, outputTokens: true, costUsd: true },
orderBy: { occurredAt: "asc" },
},
},
});
type MutableRow = {
projectId: string;
projectName: string;
folderId: string | null;
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
@@ -103,22 +162,33 @@ export async function getOrgUsage(
});
}
for (const run of runs) {
for (const run of runs as readonly RunWithFacts[]) {
const row = byProject.get(run.projectId);
if (row === undefined) continue;
const roll = rollupRun(run.usageFacts);
row.runCount += 1;
row.inputTokens += run.inputTokens ?? 0;
row.outputTokens += run.outputTokens ?? 0;
if (run.costUsd === null) {
row.runsWithoutCost += 1;
} else {
if (roll.hasCost) {
row.runsWithCost += 1;
const cost = Number(run.costUsd);
row.costUsd = (row.costUsd ?? 0) + cost;
row.costUsd = (row.costUsd ?? 0) + (roll.costUsd ?? 0);
} else {
row.runsWithoutCost += 1;
}
row.inputTokens += roll.inputTokens;
row.outputTokens += roll.outputTokens;
}
const projectsOut: ProjectUsageRow[] = [...byProject.values()];
const projectsOut: ProjectUsageRow[] = [...byProject.values()].map((r) => ({
projectId: r.projectId,
projectName: r.projectName,
folderId: r.folderId,
runCount: r.runCount,
runsWithCost: r.runsWithCost,
runsWithoutCost: r.runsWithoutCost,
inputTokens: r.inputTokens,
outputTokens: r.outputTokens,
costUsd: r.costUsd,
}));
let runCount = 0;
let runsWithCost = 0;
let runsWithoutCost = 0;
+191
View File
@@ -0,0 +1,191 @@
import { describe, it, expect, beforeEach } from "vitest";
import { prisma, resetDb, seedTestOrganization, DEFAULT_ORG_ID } from "./helpers.js";
import { getOrgUsage, getProjectUsage } from "../../src/org/usage.js";
/**
* ADR-0026: org/project usage rolls up from the UsageFact ledger, not from the
* AgentRun scalar cache. These tests pin the fact-based aggregation contract:
* - a run with a cost-bearing fact → runsWithCost, costUsd summed
* - a run whose facts all have null costUsd → runsWithoutCost (≠ zero)
* - non-token meter (quantity/unit) is stored but does not corrupt token sums
* - multiple facts per run (main loop + external capability) are summed together
* - the AgentRun rollup cache is NOT consulted by getOrgUsage
*/
describe("usage rollup from UsageFact (ADR-0026)", () => {
beforeEach(async () => {
await resetDb();
await seedTestOrganization();
});
async function seedRun(opts: {
readonly projectId: string;
readonly projectName: string;
readonly folderId?: string;
readonly facts: ReadonlyArray<{
readonly kind?: string;
readonly provider?: string;
readonly model?: string;
readonly inputTokens?: number;
readonly outputTokens?: number;
readonly costUsd?: number | null;
readonly costSource?: string;
readonly capabilityId?: string;
readonly quantity?: number | null;
readonly unit?: string | null;
}>;
}): Promise<void> {
const project = await prisma.project.create({
data: {
id: opts.projectId,
organizationId: DEFAULT_ORG_ID,
name: opts.projectName,
workspaceDir: `/tmp/test-${opts.projectId}`,
...(opts.folderId !== undefined ? { folderId: opts.folderId } : {}),
},
});
const startedAt = new Date("2026-07-18T10:00:00Z");
const finishedAt = new Date("2026-07-18T10:05:00Z");
const run = await prisma.agentRun.create({
data: {
projectId: project.id,
entrypoint: "FEISHU",
provider: "openrouter",
model: "mock-model",
metadata: {},
status: "COMPLETED",
prompt: "test",
startedAt,
finishedAt,
// Deliberately set the rollup cache to values that differ from the
// facts, to prove getOrgUsage reads facts, not the cache.
inputTokens: 999,
outputTokens: 999,
costUsd: 999,
costSource: "provider_reported",
},
});
for (const [i, f] of opts.facts.entries()) {
await prisma.usageFact.create({
data: {
runId: run.id,
occurredAt: new Date(startedAt.getTime() + i * 1000),
kind: f.kind ?? "model_completion",
provider: f.provider ?? "openrouter",
model: f.model ?? "mock-model",
inputTokens: f.inputTokens ?? null,
outputTokens: f.outputTokens ?? null,
costUsd: f.costUsd ?? null,
costSource: f.costSource ?? (f.costUsd !== undefined && f.costUsd !== null ? "provider_reported" : "unknown"),
capabilityId: f.capabilityId ?? null,
quantity: f.quantity ?? null,
unit: f.unit ?? null,
metadata: {},
},
});
}
}
it("sums cost and tokens from facts, ignoring the AgentRun rollup cache", async () => {
await seedRun({
projectId: "proj-a",
projectName: "A",
facts: [{ inputTokens: 10, outputTokens: 5, costUsd: 0.0023 }],
});
await seedRun({
projectId: "proj-b",
projectName: "B",
facts: [{ inputTokens: 20, outputTokens: 10, costUsd: 0.01 }],
});
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
expect(report.totals.runCount).toBe(2);
expect(report.totals.runsWithCost).toBe(2);
expect(report.totals.runsWithoutCost).toBe(0);
expect(report.totals.inputTokens).toBe(30);
expect(report.totals.outputTokens).toBe(15);
expect(report.totals.costUsd).toBeCloseTo(0.0123, 8);
});
it("treats a run with only null-cost facts as runsWithoutCost, never zero", async () => {
await seedRun({
projectId: "proj-unknown",
projectName: "Unknown",
facts: [{ inputTokens: 7, outputTokens: 3, costUsd: null, costSource: "unknown" }],
});
await seedRun({
projectId: "proj-known",
projectName: "Known",
facts: [{ inputTokens: 4, outputTokens: 2, costUsd: 0.05 }],
});
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
expect(report.totals.runCount).toBe(2);
expect(report.totals.runsWithCost).toBe(1);
expect(report.totals.runsWithoutCost).toBe(1);
// The unknown-cost run still contributes its tokens.
expect(report.totals.inputTokens).toBe(11);
expect(report.totals.outputTokens).toBe(5);
// And its cost is NOT counted as zero — only the known cost is summed.
expect(report.totals.costUsd).toBeCloseTo(0.05, 8);
});
it("sums multiple facts per run (main loop + external capability)", async () => {
await seedRun({
projectId: "proj-multi",
projectName: "Multi",
facts: [
{ kind: "model_completion", inputTokens: 100, outputTokens: 50, costUsd: 0.02 },
{
kind: "external_capability",
provider: "mineru",
model: null,
inputTokens: null,
outputTokens: null,
costUsd: 0.015,
costSource: "provider_reported",
capabilityId: "pdf_to_md_bundle",
quantity: 12,
unit: "pages",
},
],
});
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
expect(report.totals.runCount).toBe(1);
expect(report.totals.runsWithCost).toBe(1);
expect(report.totals.inputTokens).toBe(100);
expect(report.totals.outputTokens).toBe(50);
expect(report.totals.costUsd).toBeCloseTo(0.035, 8);
});
it("a run with no facts at all is runsWithoutCost and contributes nothing", async () => {
await seedRun({ projectId: "proj-empty", projectName: "Empty", facts: [] });
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
expect(report.totals.runCount).toBe(1);
expect(report.totals.runsWithCost).toBe(0);
expect(report.totals.runsWithoutCost).toBe(1);
expect(report.totals.inputTokens).toBe(0);
expect(report.totals.outputTokens).toBe(0);
expect(report.totals.costUsd).toBeNull();
});
it("getProjectUsage returns just that project's row", async () => {
await seedRun({ projectId: "proj-x", projectName: "X", facts: [{ costUsd: 0.1, inputTokens: 1, outputTokens: 1 }] });
await seedRun({ projectId: "proj-y", projectName: "Y", facts: [{ costUsd: 0.2, inputTokens: 2, outputTokens: 2 }] });
const row = await getProjectUsage(prisma, { organizationId: DEFAULT_ORG_ID, projectId: "proj-x" });
expect(row.projectId).toBe("proj-x");
expect(row.runCount).toBe(1);
expect(row.costUsd).toBeCloseTo(0.1, 8);
});
it("returns an empty report for an org with no projects", async () => {
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
expect(report.projects).toHaveLength(0);
expect(report.totals.runCount).toBe(0);
expect(report.totals.costUsd).toBeNull();
});
});
+3
View File
@@ -290,6 +290,9 @@ function mockPrisma(): PrismaClient {
findUnique: vi.fn(async () => null),
update: vi.fn(async () => ({ id: "run-1" })),
},
usageFact: {
create: vi.fn(async () => ({ id: "fact-1" })),
},
projectAgentLock: {
findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })),
create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => {