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])
}