forked from bai/curriculum-project-hub
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user