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

Source: https://tokentriage.com/docs/build/cost-reconciliation

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';
import ReconciliationDiagram from '../../../components/ReconciliationDiagram.astro';
import Screenshot from '../../../components/Screenshot.astro';

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](/docs/build/company-billing).

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

<ReconciliationDiagram />

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 |

```console
$ curl -s -X POST http://127.0.0.1:9090/tt/api/v1/reconcile/imports \
    -F 'format=anthropic_usage_csv' \
    -F 'file=@invoice.csv'
```

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.

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

<Screenshot
  stem="reconcile-light"
  url="localhost:9090/ui/cost/reconcile"
  alt="The reconcile view: an imported invoice matched to the ledger, with named delta causes and an unexplained residual."
  caption="Reconcile: named causes for the delta, and an unexplained residual that is not zeroed away (real dashboard, demo mode)."
/>

<Callout type="key">
**Read the delta with its coverage.** The −$65.60 above is not a discrepancy; it is a five-line
invoice being compared to a full month of traffic, and every ledger slice the invoice did not
bill contributes its whole priced total. The report states the invoice line count, the ledger
row count and the window in the same block as the headline for exactly this reason. Check the
per-slice rows before you read a big number as a finding.
</Callout>

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

```yaml
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:

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

<Callout type="warning">
**Every alias makes a delta disappear, including a wrong one.** That is why each applied mapping
is disclosed with the lines and dollars it moved, and why the sentence ends *an operator
assertion, not a measurement*. An alias is the one place in this report where money moves because
you said so.
</Callout>

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:

```console
$ 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.

1. **Check coverage first.** Is the invoice for the whole window your ledger covers? A partial
   invoice produces a large negative residual by construction.
2. **Check for `suspect` aliases.** They are listed on the report.
3. **Look at `unproxied_traffic` per slice.** Real unproxied traffic clusters on particular
   models or days; a naming mismatch appears as a whole model's worth.
4. **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.
5. **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.

<Details summary="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.

</Details>

## 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](/docs/build/governance-operations).

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

<NextSteps items={[
  {title: "How to read a cost figure", href: "/docs/build/cost-figures", description: "Why an unpriced row cannot absorb any of a delta, and what the estimated flag means here."},
  {title: "Chargeback, showback and FOCUS", href: "/docs/build/cost-exports", description: "Send the reconciled view onward in the column standard finance already reads."},
  {title: "The honesty model", href: "/docs/concepts/honesty-model#provenance--unpriced", description: "The rule the residual follows: unknown and zero are opposite facts."},
  {title: "After you apply: the realized measurement", href: "/docs/build/cost-realized", description: "The other reality check: what a change actually did, measured on rows that ran."},
]} />
