Cost accounting
A cost figure is only worth having if you can say where it came from. This is every step between a token count and a number on the screen.
Priced once, at ingest
When a span arrives carrying a model id and a token count, mcpd looks that model up in a price book, computes what the call cost and stores the result on the span. Nothing is recomputed on read.
That is the important decision on this page. Prices change. If cost were derived at query time from whatever book happened to be loaded that day, a rate cut next month would quietly rewrite what last month cost, and the number you reported to somebody would not be there when they went to check it. Costing at ingest freezes the figure against the rates that were true when the call was made. Every stored cost also records the id of the book that produced it, so a figure can be traced back to the rates behind it.
A span that already carries a cost is left exactly as it arrived. If your
provider hands you a real figure and you record it, mcpd keeps yours and does
not price over it. The is_estimated flag separates the two:
anything mcpd derived from a book is marked estimated, because it is.
Rates are per million tokens
Every rate in a price book is currency units per million tokens, which is how
every provider publishes them. A rate copied off a pricing page goes into a
book unchanged, with no division to get wrong at three in the morning. The
arithmetic for one meter is
tokens × rate ÷ 1,000,000.
Cost lives on the step, not on the run
Each model call carries its own figure. A run's total is the sum of its spans, and mcpd recomputes that sum from the spans every time one of them arrives rather than incrementing a counter. Spans arrive out of order, get retried and get updated in place, and an incrementing total drifts under all three. A headline number that does not match the rows underneath it destroys trust in every other number on the page.
- agent support_agent fail
- model chat
- tool returns_policy
- model chat
- tool returns_policy
- model chat
- tool returns_policy
The model called one tool three times and never answered. That is visible in four seconds here, and invisible in a log file.
The three model calls above are priced at $0.00015, $0.00018 and $0.00021. The run total in the header is their sum and nothing else: the tool calls, the memory lookup and the guardrail consumed no tokens, so they carry no cost and contribute none.
The meters
A model call is not one number of tokens. Providers bill several meters at different rates, and a price book gives a rate for each of them.
| Rate | Applies to | When the book omits it |
|---|---|---|
input | Ordinary input tokens. | Required. |
output | Ordinary completion tokens. | Required. |
cache_read | Tokens served from a prompt cache. | Charged at input. |
cache_write | Tokens written into a prompt cache. | Charged at input. |
cache_write_5m | Writes to a five-minute cache, where the provider prices TTLs apart. | Falls back to cache_write, then input. |
cache_write_1h | Writes to a one-hour cache. | Falls back to cache_write, then input. |
audio_input | Audio input tokens. | Charged at input. |
audio_output | Audio output tokens. | Charged at output. |
reasoning | Hidden reasoning tokens, where a provider bills them apart. |
No fallback. They are charged at output as part of the
output total, which is how the major providers bill them, unless the
provider also reported a separate text count. See below.
|
A cached token is charged once
The classic way to overstate a bill is to charge a cached token twice: once at the cache rate, and again as part of the reported input total it is already inside. So when a provider reports a breakdown, that breakdown wins. mcpd prices each meter at its own rate and charges only the remainder at the plain input rate.
A claude-sonnet-4 call reporting one million input tokens, of
which nine hundred thousand were read from cache, prices against the built-in
book like this:
- 900,000 cache reads at $0.30 per million, which is $0.27.
- 100,000 remaining input tokens at $3.00 per million, which is $0.30.
- $0.57 of input cost, rather than the $3.00 the reported total on its own would have produced.
Where a provider gives an explicit text count, that count is
used as the remainder directly. Where it does not, the remainder is the
reported total minus every metered field.
That leaves one gap worth naming. A meter the book holds no rate for is
charged only as part of the remainder, so when the provider also sent a
text count it is not charged at all. Reasoning tokens sit there
whenever your book gives them no rate of their own, and declaring one fixes
it. Image tokens sit there always, because a book has no image rate to give,
so on a heavily multimodal workload read the figure as low rather than exact.
An unknown model has no cost, not a cost of zero
If the book holds no rates for the model, mcpd stores the span with no cost field at all. It does not store a zero.
Zero is a claim: this call was free. Absent is an admission: we do not know what this call cost. They lead to different decisions, and a tool that reports the second as the first is lying about a bill. The same rule runs through the read path, where a run whose spans total nothing is returned with no cost figure rather than a cost of zero, so a blank cost column means not priced and never free.
The practical consequence is worth internalising: a model missing from the
book is missing from every total. Its spend does not appear in
/v1/api/analytics/cost, and its runs are excluded by any
positive min_cost filter. A weekly total that looks too low is
usually a model id that is not resolving, not a quiet week.
Resolving a model id
The id on a span is whatever your client sent, and clients decorate it. A gateway prefixes its vendor, a cloud prefixes its region, a provider pins a release date. A book is keyed on bare model names, so lookup tries four things in order and stops at the first hit.
| Step | Example |
|---|---|
| The id as given, case folded | GPT-4o finds gpt-4o |
| The part after the last slash | openai/gpt-4o finds gpt-4o |
| The dotted tail of a cloud id | us.anthropic.claude-sonnet-4 finds
claude-sonnet-4 |
| The id with a trailing date removed, with or without its prefix | openai/gpt-4o-2024-08-06 finds gpt-4o |
Both -YYYY-MM-DD and -YYYYMMDD suffixes are
recognised. Failing all four steps is a normal outcome rather than an error:
it means no cost, as above.
The book that ships
mcpd carries a small built-in book so that docker run produces
real figures with no configuration at all. It is called
builtin, it prices in USD, and every cost it produces records
that name.
| Model | Input | Output | Cache read | Cache write, 5 min | Cache write, 1 hour |
|---|---|---|---|---|---|
claude-opus-4 | 15.00 | 75.00 | 1.50 | 18.75 | 30.00 |
claude-opus-4-1 | 15.00 | 75.00 | 1.50 | 18.75 | 30.00 |
claude-opus-4-5 | 5.00 | 25.00 | 0.50 | 6.25 | 10.00 |
claude-sonnet-4 | 3.00 | 15.00 | 0.30 | 3.75 | 6.00 |
claude-sonnet-4-5 | 3.00 | 15.00 | 0.30 | 3.75 | 6.00 |
claude-haiku-4-5 | 1.00 | 5.00 | 0.10 | 1.25 | 2.00 |
claude-3-5-haiku | 0.80 | 4.00 | 0.08 | 1.00 | 1.60 |
gpt-4o | 2.50 | 10.00 | 1.25 | at input | at input |
gpt-4o-mini | 0.15 | 0.60 | 0.075 | at input | at input |
gpt-4.1 | 2.00 | 8.00 | 0.50 | at input | at input |
gpt-4.1-mini | 0.40 | 1.60 | 0.10 | at input | at input |
o3 | 2.00 | 8.00 | 0.50 | at input | at input |
o4-mini | 1.10 | 4.40 | 0.275 | at input | at input |
gemini-2.5-pro | 1.25 | 10.00 | 0.31 | at input | at input |
gemini-2.5-flash | 0.30 | 2.50 | 0.075 | at input | at input |
text-embedding-3-small | 0.02 | 0.00 | at input | at input | at input |
text-embedding-3-large | 0.13 | 0.00 | at input | at input | at input |
USD per million tokens, as the book stood when it was last checked against
a third-party model catalogue, which the book records as its
as_of date. Anthropic's entries carry both cache write rates
because the daemon falls back from the one-hour rate to the five-minute one,
and a book that omitted the longer one would under-bill in silence. OpenAI
charges nothing to write to its cache, so a cache write there is billed at
the input rate. gemini-2.5-flash also carries an audio input
rate of 1.00; every other entry prices audio at its text rate. There is no
claude-haiku-4: the id Anthropic serves is
claude-haiku-4-5, and an id that resolves to nothing is left
unpriced rather than guessed.
These are published list prices, not your prices. If you have negotiated rates, or you run a model that is not in this table, the figures mcpd shows you are an estimate and should be read as one. The book is deliberately short: a long one would go stale in more places and give a stronger impression of accuracy than it deserves.
You cannot supply your own book. There is no flag, no config file key and no endpoint that loads one: every deployment prices with the built-in table above, and a model that table does not name stays unpriced. Negotiated rates, a model you host yourself and a currency other than US dollars are all out of reach today. That is a gap rather than a decision, and until it is closed the honest reading of every figure on this page is that it is an estimate at list prices.
What does exist is the shape a book has, which is worth knowing while you weigh whether these figures are close enough for what you need. A book is an id, an ISO 4217 currency and a map of model id to rates. Every figure is stamped with the id of the book that produced it, so two books can be told apart after the fact, and the rates in one project would have to share one currency: mcpd sums a run's spans arithmetically and converts between none of them.
Reading the money back
GET /v1/api/analytics/cost answers where the money went over a
window, without exporting anything to a spreadsheet.
curl -s 'http://localhost:4317/v1/api/analytics/cost?from=-7d&group_by=model' | Parameter | Meaning |
|---|---|
from, to | The window. RFC 3339 or the relative shorthand. Defaults to the last seven days, ending now. |
group_by | model by default. Also provider,
entity_name, environment,
status, session_id, user_id,
service_name or name.
|
bucket | 30s, 5m, 1h or 1d.
Omit it for totals only; supply it to get a time series alongside them.
|
project | Which project to read. Defaults to the daemon's default project. |
where it went
# Where the money went last week, by model.
curl -s --get http://localhost:4317/v1/api/analytics/cost \
--data-urlencode 'from=-7d' \
--data-urlencode 'group_by=model'
# The same week as a daily series, so you can see the day it changed.
curl -s --get http://localhost:4317/v1/api/analytics/cost \
--data-urlencode 'from=-7d' \
--data-urlencode 'group_by=model' \
--data-urlencode 'bucket=1d' import { Mcpd } from '@mcpd/sdk'
const mcpd = new Mcpd()
const spend = await mcpd.analyticsCost({
from: '-7d',
to: '-0s',
groupBy: 'model',
bucket: '1d',
})
console.log(`${spend.currency} ${spend.totalCost.toFixed(4)} over the window`)
for (const group of spend.groups) {
console.log(group.key, group.totalCost, group.inputTokens, group.outputTokens)
} from mcpd import Mcpd
mcpd = Mcpd()
spend = mcpd.analytics_cost("-7d", "-0s", group_by="model", bucket="1d")
print(f"{spend.currency} {spend.total_cost:.4f} over the window")
for group in spend.groups:
print(group.key, group.total_cost, group.input_tokens, group.output_tokens) Groups come back largest first, capped at a hundred rows, and a bucketed series is capped at five thousand points.
response
{
"from": "2026-08-20T09:00:00Z",
"to": "2026-08-27T09:00:00Z",
"currency": "USD",
"total_cost": 12.418264,
"groups": [
{
"key": "claude-sonnet-4",
"count": 1841,
"error_count": 12,
"total_cost": 9.612055,
"input_tokens": 2903118,
"output_tokens": 214477,
"latency": { "mean": 1840.2, "max": 24193.0 }
},
{
"key": "gpt-4o-mini",
"count": 6204,
"error_count": 3,
"total_cost": 2.806209,
"input_tokens": 9114882,
"output_tokens": 388310,
"latency": { "mean": 612.4, "max": 8801.0 }
}
]
}
One subtlety in count. Grouping by model,
provider or entity_name reads the spans, so
count is a number of model calls. Grouping by anything else
reads the run projection, so count is a number of runs. Both are
useful; they are not the same number, and comparing one against the other is
the easiest mistake to make here.
The per-group latency in this response carries a mean and a maximum only.
Percentiles come from
GET /v1/api/analytics/summary, which computes them over the
window.
Where to go next
Cost is one column in a run list, and the run list is where an investigation
actually starts:
Finding a run covers
min_cost and sorting by spend. If your figures look wrong
because payloads are being dropped before they are priced, check
Sampling, which decides what is
recorded at all. Every field named on this page is listed in the
data model.