DEV Community

Multigrid
Multigrid

Posted on • Originally published at multigrid.ai

Migrating Cost Attribution From Per-Provider to Unified Tracking

The reason a migration fragments your cost history is not that the numbers are in different places. It is that two fields with the same name count different things, and a rename-based adapter cannot see it.

Three usage objects, three different meanings

Here are the field names, from each provider’s own reference at the time of writing.

OpenAI’s Chat Completions response carries usage.prompt_tokens, usage.completion_tokens and usage.total_tokens, with breakdowns in usage.prompt_tokens_details (containing cached_tokens) and usage.completion_tokens_details (containing reasoning_tokens, accepted_prediction_tokens and rejected_prediction_tokens).

Anthropic’s Messages response carries usage.input_tokens, usage.output_tokens, usage.cache_creation_input_tokens and usage.cache_read_input_tokens, with usage.cache_creation splitting writes by TTL into ephemeral_5m_input_tokens and ephemeral_1h_input_tokens, and usage.output_tokens_details.thinking_tokens breaking out thinking.

Google’s Gemini response carries usageMetadata with promptTokenCount, candidatesTokenCount, cachedContentTokenCount, thoughtsTokenCount, toolUsePromptTokenCount and totalTokenCount.

Three names for input, three for output, three cache stories. A rename map handles that in an afternoon, and produces a system that is wrong.

The inclusive-versus-additive trap

The trap is what the headline input number includes.

On OpenAI, cached_tokens is a subset of prompt_tokens: the cached portion is counted inside the headline number and then broken out for pricing. OpenAI’s prompt caching guide documents the relationship.

On Anthropic it is the other way round. Anthropic’s prompt caching documentation states that input_tokens represents only the tokens after the last cache breakpoint, not all the input tokens you sent, and gives the reconstruction explicitly: total input is cache_read_input_tokens plus cache_creation_input_tokens plus input_tokens. The three buckets do not overlap.

So an adapter that maps prompt_tokens and input_tokens onto one input column is not normalizing. It is silently dropping every cached and cache-written token on one side of the migration. On a workload with a large stable system prompt — which is most workloads that bothered with caching — that is the majority of the input volume, and the reporting gap is enormous. Gemini sits with OpenAI here: cachedContentTokenCount is a breakdown of promptTokenCount, not an addition to it.

The same trap exists on the output side. Reasoning and thinking tokens are billed as output and are counted inside the headline output number on all three, but they are the component most likely to change size across a migration, so a schema that does not keep them separate cannot explain why the bill moved.

The normalized record

Model the record on non-overlapping buckets, because that is the only shape both conventions can be converted into without loss. Every count is a distinct quantity of tokens, and the sum of the input buckets is the true input volume.

type NormalizedUsage = {
  provider: string;          // "openai" | "anthropic" | "google"
  model: string;             // the model string as sent, not a friendly name
  requestId: string | null;  // provider request id, for reconciliation
  input_fresh: number;       // billed at the standard input rate
  input_cache_read: number;  // billed at the cache-read rate
  input_cache_write: number; // billed at the cache-write rate
  output_visible: number;    // output excluding reasoning/thinking
  output_reasoning: number;  // reasoning or thinking tokens
  raw: unknown;              // the untouched provider usage object
};
Enter fullscreen mode Exit fullscreen mode

Two fields there earn their place beyond the arithmetic. raw means a field you did not know about when you wrote the adapter is still recoverable a year later, which matters because providers add usage fields far more often than they remove them. And model holds the string you actually sent, not a normalized label: aliases resolve differently over time, and the string is what the invoice is keyed on.

Writing the adapters

  1. Anthropic — additive. Read the three input buckets straight across; they are already disjoint.
  2. OpenAI — subtract. Fresh input is prompt_tokens minus cached_tokens. Cache writes are reported separately where the model family bills for them, and are zero otherwise.
  3. Google — subtract. Fresh input is promptTokenCount minus cachedContentTokenCount. Decide deliberately whether toolUsePromptTokenCount belongs in your input total; it is a real cost and it is easy to omit.
  4. Everywhere — clamp and alarm. A subtraction that goes negative means the provider changed the semantics. Clamp to zero so reporting survives, and raise, because you have just learned something.
