Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

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.

How-to guideLast updated

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.

Provider-bill reconciliationAn imported provider invoice is matched against the decision ledger. The delta is decomposed into five named candidates — unproxied_traffic, window_mismatch, rounding, estimated_pricing and unpriced_excluded — and whatever is left over is shown as unexplained, never zeroed to make the two sides agree.Provider invoiceimportedDecision ledgeryour costed rowsReconcileinvoice ⋈ ledgermatch & decomposeΔ = invoice − ledgerunproxied_trafficwindow_mismatchroundingestimated_pricingunpriced_excludedunexplainednever zeroed away

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.

localhost:9090/ui/cost/reconcileDemo data
The reconcile view: an imported invoice matched to the ledger, with named delta causes and an unexplained residual.
Reconcile: named causes for the delta, and an unexplained residual that is not zeroed away (real dashboard, demo mode).

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.

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