Reconcile against a provider invoice
Match a provider's bill against your ledger, decompose the difference into named causes, and read the residual that is never zeroed.
On this page
Your ledger says one number and the provider’s invoice says another. Both are probably right about different things, and the useful work is finding out which things.
By the end of this page you will have imported an invoice, read a delta decomposed into named causes, and know what to do with the part that has no name.
The invoice here is a provider’s, billing you for tokens. For the invoice TokenTriage sends your company, and how its usage line splits by what each request was for, see What your company is billed for.
What reconciliation claims
A reconciliation is a claim about a document, not about your account. The import stores the file’s SHA-256, who imported it and when, so a report can always be tied back to exactly which bytes produced it. Re-download the invoice next month and get different numbers, and the two reports are distinguishable.
Matching is per model and per day. For each of those slices the signed difference — invoice minus ledger, is absorbed by named, quantified causes, and whatever is left is reported as unexplained.
Read it left to right: the invoice and the ledger both feed the matcher, and the delta is decomposed down the right-hand column, with the residual at the bottom.
1. Import an invoice
Two provider formats are registered. Both are CSV exports from the provider’s own console, and the parsers accept the header spellings each provider has shipped across versions.
| Format name | For |
|---|---|
anthropic_usage_csv |
an Anthropic console usage or cost export |
openai_usage_csv |
an OpenAI usage export |
$ curl -s -X POST http://127.0.0.1:9090/tt/api/v1/reconcile/imports \
-F 'format=anthropic_usage_csv' \
-F '[email protected]'
A malformed file fails the whole import rather than parsing partway. A missing required column, or a data cell that is not a number or not a date, is an error and not a skipped row. Uploads over 25 MiB are refused with the limit named.
2. Read the decomposition
Here is a live report. It comes from importing a partial five-line invoice against a 412-row sandbox ledger, which is the most instructive case there is.
{
"invoice_total": { "usd": 16.2180018 },
"ledger_total": { "usd": 66.371826395, "estimated": true },
"delta": { "usd": -50.153824595, "estimated": true },
"explained": { "usd": 15.448085805, "estimated": true },
"unexplained": { "usd": -65.6019104 },
"decomposition": [
{ "kind": "unproxied_traffic", "usd": { "usd": 3.7180018 }, "slices": 4 },
{ "kind": "window_mismatch", "usd": { "usd": 12.5 }, "slices": 1 },
{ "kind": "rounding", "usd": { "usd": -0.048146895 },"slices": 26 },
{ "kind": "estimated_pricing", "usd": { "usd": -0.7217691 }, "rows": 31, "slices": 31 },
{ "kind": "unpriced_excluded", "usd": { "usd": null, "unpriced": true }, "rows": 35, "slices": 35 }
]
}
The five causes, in the order they are worth reading:
| Cause | What it says |
|---|---|
unproxied_traffic |
invoice lines with no matching ledger activity: traffic the proxy did not see |
window_mismatch |
invoice lines whose day falls outside the ledger’s observed range |
rounding |
sub-cent differences between the provider’s summation and yours |
estimated_pricing |
the part of the delta bounded by ledger rows priced from a fallback rate |
unpriced_excluded |
rows the ledger could not price at all: disclosed, never valued |
unproxied_traffic is the finding that makes people sit up: it says somebody is calling the
provider around your gateway. Read it carefully before acting, because a model naming mismatch
produces the identical symptom. See step 3.
unpriced_excluded is the one that behaves differently from the rest. It carries no dollar,
because the ledger genuinely does not know what those rows cost. It explains why a positive
delta can exist without absorbing any of it, which is the honest position and also the reason
the arithmetic does not close.

