forked from EduCraft/curriculum-project-hub
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:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } }) => {
|
||||
|
||||
Reference in New Issue
Block a user