# 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.

Source: https://tokentriage.com/docs/build/providers-settlement

---
import Callout from '../../../components/Callout.astro';
import NextSteps from '../../../components/NextSteps.astro';

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](/docs/build/providers-async-jobs) 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](/docs/build/providers-async-jobs#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:

```text
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](/docs/build/cost-cli).

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

```json
{"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:

```json
{"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.

<Callout type="key" title="One count is inside the other">

`unpriced_rows` counts every row in the period carrying no dollar, for any cause.
`pending_settlement_rows` counts the ones whose money is in flight: no settlement row yet, unpriced,
and the reason `settlement_pending`. Same window, same population, one nested inside the other, so
**adding them counts the same rows twice.**

The subtraction is the useful one. `unpriced_rows` minus `pending_settlement_rows` is the number of
rows a price-table change could clear.

</Callout>

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:

1. There is no settlement row for this `req_id`.
2. The row is unpriced.
3. 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.

<Callout type="warning" title="Halve a priced OpenAI batch settlement">

Settlement prices a batch job at the **standard** rate. OpenAI publishes a Batch tier at 50% of
standard, so a priced batch settlement is **twice** the cost you were charged. The factor rides the
row: the settlement's `error` field carries the disclosure and the caveat, so the magnitude travels
with the money instead of living in a document.

The batch-tier rate is a reserved slot in the price artifact and it is left empty rather than filled
in by halving. "50% of standard" is not universally derivable: `gpt-3.5-turbo-instruct` has a
standard row and no row at all in OpenAI's batch table, so halving every standard rate would invent
a rate OpenAI does not publish. Closing that slot is a price-catalog refresh carrying batch-tier
rates as their own column.

</Callout>

**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](/docs/build/providers-async-jobs) 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.

<NextSteps items={[
  {title: "Providers that take a job and answer later", href: "/docs/build/providers-async-jobs", description: "Why there are two rows to fold at all, what writes the second one, and how an abandoned job gets recorded."},
  {title: "Decision ledger fields", href: "/docs/reference/ledger-fields", description: "The fields to test when you write the aggregate yourself, instead of matching on a reason string."},
  {title: "How to read a cost figure", href: "/docs/build/cost-figures", description: "What a total has already left out before any async row joins it, and what the estimated flag means."},
  {title: "Endpoints and cost shapes", href: "/docs/build/providers-endpoints", description: "Which surfaces settle later rather than at once, and which bill nothing per call and say so."},
]} />
