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.