Skip to content

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.

sql
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:

sql
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:

sql
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:

sql
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 rowTemporal rate card, joined at query timePrice written onto the call row
February's answer in AprilRecomputed at April ratesCorrectCorrect
Query complexitySimple joinJoin with a BETWEEN valid_from AND valid_to predicateNo join
Cost of a price changeOne UPDATE, history silently rewrittenClose one interval, open anotherInsert a version; existing rows untouched
Restating history deliberatelyImpossible to detect, always happeningPossible, by editing intervalsRequires an explicit backfill you can review
Extra bytes per call row00~40 bytes
Failure mode when it goes wrongSilent, direction unknowableLoud — rows fall outside every intervalLoud — 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.

python
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

Back to top