forked from bai/curriculum-project-hub
f3b087371a
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.
65 lines
2.6 KiB
SQL
65 lines
2.6 KiB
SQL
-- 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;
|