Reading a settled cost
Add up a month that includes async jobs without double-counting: which dollar replaces which, and which count is a subset.
On this page
It’s month end and your log has async jobs in it. A job a provider took and answered later is two lines rather than one: the submit, written when the request landed, and the settlement, written when the poll came back with what it cost.
Add those two lines up the way you’d add up two ordinary rows and the dollar survives, but the population doesn’t. You report one request as two, and a job that has already settled sits in your unpriced count for the rest of the month. This page is the procedure that avoids that, plus the two adjustments a settled dollar needs before you hold it beside an invoice.
Every surface that reads cost does the join for you: top and report on the command line, the
analytics API, budget status, the dashboard’s record drawer. The steps below are what to do when
you’re reading raw JSONL, writing your own aggregate, or checking a figure somebody else produced.
You don’t need the mechanism to follow them. If you want it, providers that take a job and answer later covers why a submit lands unpriced, what the job journal holds, and how an abandoned job is recorded.
1. Read the total, then the two lines under it
A total over async traffic is incomplete in two different ways, and top prints both of them under
the table rather than folding them into the dollar. Read those before you read the total.
The capture behind this page is printed in full under what a pending row does to your
totals: a six-line seeded
ledger holding three synchronous requests, one job that settled and one job still in flight. Its
$0.001530 total is checkable by hand, three synchronous requests at $0.000360 plus the settled
job’s $0.000450, and REQS says 5 while the cost column covers 4, because the table doesn’t
quietly shrink its denominator to the rows it could price. Under the table it renders these:
COST excludes 1 unpriced row of 5 (no price resolved; the $0 is a missing price, not $0 of spend)
pending settlement: 1 row (an async job the provider has not costed yet; a settlement row will resolve it — the dollars are not missing, they are not knowable yet)
The two lines count different things, which is why they are printed as two lines.
COST excludes 1 unpriced row of 5 is the exclusion: a row carrying no dollar, whatever the cause.
pending settlement: 1 row names the part of that exclusion whose money is in flight rather than
missing. Both lines are conditional, so a log with no unpriced rows and no async jobs renders
without either, and their absence is information too. The commands themselves are documented in
reading your spend from the terminal.
2. Fold each job by replacement
The two lines of a job share a req_id, and that key is the fold. The settlement’s cost cell
replaces the submit row’s; the result is one row, not two.
Here are the two cost cells from the settled job in that ledger. The submit row’s:
{"usd":0,"pricing_key":"","pricing_snapshot":"litellm@2026-07-14#179affb","estimated":true,"would_usd":null,"unpriced":true,"unpriced_reason":"settlement_pending"}
and the settlement’s:
{"usd":0.00045,"pricing_key":"openai/gpt-4o-mini","pricing_snapshot":"litellm@2026-07-14#179affb","estimated":false,"would_usd":null}
Both are excerpted from the same capture as the table above. The empty pricing_key on the first
is not an omission: a pending row has no winning price key, and inventing one would be provenance
for a dollar that does not exist. The second is arithmetic you can redo, over the settlement’s own
provider-reported usage: 1000 input tokens at $0.00000015 and 500 output tokens at $0.0000006 come
to $0.00045.
So the operation is replacement. Take the settlement’s cost cell, discard the submit row’s, and keep the submit row’s identity, model, endpoint and timestamps, because the settlement line does not carry most of them.
Read the pair as two independent rows and the damage is not in the dollar. Adding $0 to
$0.00045 gives $0.00045, so a spot check on one job passes. What breaks is the population: you
report 6 rows where 5 requests happened, and the settled job’s submit row stays in your unpriced
count for good, because on its own that line still reads unpriced_reason: settlement_pending.
That last detail is also the answer to the other question this page gets. The submit row is not corrected. The log is append-only: the submit line is written once and stays as written, and the settlement arrives as a second line rather than a rewrite of the first. The dashboard’s record drawer shows the submit row verbatim beside the folded arithmetic for that reason. Read the first line as history, not as an error nobody got round to fixing.
3. Subtract the subset, don’t add it
Two counts travel together on every cost surface, and they are the pair most likely to be summed.
The capture in step 1 shows the pair at its smallest. COST excludes 1 unpriced row of 5 and
pending settlement: 1 row are the same row. Summed, that row is counted twice and you go hunting
for a rate nobody is missing. Subtracted, the answer is that zero rows need a price, which is
correct: the one exclusion in that log is a job the provider has not costed yet.
Count pending structurally, not by reason string
The obvious implementation is to count rows where unpriced_reason equals settlement_pending. It
gives the wrong number, and it errs in the direction that hides the problem.
An abandoned job keeps that reason on its submit row for the life of the log, because the submit row is not rewritten. A string match therefore climbs and does not come down: every job that ever went pending stays in the count, settled or not, under a name that promises the opposite.
The test the product applies has three parts, and all three carry weight:
- There is no settlement row for this
req_id. - The row is unpriced.
- Its unpriced reason is
settlement_pending.
An abandoned job fails the first part, because abandonment writes a settlement row like any other
terminal state. Read pending_settlement_rows from the analytics envelope, pending_settlement
from budget status, or the pending settlement: line on the command line, and you get that test
applied for you.
4. Keep the settlement reasons out of the pricing queue
The unpriced-reason vocabulary is closed, and it opened twice for settlement: settlement_pending
and settlement_terminal. Both name a settlement state, and no pricing action clears either one, so
neither belongs in the list of models you owe a rate.
unpriced_reason |
What the row is waiting for | What clears it |
|---|---|---|
settlement_pending |
the provider has not costed the job yet | the settlement row, when it lands |
settlement_terminal |
the job reached a terminal state with nothing costable | nothing; that cost is not knowable |
| every other reason | a rate the price table does not carry | a change to the price table |
The product’s own work queue already splits them. GET /pricing/unpriced reports settlement rows
under settlement_excluded instead of putting them in the queue, because no price-table edit could
move them out of it. Build your own queue the same way, or read that surface rather than the raw
rows.
One case is worth knowing before you assume the queue is short. soniox is covered and served: it
reports a measured audio duration, and its settlement row carries the seconds, provider-reported,
inferring nothing. Both of its catalog rows carry no rate at all, so both settle unit_missing. A
single per-second rate would make it priceable with no driver change. That is a gap in the price
table rather than in the driver, and the two want different work from different people.
5. Adjust before you compare with an invoice
Two things sit between a settled dollar and the line on a provider’s bill. Both are disclosed on the row, and each wants something from you: one an adjustment, the other a check on which bytes produced the figure.
A translated row prices from different bytes depending on whether it streamed. A translated streaming row is priced from the upstream’s own frames, in the upstream’s own dialect. A translated non-streaming row is priced from the re-rendered client-dialect body, after the response adapter has re-encoded the upstream’s token counts. Two provenances, one feature, and a reader holding two translated rows wants to know which of them is which before treating their usage as like-for-like.
The instruction that falls out is short: reconcile against the ledger row, not against the response your client saw. For one pair in particular, an Anthropic-format client streaming from an OpenAI upstream, the client’s own usage numbers are approximate while the row’s are not, and the row carries a receipt saying so.
What can go wrong
Your parser skipped the settlement lines. A settlement line is
{"v":1,"kind":"settlement",…}, and its shape differs from a decision row’s. A reader that drops
lines it doesn’t recognise loses every async dollar and keeps every pending zero, which reads as a
provider that costs nothing. Handle both line kinds, or read the log through a surface that already
does.
A budget found out late. Admission gates the submit like any other request: scope, rate and concurrency limits all apply the moment the job is created. A spend gate sees the job’s cost only once its settlement reaches the ledger, so enforcement is bounded by settlement latency: plan for about an hour rather than for forty-five minutes. Providers that take a job and answer later carries the schedule, the 45-to-67-minute band around it and the two knobs that move it.
No reservation against a predicted job cost is attempted. Predicted money is fabricated money, and a
budget turning down a request against an estimate of what an image generation might cost would be
enforcing a number nobody measured. So the exposure between submit and settlement is a bound to plan
around: alert on pending_settlement_rows beside burn rather than on burn alone, and if the lag is
unacceptable for a tenant, the control that works is admission (rate and concurrency scoping on the
submit), not a spend gate on a dollar that doesn’t exist yet.
A row has been pending far longer than that. Past the abandonment horizon a pending row means
the poller is not running, not that the job is slow. There is no tokentriage jobs command; the job
plane is observable through the jobs block on GET /tt/api/status, which appears when the plane
is armed, and through a line at boot and a line at shutdown.