BlogCost & ops8 min read

Which feature tripled the bill

A provider invoice tells you the total and nothing else. Attribution is a tagging decision you have to make before you need the answer.

The invoice says $18,400. Last month it said $6,100. Somebody asks which feature did that, and there is no way to answer, because the provider does not know what your features are. It knows about API keys and tokens.

The console will break the total down by model, and possibly by key. Neither maps to the thing you need to make a decision about, which is a product surface with a person responsible for it.

Cost attribution is not a reporting problem you can solve after the fact. It is a decision about what you record at call time, and you have to have made it before you need the answer.

Record your own usage, at the call site

Every provider returns token counts in the response. Write them down, with the context the provider does not have:

create table model_usage (
  id              bigserial primary key,
  at              timestamptz not null default now(),

  -- Who is asking. The attribution dimensions.
  feature         text not null,      -- 'invoice_extract', 'support_chat'
  tenant_id       text,               -- for per-customer margin
  environment     text not null,      -- prod | staging | dev | eval
  run_id          text,               -- ties to the agent run or job
  prompt_version  text not null,      -- which prompt produced this

  -- What it cost. Store counts AND money, see below.
  provider        text not null,
  model           text not null,
  input_tokens        int not null,
  output_tokens       int not null,
  cache_write_tokens  int not null default 0,
  cache_read_tokens   int not null default 0,
  cost_usd        numeric(12, 6) not null,

  -- Why it happened. The line item nobody attributes.
  attempt         int  not null default 1,
  outcome         text not null       -- ok | retry | failed | rejected_by_schema
);

create index on model_usage (at, feature);
create index on model_usage (at, tenant_id);

Four columns there earn their place beyond the obvious.

prompt_version. When cost per document moves, the first question is whether the prompt changed. Without this you are correlating against deploy timestamps in a different system.

environment. Eval runs are model spend. They are also not a cost of serving customers, and blending the two hides both — a big eval sweep looks like a production cost spike, and real production growth gets excused as "probably the evals".

attempt and outcome. A call that failed schema validation and was retried cost real money and produced no value. Attributed to the feature, retries are often a double-digit share of its bill, and they are invisible in every provider-side view.

cost_usd stored, not computed. Prices change. If you compute cost from current rates at query time, your historical chart silently rewrites itself the next time a provider adjusts pricing — and at least one is already scheduled to. Price the row when you write it.

Price the row correctly

The one that catches people is caching, because cache writes and cache reads are priced differently from base input and cannot be folded into one number:

// Rates as published, per million tokens. Keep this in one place,
// versioned, with the date you last checked it.
const RATES = {
  "claude-sonnet-5":  { input: 2,  output: 10,   cacheWrite5m: 2.50, cacheRead: 0.20 },
  "claude-haiku-4-5": { input: 1,  output: 5,    cacheWrite5m: 1.25, cacheRead: 0.10 },
  "claude-opus-5":    { input: 5,  output: 25,   cacheWrite5m: 6.25, cacheRead: 0.50 },
} as const

function costUsd(model: keyof typeof RATES, u: Usage): number {
  const r = RATES[model]
  return (
    (u.input_tokens       * r.input        +
     u.output_tokens      * r.output       +
     u.cache_write_tokens * r.cacheWrite5m +
     u.cache_read_tokens  * r.cacheRead) / 1_000_000
  )
}

Note that input_tokens in most provider responses excludes cached tokens, which are reported separately. Adding cached tokens into the base input count is the most common arithmetic error here, and it overstates cost by roughly the cache hit rate — which means the more effective your caching is, the more wrong your numbers get.

The queries you actually run

Cost by feature, week over week, is the report that answers the original question:

select
  feature,
  date_trunc('week', at) as week,
  sum(cost_usd)                                as cost,
  sum(cost_usd) filter (where outcome <> 'ok') as wasted,
  count(*)                                     as calls
from model_usage
where environment = 'prod' and at > now() - interval '8 weeks'
group by 1, 2
order by 2 desc, 3 desc;

But the number worth putting on a dashboard is not monthly total. It is cost per unit of business value — per document processed, per conversation resolved, per report generated:

select
  date_trunc('day', at) as day,
  sum(cost_usd) / count(distinct run_id) as cost_per_document
from model_usage
where feature = 'invoice_extract' and environment = 'prod'
group by 1 order by 1 desc;

Total spend rising is ambiguous — it might just be growth, which is good. Cost per document rising is unambiguous, and it is the one that quietly destroys the margin on a per-seat or per-document contract. Alert on the second, not the first.

Reconcile against the invoice

The ledger is only useful if it is complete, and completeness is the thing that decays. A new code path ships without instrumentation, and from then on your numbers are quietly wrong in a direction that looks like good news.

Check monthly:

select provider, sum(cost_usd) from model_usage
where at >= date_trunc('month', now() - interval '1 month')
  and at <  date_trunc('month', now())
group by 1;

Compare to the invoice. Expect a small gap — rounding, free-tier calls, tokens counted slightly differently. If the gap is more than about 5%, something is calling the API without going through the wrapper, and finding it is the highest value work available, because an untagged call path is by definition the one nobody is watching.

Which is the argument for the wrapper being the only way to reach the provider. One module makes the call, records the row, and prices it. A direct SDK import anywhere else is the bug — worth a lint rule, since it is the kind of thing that gets added at 6pm during an incident and never removed.


More in when the model bill triples, prompt caching with the arithmetic, and what an AI feature costs to build.

Something here

the audit is the cheapest way to find out for certain.