3. Map the models the provider bills under
Providers do not always bill under the id your ledger recorded. claude-opus-4-8 on the invoice
against anthropic/claude-opus-4-8 in the ledger is one truth arriving as two slices: a
ledger-only slice with a negative delta, and an invoice-only slice classified as
unproxied_traffic.
Both halves are wrong, and the wrong half is the dangerous one, because “traffic the proxy never saw” is a finding somebody acts on. A naming mismatch gets reported as a governance hole.
Map them:
analytics:
reconcile:
model_aliases:
claude-opus-4-8: anthropic/claude-opus-4-8
claude-sonnet-4-5: anthropic/claude-sonnet-4-5
Direction is invoice → ledger and it is not symmetric. The ledger is the vocabulary every other cost surface already speaks, so normalising toward it means an aliased reconciliation slices the same way as your queries, your chargeback reports and your exports.
Re-importing the same invoice with those aliases in place moves unproxied_traffic from
$3.7180018 across 4 slices down to $3.25 across 1, and the report discloses every mapping it
applied:
{
"aliases": [
{
"from": "claude-opus-4-8",
"to": "anthropic/claude-opus-4-8",
"lines": 3,
"usd": 12.9495545,
"note": "invoice model \"claude-opus-4-8\" was matched to ledger model \"anthropic/claude-opus-4-8\" by a configured alias (3 invoice lines, $12.949554) — an operator assertion, not a measurement"
},
{
"from": "claude-experimental-9",
"to": "anthropic/claude-nonexistent",
"lines": 1,
"usd": 3.25,
"suspect": true,
"note": "invoice model \"claude-experimental-9\" was matched to ledger model \"anthropic/claude-nonexistent\" by a configured alias (1 invoice line, $3.25) — but the ledger recorded NO rows under \"anthropic/claude-nonexistent\", so this alias is probably a typo and this slice will read as a large unexplained delta"
}
]
}
The second entry above is that hazard caught. The alias points at a model the ledger has never
recorded, so it is flagged suspect. The rewrite still happens, because refusing it would
silently restore the phantom slice the alias was configured to remove, but the dollars now sit
on a model that does not exist, where they read as unexplained rather than reconciled.
Chained aliases are refused before the process starts, because the result would depend on map iteration order:
$ tokentriage validate --config tokentriage.yaml
Error: configuration is invalid: config tokentriage.yaml: analytics.reconcile.model_aliases: alias "claude-opus-4-8" => "anthropic/claude-opus-4-8" chains onto another alias whose source is "anthropic/claude-opus-4-8"; flatten it (a chain makes the result depend on resolution order)
Empty keys and self-mappings are refused too. A self-mapping is a no-op somebody wrote believing it did something, and saying so is cheaper than letting them debug it later.
4. What to do with the residual
The residual is called unexplained and it is not adjusted to make the two sides agree. That
is what the surface exists for, so the question is how to work it down.
- Check coverage first. Is the invoice for the whole window your ledger covers? A partial invoice produces a large negative residual by construction.
- Check for
suspectaliases. They are listed on the report. - Look at
unproxied_trafficper slice. Real unproxied traffic clusters on particular models or days; a naming mismatch appears as a whole model’s worth. - Read
estimated_pricing. It bounds the part of the delta attributable to rows priced from a fallback rate, which tells you how much of the gap could be a pricing-table problem rather than a usage one. - Accept what is left. A residual of a few cents on a month is rounding you cannot chase further. A residual of a few percent is a question worth answering.
Two properties of the residual that its own fields do not show
An estimated: false residual can still be arithmetic over estimated dollars. When
ledger_total carries estimated: true, the number the residual was derived from is estimated
even though the residual’s own flag reads false. Read the totals it came from, not only its own
flag.
A model-and-day the invoice does not mention yields invoice_usd: {usd: 0}, a genuine zero
meaning “billed nothing”, not an unpriced. That is the one place in this section where a zero is
a genuine zero, and it is why a slice the invoice omitted contributes its full ledger dollars to
the delta.
What can go wrong
A huge N scope(s) DRIFTED from govern reconcile. That is a different command answering a
different question: whether a governance rollup still agrees with the ledger. See
operating governance.
The import returned 503. Matching reads the whole ledger, so admission is bounded by the same budget every heavy off-path read uses. It is transient and retriable.
The residual got smaller after you added aliases and you stopped there. That is what aliases do, and it is the moment to check the disclosure list rather than the headline. A shrinking residual is only good news if every alias that shrank it is right.