Chapter 1 of 7 · 17 min read
Pricing a call at write time
On this page
A cost ledger is a table into which a system writes one row every time it calls a model, recording what the call consumed and what it was for. It exists because a provider's invoice does not answer the questions a business asks about model spend: the bill arrives once a month, broken down by model and by API key, and has nothing to say about which feature of your product generated the traffic, which customer the work was done for, or which of yesterday's clicks was the expensive one. Cost attribution, the subject of this topic, is the practice of recording every model call against the request, the user, the feature and the tenant — the customer organisation whose account the work served — that caused it, so spend can be added up along any of those lines afterwards. You meet it in the spend dashboard a product manager opens on Monday, in the per-customer margin analysis finance asks for at quarter end, and in the alert telling an on-call engineer that one feature became four times more expensive at 14:07 this afternoon.
The money on such a row has to be computed, because a provider does not quote a
price for each call as it happens. What comes back from a call is a count of
tokens, and dollars come from multiplying that count by a published per-token
price. The table of those prices — every model the system uses, with what it
charges per million input tokens and per million output tokens — is the rate
card; usd_per_mtok in the schemas below is dollars per million tokens. The
rate card is the provider's document rather than yours, revised whenever they
choose, and this chapter turns on a single question about your own system: when
does it consult that document? At the moment the call is made, or later, at the
moment somebody asks what the call cost.
The schema that reprices history
The design that answers "later" is the one almost everyone writes first, because it is the one a normalisation-minded engineer would defend in review. Token counts go in a facts table, prices go in a small lookup keyed by model, and the money is computed at query time by joining the two.
CREATE TABLE model_prices (
model text PRIMARY KEY,
input_usd_per_mtok numeric(12,6) NOT NULL,
output_usd_per_mtok numeric(12,6) NOT NULL
);
CREATE TABLE llm_calls (
id bigserial PRIMARY KEY,
occurred_at timestamptz NOT NULL,
feature text NOT NULL,
model text NOT NULL REFERENCES model_prices(model),
input_tokens integer NOT NULL,
output_tokens integer NOT NULL
);
-- February spend for the summarize feature
SELECT sum(c.input_tokens / 1e6 * p.input_usd_per_mtok
+ c.output_tokens / 1e6 * p.output_usd_per_mtok) AS usd
FROM llm_calls c
JOIN model_prices p ON p.model = c.model
WHERE c.feature = 'summarize'
AND c.occurred_at >= '2026-02-01' AND c.occurred_at < '2026-03-01';The failure is already visible in the join predicate, before any price changes.
The condition is p.model = c.model and nothing else, so the only thing the
query knows about a row when it goes looking for a price is which model served
it. A price is not a property of a model, though. It is a property of a model
during an interval, and the interval is exactly the information the predicate
does not carry. One price is therefore available to the query — whichever row
happens to sit in model_prices at the instant it runs — and that price is
applied to traffic from whatever month you asked about.
That query does not report February's cost. It reports what February's traffic would cost if it ran again today, which is a different and much less useful quantity. The distinction is invisible while prices hold still, and prices do not hold still. Anthropic's Claude Sonnet 5 shipped at an introductory $2.00 per million input tokens and $10.00 per million output tokens, reverting to $3.00 and $15.00 on 1 September 2026. A team on that model with a query-time join will see every request logged before September silently reprice upward by 50% the morning the intro period ends — a $40,000 restatement of history caused by nothing more than a row update. Price changes also run the other way and arrive without warning, and the join has no opinion about which direction is happening.
Both pairs of figures there move by the same ratio, which is worth checking
rather than taking on trust: $3.00 against $2.00 is one and a half times, and
$15.00 against $10.00 is one and a half times as well, so the 50% applies to
input and output alike. The repricing also does not happen on the date in the
announcement. It happens the moment somebody runs the UPDATE, which may be
days early or weeks late, and no query gives any sign of which day that was.
What makes the loss unrecoverable rather than merely wrong is that the UPDATE
destroys the only copy. The rate that applied in February lived in exactly one
place, a row in model_prices, and overwriting it leaves nothing in the
database from which February's answer could be rebuilt. The llm_calls row
survives intact — an honest occurred_at, an honest model, honest token
counts — and none of those three determines a price. Anyone checking whether the
data is still there will find that it is, which is a large part of why this
design passes review.
It is tempting for a second reason, and that reason is usually good advice.
Storing a value you could derive is duplication, duplication is how a database
comes to hold two answers to one question, and a normalising instinct therefore
says keep the token counts and look the price up. That reasoning holds for facts
that are true simultaneously. It fails here because model → price is not a
functional dependency of a call at all: the dependency is
(model, time) → price, and a lookup keyed on model alone has nowhere to put
the time. Writing the applied rate onto the call row is not denormalisation of a
derived value. It is recording an attribute of the transaction that no other
table holds.
Historical cost, not replacement cost
Accounting has had a word for this distinction for about five hundred years. An asset is carried at historical cost — what you actually paid for it — rather than at what it would cost to replace today, precisely so a change in the market cannot silently rewrite what happened last quarter. Spend on model calls is the same kind of fact. The cost of a February request is a completed transaction whose amount was fixed at the instant the request was billed, and the only reason a database can lose that fact is that nobody wrote it down.
Writing it down means putting the applied unit price on the row itself, at the moment the call completes:
CREATE TABLE llm_calls (
id bigserial PRIMARY KEY,
occurred_at timestamptz NOT NULL,
feature text NOT NULL,
model text NOT NULL,
input_tokens integer NOT NULL,
output_tokens integer NOT NULL,
-- applied rate, snapshotted at call time, never updated
input_usd_per_mtok numeric(12,6) NOT NULL,
output_usd_per_mtok numeric(12,6) NOT NULL,
rate_card_version text NOT NULL,
cost_usd numeric(14,8) NOT NULL
);The February query then has no join and no dependency on today's world:
SELECT sum(cost_usd) AS usd
FROM llm_calls
WHERE feature = 'summarize'
AND occurred_at >= '2026-02-01' AND occurred_at < '2026-03-01';Storing both the applied rates and the computed money is deliberate redundancy,
and it earns its keep twice. The cost_usd column makes every aggregate a
plain sum() that a product manager can write, with no per-category arithmetic
to get wrong. The rate columns let you audit any row six months later without
reconstructing which rate card was live at 14:02 on a Tuesday — the row states
what it was charged, so a disputed number resolves by reading the row rather
than by archaeology through deploy logs.
The rate_card_version column is what turns the rate card from a mutable
lookup into an append-only record. A price change stops being an UPDATE and
becomes an insert of a new version, with the previous version left intact and
referenced by every row priced under it. With the applied rates already sitting
on the call row, that column can look like a third copy of the same fact, and it
is not. The rates say what this row was charged; the version says which document
said so. Naming the document is what makes a whole population of rows
addressable at once — every row priced under one version can be found, counted,
and, if that version turns out to have been entered wrongly, repriced as an
explicit set rather than by guessing at dates. The lookup itself gains a
validity interval:
CREATE TABLE rate_card (
version text NOT NULL,
model text NOT NULL,
input_usd_per_mtok numeric(12,6) NOT NULL,
output_usd_per_mtok numeric(12,6) NOT NULL,
valid_from timestamptz NOT NULL,
valid_to timestamptz, -- null = currently in force
PRIMARY KEY (version, model)
);Data warehousing has a name for this shape too — a slowly changing dimension, type 2 — and the point of the pattern is that history stays queryable when the present changes. The three designs differ in what they cost and what they can answer:
| Mutable price row | Temporal rate card, joined at query time | Price written onto the call row | |
|---|---|---|---|
| February's answer in April | Recomputed at April rates | Correct | Correct |
| Query complexity | Simple join | Join with a BETWEEN valid_from AND valid_to predicate | No join |
| Cost of a price change | One UPDATE, history silently rewritten | Close one interval, open another | Insert a version; existing rows untouched |
| Restating history deliberately | Impossible to detect, always happening | Possible, by editing intervals | Requires an explicit backfill you can review |
| Extra bytes per call row | 0 | 0 | ~40 bytes |
| Failure mode when it goes wrong | Silent, direction unknowable | Loud — rows fall outside every interval | Loud — the insert fails with no rate in force |
The middle column of that table deserves a caveat, because a temporal rate card
joined at query time is the compromise a careful engineer proposes next. It does
answer February correctly, and it does so by re-deriving the price from
valid_from and valid_to every time anybody asks. That is correct exactly as
long as the intervals are, and an interval is a mutable row like any other: one
edited to fix a typo silently changes every historical answer falling inside it,
which is the original failure with more columns. It also requires every call
row's occurred_at to land inside exactly one interval for its model, so a gap
leaves rows the join drops and an overlap leaves rows the join counts twice.
The forty bytes a row in that table's last column are the whole storage cost of the third design, and at 4.2 million calls a day they come to about 170 MB a day of price columns — a rounding error against the token counts and identifiers already on the row, and a very cheap price for never having to explain a restatement to finance. That figure is 4.2 million multiplied by forty, which comes to a hundred and sixty-eight million bytes a day. Keep the temporal rate card as well — it is where the price-at-write lookup reads from, and it is what answers "what did we charge on this date" when a row is missing or disputed.
A lookup in the request path fails in two ways
Moving the join from query time to write time does not make it disappear. It relocates it into the request path, where it acquires two failure modes worth designing for before they occur in production.
The first is that the model you priced against may not be the model that served
the request. Code that reads the model parameter it passed in is pricing a
request it made rather than a request that ran, and the two diverge in several
ordinary situations. Aliases resolve to whatever is current. Server-side
fallbacks re-run a declined request on a different model inside the same call,
and the response's top-level model field names the model that produced the
returned message while usage.iterations carries the per-attempt breakdown —
so a fallback turn priced from the request parameter is priced at the wrong
model's rates, and priced from the top-level usage alone it omits whatever the
first attempt streamed before declining. The rule that survives all of this is
to price from the response: read response.model for the rate lookup, and
where the provider exposes per-attempt usage, write one row per attempt at each
attempt's own model rate.
The reason the response is the authority and the request is not comes down to where the resolution happens. An alias is a name your process sends; the provider turns it into a specific snapshot at dispatch, on its own side, and your process never observes that step except in what comes back. The request parameter is therefore a statement of intent, and the response field is a record of what ran. Those two agree most of the time, which is what makes the habit of pricing from the request survive so long — it is right until the day an alias moves or a fallback fires, and on that day it is wrong without changing its behaviour in any visible way.
def price_call(response, rate_card) -> Row:
served_model = response.model # not the model you asked for
rate = rate_card.in_force(served_model, at=now())
if rate is None:
return Row(model=served_model, cost_usd=None,
pricing_status="no_rate_in_force")
return Row(model=served_model, cost_usd=compute(response.usage, rate),
rate_card_version=rate.version, pricing_status="priced")The second failure mode is the rate is None branch, and how it is handled
decides whether a routine event becomes an outage. A new model identifier will
appear in production before it appears in your rate card — a provider ships an
alias, a colleague flips a config, a fallback names a model nobody has priced.
Raising there means an unpriced model takes down the feature, which is an
absurd trade: the call already succeeded and the user already has their answer.
Writing the row with a null cost and a pricing_status of no_rate_in_force
keeps the request serving, keeps the token counts, and leaves an explicit,
countable, backfillable gap. Alert on rows in that state rather than failing on
them, and backfill them once by inserting the missing rate card entry and
repricing exactly the rows that carry the marker — a backfill you can scope,
review and repeat, which is the opposite of the silent global repricing the
mutable lookup performs.
The llm_calls schema above has to give a little for that branch to be
writable, and it is
worth noticing rather than discovering at the first unpriced model. cost_usd
and the two rate columns are declared NOT NULL, and a row carrying a
pricing_status of no_rate_in_force has none of the three. Those columns have
to become nullable, and the guarantee the NOT NULL was providing moves onto
the status: a check constraint saying the money is present exactly when the
status reads priced. The property being protected is not that every column is
filled in. It is that no row ever carries a blank price while claiming to be
priced, and a check constraint states that directly where a NOT NULL could
only approximate it.
The money column itself deserves one line of care. A single Haiku call can cost
$0.0000042, and storing that as cents-as-integer rounds it to zero, so a
million such calls sum to nothing at all. Store the per-call figure in a
fixed-point decimal type with enough scale to hold it — numeric(14,8) covers
eight decimal places, a hundred-millionth of a dollar — and round to cents only
at the point of display. Floating point is the other trap: summing 4.2 million
doubles accumulates error that shows up as a few cents of drift a day, small
enough to survive review and large enough to make two dashboards disagree.
Each of those three claims has a step under it. $0.0000042 expressed in cents is
forty-two hundred-thousandths of a cent, so an integer cent column stores zero,
and a million rows each holding zero sum to zero rather than to a small number —
the error is total rather than approximate. numeric(14,8) clears that by two
places, since $0.0000042 needs seven and the type carries eight; the remaining
six digits sit ahead of the point, which is room for a per-call figure up to just
under a million dollars.
The floating-point failure is the one that looks harmless, and its mechanism is
worth having. Binary floating point cannot represent most decimal fractions
exactly, so a price stored as a double is already a hair away from the price
that was quoted. Addition of such values is also not associative: adding the
same set of rows in a different order produces a different total in the last
places. That is why the symptom is two dashboards disagreeing rather than one
dashboard being wrong — a different GROUP BY, or the same query split across a
different number of parallel workers, adds the same rows in a different sequence
and lands on a slightly different number. A fixed-point decimal type has neither
property, which is why the fix is a column type rather than a rounding rule.
List cost, currency, and arithmetic that is simply wrong
A correctly priced row states what the published rate card says the call cost, which is not the same as what the company paid. Enterprise agreements carry negotiated discounts, committed-use rebates, and credits landing at the account level long after the call. A row priced from the public rate card is a list cost, and Anthropic's Managed Agents surface is explicit about the distinction: session budgets are enforced against list-priced consumption, so a session hits its cap when the list total does, while the billed amount may be lower. Treat a fact table the same way. Store list cost per row, keep the negotiated multiplier as a separate account-level adjustment applied at reporting time, and never bake the discount into the per-call number — discounts get renegotiated, and baking them in reintroduces exactly the mutable-history problem the row snapshot removed.
Currency is the same problem wearing a different hat, and it catches teams
reporting in anything other than the currency the provider bills in. If the
provider bills in dollars and the business reports in euros, converting at
query time reprices every historical month at today's exchange rate, and
exchange rates move considerably more often than rate cards do — a 6% move over
a quarter is unremarkable. The fix is identical in shape: store cost_usd as
the immutable fact, and where a second currency is needed, store cost_eur
alongside it with the FX rate and its date snapshotted onto the row. Two money
columns and an fx_rate_date is a small schema cost against a reporting line
that would otherwise change every morning.
The arithmetic there is worth doing once. A February total held in dollars does not move, so converting it in April at April's rate produces a euro figure differing from the March one by the whole of the currency move — the 6% arrives on the report although nothing about February changed. Repeat that each quarter and the historical euro figure is never the same twice, with no version of it more correct than any other, since none of them records anything that happened. Snapshotting the rate is what turns one of them into a record. Snapshotting the date beside it is what makes that record checkable, because which day's rate applies — the day of the call, the day of the invoice, the last day of the month — is a policy decision, and a rate with no date attached cannot be tested against the policy or corrected when the policy changes.
Immutability also does nothing about the arithmetic being wrong. If the code applies the input rate to a token count that includes cached reads, every row is internally consistent, permanently wrong, and confidently auditable. Snapshotting preserves whatever you wrote; it does not check it. Reconciliation against the provider's own totals is what catches that class of error, and it needs to run on a schedule rather than on suspicion.
The belief to watch for there is that a stable number is a verified one. A figure that comes back identical every time anybody queries it invites the reading that somebody checked it, when the only thing established is that nobody overwrote it. Stable and correct are independent properties of a row, and the design in this chapter buys the first outright while leaving the second entirely to whoever wrote the multiplication.
The rule this chapter reduces to is one sentence: a cost row carries its own price. The rate card is consulted once, in the request path, by code that reads the model out of the response rather than out of the request, and the number it produces is never recomputed. Everything else in this topic — grouping calls into user actions, splitting token categories, reconciling against the invoice — assumes that a row means the same thing in April that it meant in February, and none of those mechanisms can restore that property later.
A priced row is still just one row, though. A single model call is rarely the
thing anyone wants to know the cost of. The question that gets asked is what a
user's click cost, or what a tenant cost last month, or whether the research
feature is profitable at the current price point — and every one of those is
a sum() over some set of rows that has to be identified as belonging
together. A click that fires a planner call, four sub-agent calls, two retries
and an asynchronous summariser leaves nine rows behind, each with an honest
timestamp, an honest model and an honest price, and nothing whatsoever
connecting them.
Cost tracking per request, user, feature and tenant — read the current version at https://daviddominguez.dev/learn/en/cost-tracking-per-request-user-feature-and-tenant/pricing-a-call-at-write-time