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

Replace the single-scalar cost model on AgentRun with an append-only
UsageFact ledger. One AgentRun owns zero or more UsageFact rows; each
records one billable consumption event (model completion, external
capability, or tool proxy) with its own provider/model/tokens/quantity/
cost. AgentRun.costUsd/inputTokens/outputTokens become a derived rollup
cache.

This unblocks external capabilities (PDF->MD bundle, audio/video->text)
that bill in non-token units (pages, seconds) through a different
provider than the main agent loop, without per-capability schema changes
or nested AgentRuns (which would pollute lock/admission/session
semantics).

Contract:
- spec/Spec/System/Agent/Usage.lean pins UsageFact, UsageFactKind,
  CostSource and three invariants: append-only; belongs to one run,
  never holds a lock; missing cost != zero (ADR-0022).
- ADR-0026 records the decision, the rejected nested-Run alternative,
  the rollup cache strategy, and the deferred capability registry /
  pricebook / post-hoc correction flows.

Schema:
- UsageFact model with indexes on (runId, occurredAt), (runId, kind),
  (provider, model, occurredAt), (capabilityId, occurredAt).
- Migration backfills one synthetic model_completion fact per existing
  run with recorded cost/tokens (correlationId = runId marks backfill);
  truly unrecorded runs stay runsWithoutCost per ADR-0022.

Write path (trigger finish):
- Write the UsageFact first, then mirror it onto AgentRun as two
  separate statements (not one transaction). The fact is the truth so it
  goes first; the cache is derived so it goes second. A crash between
  them leaves the cache stale but the usage service re-reads facts
  directly, so this is recoverable; the reverse order would lose the
  truth. Separate statements also avoid an AgentRun row lock held across
  the insert's FK ShareLock, which deadlocked concurrent workspace
  teardown under the Organization->Project->AgentRun->UsageFact cascade.

Read paths:
- org/usage.ts aggregates from UsageFact, ignoring the AgentRun cache.
- slash /usage buckets by (fact.provider, fact.model); a run with a
  main loop + an external call lands in two buckets.
- session detail exposes usageFacts[] + costSource for future per-run
  cost-breakdown UI.

Tests:
- usage.test.ts: 6 integration tests pin fact aggregation, missing-cost-
  !=-zero, multi-fact-per-run, empty-run, project-level, empty-org.
- trigger.test.ts: existing /usage assertion ($0.0023,
  openrouter / mock-model) passes on the fact path.
- feishu-reactions mock prisma gains usageFact.create.
This commit is contained in:
2026-07-18 14:44:23 +08:00
parent 97f7972cc5
commit f3b087371a
11 changed files with 760 additions and 46 deletions
+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;