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
+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");
}