Every model call now says what it cost, and whose it was
The broker already wrote one cost row per provider generation. It could tell you what a firm spent. It could not tell you what a matter, a user, or a document spent, how many tokens went into it, how slow the model was, what it cost in rupees, or whether the work failed. It can now — on the one ledger that already existed.
The shape of the change
PRAMAAN already had a vendor cost ledger: billing.vendor_cost_event, created by migration 0178 under pramaan-docs ADR-173, written by the clerk broker on every consumed model.usage receipt. This Story extends that row rather than creating a second one.
brain emits model.usage ──► broker consumes it
│
├─ joins firm / matter / user / document / run / agent from the command
├─ takes tokens + model id from the receipt
├─ measures latency between stream frames
├─ prices it: provider cost if reported, else model-pricing.yaml
├─ freezes fx_rate_to_inr and computes cost_inr_micros
│
└──► ONE billing.vendor_cost_event row
Governing decision: pramaan-docs ADR-177 (amends and extends ADR-173).
What the row carries now
| Group | Columns | Where it comes from |
|---|---|---|
| Who | user_id (with existing firm_id, matter_id, document_id) | The broker's command and surface context |
| Run | run_id, agent_key, seat | The command; seat awaits the agent receipt |
| Model | model_id, route | The receipt; model_id promoted out of JSONB |
| Tokens | input_tokens, output_tokens, cache_read_tokens, cache_write_tokens | The receipt |
| Timing | latency_ms, output_tokens_per_sec | Broker-observed; throughput is a GENERATED column |
| Cost | fx_rate_to_inr, cost_inr_micros (with existing reporting_cost_usd_micros) | Frozen at write time from billing.pricing_config |
| Failure | status, finish_reason, cause_type, retries | The broker's failure path (ADR-124 R16) |
Four decisions worth knowing about
1. The answering model left the metadata blob — and the old spelling was deleted
model_id used to live as event_metadata->>'served_model', where no index could reach it. Migration 0205 backfills it into a real column, strips the key from every existing row, and removes served_model from the allowlist CHECK and the service validator in the same migration. Two spellings of the answering model is exactly the drift ADR-176 D3 forbids, so nothing is left behind for "part 2".
2. Rupees are frozen per row, never recomputed
Each row stores the rate that was applied (fx_rate_to_inr) beside the amount it produced (cost_inr_micros), both in integer micros. A rollup sums what was frozen. Two identical $1.00 generations priced at ₹83.25 and ₹110.50 sum to ₹193.75 — not twice either rate. A closed month cannot reprice.
3. Throughput is computed by Postgres, not by us
output_tokens_per_sec is GENERATED ALWAYS ... STORED over output_tokens and latency_ms. A derived number written by hand drifts from its inputs the first time one writer forgets; this one cannot. A missing latency yields NULL, not a division error and not a fabricated rate.
4. Failed work is a row
A generation that never completed emits no receipt, so a failing matter used to look cheap. A failed run now writes one zero-cost status='error' row carrying cause_type — the original exception's class name, content-free by construction. The money stays honest at zero; the work becomes visible.
Where prices come from
Provider-reported cost always wins. When OpenRouter returns a realized charge, that integer micro-USD number is billed verbatim as event_kind='actual' and the rate card is never consulted.
The card prices only what no provider priced — a direct native-provider call (ADR-176's direct-signup route returns no per-call cost) or a gateway call that reported cost_source='unreported'. Those rows are event_kind='estimate', stamped with rate_card_version, and reconciled monthly against the vendor invoice.
| Question | Answer |
|---|---|
Where does model-pricing.yaml live? | pramaan-functions, at src/shared/agent_contracts/model-pricing.yaml — beside the broker that applies it. Exactly one copy. |
Why not pramaan-agents? | pramaan-docs ADR-110: brains write no truth. Pricing a usage report is a billing act. |
| What replaced the old flat fallback? | The per-model card. The conservative-high flat rate survives only as the card's default entry, so an unlisted model still over-bills slightly rather than running free. |
| Is it in the Lambda package? | Yes, and the deploy workflow now asserts it. A data asset's absence produces no import error — it would deploy green and fail to price at runtime. |
Asking the ledger a question
Each rollup is one query, served by a partial (dimension, incurred_at DESC) index. src/domains/vendor_cost_event/cost_rollups.py holds them all rather than scattering SQL across call sites.
cost_rollup(session, dimension="matter", dimension_id=..., grain="month") cost_rollup(session, dimension="user", dimension_id=..., grain="day") cost_total_last_30_days(session, dimension="firm", dimension_id=...) top_models_by_cost(session, firm_id=...) # where the money actually goes
Windows are half-open (since inclusive, until exclusive) so consecutive windows tile without double-counting a boundary row, and buckets are UTC so a month boundary does not move with the reader's timezone.
top_models_by_cost is only answerable because model_id became a column — a GROUP BY event_metadata->>'served_model' could never be trusted at scale.
Two asks in the issue were narrowed, deliberately
Monthly partitioning — deferred. ADR-173 §4 already ruled "no launch partitioning; add it only after measured growth", and the blocker is structural as well as premature: PostgreSQL requires the partition key in every unique constraint. The ledger's exactly-once guarantee is a global unique idempotency_key — the one mechanism that makes SQS/EventBridge redelivery safe. Partitioning would force UNIQUE (idempotency_key, incurred_at), letting one provider generation bill twice across a month boundary, and would break the self-referencing reconciles_event_id FK. Revisit trigger is named: 50M rows or a 2s firm-month rollup.
Firm-scoped RLS read — not added. The RLS posture is unchanged: staff-only SELECT, producer INSERT, no firm read policy. ADR-173 §6.1 says customers see "never tokens, models, vendor names, vendor costs, prompts, or margin" — and after this change the table carries every one of those. A firm read policy would publish PRAMAAN's margin to its own customers. Firm-scoping is delivered instead as firm attribution plus firm-scoped rollups on staff-only routes. Showing a firm its own inference cost is a change to ADR-173 §6.1 and a founder call.
A third ask was mapped rather than duplicated: generation_id is source_id, already enforced by the openrouter:<generation_id> idempotency grammar. A second column holding the identical value is the drift this migration just deleted from metadata.
Acceptance evidence
| Migration round-trip | 0205 upgrade → downgrade → upgrade against real Postgres, then alembic check clean (entity mirrors schema exactly). |
|---|---|
| Red-first proof | 10 of 14 new integration tests were observed failing against the pre-0205 schema before they passed against it. The downgrade path was also observed failing (the GENERATED column blocked dropping its own inputs) and fixed. |
| Generated column | 500 output tokens in 2000 ms reads back as exactly 250.000 tok/s from the database; a null latency reads back null. |
| Frozen FX | Two rows at different rates aggregate to the sum of what was frozen, not to either rate applied twice. |
| Dimension CHECK | A textract row carrying inference dimensions is rejected by the service validator and, when planted directly, by the database. |
| Platform-private posture | A firm session sees zero rows through raw SQL and through the rollup module. |
| Rollups | Seeded events roll up correctly per firm / matter / user / document across day, month, and trailing-30 windows, with a sibling matter's spend correctly excluded. |
| Legacy deleted | src/shared/agent_contracts/pricing.py and its test are removed; served_model is gone from the allowlist, the CHECK, the validator, and every stored row. |
| Full suite | 3856 passed, 3 skipped. Lint, alembic check, OpenAPI snapshot, domain-events, observability, and model-placement gates all pass. |
Known limits, stated rather than hidden
latency_ms is broker-observed | Wall time from the previous stream frame to the model.usage frame, including stream transport — not provider-reported generation latency. The canonical agent receipt carries none today. |
|---|---|
seat and finish_reason are NULL | Neither rides the agent receipt yet. Both land when pramaan-agents stamps them; filed as a follow-up, not left implicit. |
| Ambient agents still write nothing | Registrar, framer, deep-read, and the context passes do not consume model.usage receipts at all, so their spend reaches no ledger. The clerk broker is the only wired producer; closing this is a filed follow-up. |
| The rate card is hand-maintained | It will drift from vendor list prices. rate_card_version plus monthly invoice reconciliation is the containment — a stale card produces a reconcilable estimate, never a silent wrong actual. A CI test asserts the card still covers the seats the fleet runs. |
Governing issue: pramaan-spec#2802.