Reconciling Historical Cost Data Across a Provider Migration
10 min read · updated August 11, 2026
Two CSV exports, two schemas, two currencies, two billing periods, and a question from finance about whether the migration saved money. The work is not the join; it is deciding what the rows are supposed to mean before you write it.
Pick the grain first
Providers export at whatever grain suits their billing system: a daily-per-model rollup, an hourly usage series, a line item per invoice section, sometimes a per-request log behind a separate API. You cannot reconcile at a grain finer than the coarsest export you have, so decide up front and resist the temptation to go finer for one side.
Day-by-model-by-tenant is usually the right choice. It is coarse enough that every provider can produce it, fine enough to survive a cycle boundary landing mid-month, and it keeps the tenant dimension that makes the resulting numbers useful for anything other than a single total. If you have per-request records of your own — and you should, because they are the only source that is not vendor-shaped — keep them, but roll them to the same grain for the reconciliation rather than trying to match them to invoice lines one by one.
The common schema
One table, both providers, one row per grain cell. The important design decision is that quantities and prices are separate columns and money is derived, never stored as the only representation.
day DATE -- UTC calendar day provider TEXT endpoint TEXT -- responses | messages | generateContent ... model TEXT -- exact model string, not a family tenant TEXT tier TEXT -- standard | batch | priority | cached tokens_in BIGINT -- billable input, cache-exclusive tokens_cache_w BIGINT tokens_cache_r BIGINT tokens_out BIGINT tokens_reason BIGINT -- subset of tokens_out where reported requests BIGINT units_of_work BIGINT -- conversations, documents, tickets price_in NUMERIC -- per token, from a dated price table price_cache_w NUMERIC price_cache_r NUMERIC price_out NUMERIC currency CHAR(3) -- the currency of the price table fx_to_base NUMERIC -- rate used, with its own date source TEXT -- api_usage | invoice | own_logs
Three columns do the heavy lifting. tokens_in is defined as cache-exclusive so that the same column means the same thing on a provider whose usage object nests cached tokens inside the headline count and one whose usage object lists them as siblings — the direction of that correction is the subject of rebuilding the per-conversation metric, and getting it wrong here silently doubles or halves your input line. units_of_work is what makes a before-and-after comparison mean anything, because a month with more traffic costing more is not a finding. And source lets one cell exist twice, once from the API and once from the invoice, which is what turns a reconciliation into something you can check.
The normalisation, in order
- Freeze the window. Choose a comparison period that excludes the switch month entirely — a full month before cutover and a full month after, once traffic has stabilised. The switch month contains partial bills from both sides and belongs in a separate line called migration cost, not in either comparison.
- Pull both sides at the chosen grain from the providers’ usage APIs or exports, and pull your own request logs for the same window. Three sources, not two.
- Map fields into the schema explicitly, one mapping file per provider, with a comment on every column that required a transformation. Do not map by column-name similarity: an
input_tokenson one side and aprompt_tokenson the other may or may not include cached tokens, and the mapping file is where that decision gets recorded. - Normalise the calendar. Convert every timestamp to UTC before taking the day, and record which timezone the source used. A provider that closes its day at a local midnight will move roughly one twenty-fourth of your traffic across a day boundary relative to one that uses UTC, which is invisible in a monthly total and obvious in a daily chart.
- Normalise currency with a stated policy. Pick one base currency and one rate convention — a single month-end rate, or the daily rate, or the rate your accounting system used — write it in the
fx_to_basecolumn with its date, and never re-derive historical rows with today’s rate. A comparison whose numbers change when you re-run it cannot be reviewed. - Attach a dated price table rather than a stored money amount. Prices change mid-period; a price table with validity dates lets you cost the same tokens under both providers, which is the counterfactual finance will ask for within a day of seeing the first version.
- Reconcile API sums against invoices per provider, per month, and record the discrepancy as a percentage before you compare anything across providers. An unexplained gap on one side invalidates the comparison regardless of how clean the other side is.
- Derive the metric per unit of work and only then compare. Cost per conversation, per ticket, per document — whatever your
units_of_workcounts — with a median and a 95th percentile rather than a mean.
Why the sum will not equal the invoice
Expect a gap, decide what size of gap is acceptable, and investigate only when it is exceeded. The usual causes, roughly in order of how often they explain it: requests that failed after being billed or succeeded without being logged by you; rounding, because providers price per million tokens and round somewhere you cannot see; credits, free tiers and committed-spend drawdowns applied at invoice level and absent from usage records; a cycle boundary that does not align with your calendar month; and taxes appearing on the invoice and not in usage.
A one per cent band is a reasonable starting tolerance for a token-derived sum against an invoice, tightened once you know your own data. What matters more than the number is that it is written down and monitored, because a reconciliation that is only performed when somebody is suspicious tells you nothing about when the drift started. The same discipline applies to your own request logs against the provider’s usage API: that gap is your logging bug, and it is worth knowing separately from the vendor gap. See cost attribution for the general problem of getting spend back to the thing that caused it.
The comparison that survives
Present three numbers per provider rather than one: cost per unit of work, tokens per unit of work, and effective price per token. They decompose the change. If cost per conversation fell while tokens per conversation rose, the new provider is cheaper per token and more verbose, and the saving will erode if output lengths keep growing. If tokens fell and the effective price rose, you gained on prompt design and lost on rate, which is a negotiation input rather than a migration result.
Keep the mapping files, the price tables and the FX policy in version control next to the query. The reconciliation will be re-run — at the next price change, at renewal, and the first time somebody disputes the result — and the value of this work is almost entirely in being able to re-run it and get the same answer.