function fromOpenAI(u, model, requestId) {
  const cached = u.prompt_tokens_details?.cached_tokens ?? 0;
  const reasoning = u.completion_tokens_details?.reasoning_tokens ?? 0;
  return {
    provider: "openai", model, requestId,
    input_fresh: Math.max(0, u.prompt_tokens - cached),
    input_cache_read: cached,
    input_cache_write: u.prompt_tokens_details?.cache_write_tokens ?? 0,
    output_visible: Math.max(0, u.completion_tokens - reasoning),
    output_reasoning: reasoning,
    raw: u,
  };
}

function fromAnthropic(u, model, requestId) {
  const thinking = u.output_tokens_details?.thinking_tokens ?? 0;
  return {
    provider: "anthropic", model, requestId,
    input_fresh: u.input_tokens,
    input_cache_read: u.cache_read_input_tokens ?? 0,
    input_cache_write: u.cache_creation_input_tokens ?? 0,
    output_visible: Math.max(0, u.output_tokens - thinking),
    output_reasoning: thinking,
    raw: u,
  };
}
Enter fullscreen mode Exit fullscreen mode

The test that keeps it honest

One property test is worth more than a suite of examples here: for every adapter, the sum of the normalized input buckets must equal the provider’s own notion of total input. On Anthropic that is the sum of the three raw fields; on OpenAI and Google it is the headline prompt count. Feed each adapter a recorded response from a real call with caching on and caching off, and assert the identity in both states. A rename-based adapter fails this immediately, which is the point.

Then hold the schema to the invoice. Sum a full billing period by provider, price each bucket at its own rate, and compare against the statement. Agreement to within rounding means the adapters are right; a gap the size of your cached volume means one of them is inclusive where you assumed additive. Migrating the price lookup table handles the other half of that reconciliation, and the general treatment of cost attribution covers the tagging that gets a request to a team in the first place.

What the record deliberately does not hold

The schema above holds token counts, not money, and that separation is worth defending against the first person who asks why there is no cost column. A cost is a token count multiplied by a rate that was in force on a particular day, and rates change, discounts are negotiated retroactively, and a figure written into a row a year ago cannot be corrected without rewriting history. Store the counts, which are facts about a request that never change, and compute cost at read time from a dated rate table.

Several things also genuinely do not fit in a per-request record, and pretending otherwise produces numbers that look precise and are not. Batch discounts apply to a job rather than to a call. Service tiers — Anthropic reports usage.service_tier as standard, priority or batch, and Gemini reports a serviceTier too — change the rate for a request that is otherwise identical, so carry the tier as a column rather than assuming one price per model. Provisioned or reserved capacity has no per-request price at all: the money was spent when the capacity was bought, and attributing it means dividing a fixed cost by observed usage, which is an allocation policy and belongs somewhere a human can argue with it.

Keep those out of the ingest path and the schema stays a faithful record of what happened. Push them in and the first question anyone asks about a surprising number is unanswerable, because the raw counts have already been mixed with a set of assumptions nobody wrote down.

The reason this is worth doing once rather than per service is that every adapter is a place the arithmetic can be wrong, and adapters written by different teams tend to disagree in exactly the inclusive-versus-additive way above. A gateway is one implementation of this normalization sitting in front of all of them — Multigrid emits a single usage shape across providers for the same reason this page builds one. The schema above is the thing that matters; where it lives is a separate decision.

Usage field names and their nesting are the fastest-moving part of any provider API. Everything named on this page is from the vendors’ own references at the time of writing; treat the shapes as verify-before-use, and let the raw column be what protects you when one changes.

Related

Top comments (0)