# Model Pricing
yoagent reports what a run cost (`Agent::session_cost_usd`, `SessionStats`,
the `llm_stream` span's `cost_usd`) from the `CostConfig` on
`ModelConfig::cost`. This page covers where those rates come from.
Each model is priced at its own rates: a sub-agent's turns at the sub-agent's
model (`SessionStats::sub_agents`), `LlmCompaction`'s summaries at the
summarization model (`SessionStats::compaction`), decision-model requests at the
decision model (`SessionStats::decision`). `SessionStats::total_cost_usd()` and
`Agent::total_cost_usd()` add them all, and are `None` when any part with
non-zero usage has no price.
## Enabling pricing
**Nothing is priced by default** (since 0.25). Every constructor leaves
`ModelConfig::cost` at `None`, so costs read as *unpriced*: `session_cost_usd()`
and `total_cost_usd()` return `None`, `SessionStats::is_unpriced()` is true,
`Budget::for_model` returns `None`, and the decision presets (`jev()`,
`clef*()`) report no cost. Only models you run for free stay priced at $0
(`DecisionModel::local`, a loopback `DecisionModel::logprobs`, or a `cost` of
zero rates you set yourself).
Opt in **once per process, before building configs**, with one call:
```rust
use yoagent::provider::prices;
// Offline and reproducible: the snapshot compiled into this release.
prices::enable_bundled();
```
```rust
use yoagent::provider::{prices, PriceSource};
// Live (native only): fetch models.dev now and install it over the bundled
// snapshot. If the fetch fails, the snapshot alone is used — and it says so.
let live = prices::enable_live(&PriceSource::ModelsDev).await;
if let Some(e) = live.origin.fetch_error() {
tracing::warn!("price fetch failed ({e}); using the bundled snapshot");
}
```
| `prices::enable_bundled()` | none | all, wasm32 included | the bundled snapshot |
| `prices::enable_live(&source)` | one request, now | `native` | the fetched table over the snapshot; the snapshot alone if the fetch fails |
| `prices::enable_live_cached(&source, path, CacheOptions)` | only when the cache is stale | `native` | as `enable_live`, through a cache file (see [Caching](#caching)) |
All three return what changed in the resolved table (every entry, including providers no constructor reads): `enable_bundled` a
`Vec<PriceChange>`, the live ones a `LivePrices` report carrying `changes`
plus the fetch's `origin`, `skipped`, `ignored_fields` and `cache_problem`
(`live.fell_back()` is true when only the snapshot is in effect; the fallback
is also logged at `warn`). They affect configs built **afterwards**; re-price
the ones you hold with `config.reprice()` / `agent.reprice()`.
`global::clear_bundled()` turns the snapshot off again;
`global::is_bundled_enabled()` and `global::pricing_enabled()` report the
state. On wasm32, `PriceTable::fetch` still works: fetch, then
`enable_bundled()` and `global::install_fetched(table)`.
A user layer is an opt-in of its own, for exactly the models it lists: the
`YOAGENT_PRICES` file and `global::install_override` (and likewise
`global::install_fetched` with the snapshot off). An app that shows costs — a
yoyo-style CLI — needs one of these calls at startup.
yoagent **never makes a network request for prices unless you call a
function that says it does** (`enable_live*`, `PriceTable::fetch*`).
## The data file
The **bundled snapshot** is one JSON file,
[`src/provider/prices.json`](https://github.com/yologdev/yoagent/blob/main/src/provider/prices.json),
embedded at compile time and applied only when you opt in. It is keyed by `ModelConfig::provider`, then by
`ModelConfig::id`:
```json
{
"schema": 1,
"providers": {
"anthropic": {
"claude-opus-5-5": {
"input": 4.0,
"output": 20.0,
"cache_read": 0.2,
"cache_write": 5.0,
"note": "Cache hits bill at 0.05x input ...",
"source": "https://platform.claude.com/docs/en/about-claude/pricing",
"verified": "2026-09-25"
}
},
"openai": {
"gpt-6-sol": {
"input": 2.0,
"output": 10.0,
"cache_read": 0.2,
"cache_write": 2.5,
"tiers": [
{
"above_prompt_tokens": 272000,
"input": 4.0,
"output": 15.0,
"cache_read": 0.4,
"cache_write": 5.0
}
],
"source": "https://developers.openai.com/api/docs/pricing",
"verified": "2026-09-25"
}
}
}
}
```
| `schema` | Format version. This release reads `1` and rejects anything else. |
| `input`, `output` | USD per million tokens. Required. |
| `cache_read`, `cache_write` | USD per million cached / cache-written prompt tokens. Omitted or `0` bills at the band's input rate (see [the zero-rate rule](../reference/configuration.md#costconfig)). |
| `tiers` | Context tiers: above `above_prompt_tokens` prompt tokens the whole request bills at the tier's rates. Strictly ascending. |
| `cache_write_at_input` | The vendor has no cache-write premium and the entry states `cache_write` as the input rate. Validated: every band's `cache_write` must equal its `input`. |
| `note` | A caveat, e.g. a rate the vendor page does not state itself. |
| `source` | The vendor page the rates were checked against — the authority. |
| `verified` | The date (`YYYY-MM-DD`) they were checked. |
| `absent_upstream` | Why models.dev lacks this model, and when that was checked (read by the price audit). |
Parsing validates every entry, through the one choke point
`PriceTable::insert` (and `PriceEntry::validate`, which you can call
yourself):
- provider and model ids are non-empty;
- rates are finite and non-negative;
- tier thresholds are above zero and strictly ascending;
- `verified` is a `YYYY-MM-DD` date;
- a `cache_write_at_input` flag agrees with the rates.
**Unknown fields** depend on who maintains the data:
- **Strict**, for data people maintain by hand: `PriceTable::from_json_str`,
`from_path`, the `YOAGENT_PRICES` file and `PriceSource::Url`. An unknown
field is `PriceError::UnknownField`, so a misspelled `"cache_reed"` is an
error, not a silently unpriced cache. It may also mean the data was written
for a newer yoagent; the error says to upgrade or remove the field.
- **Lenient**, for this crate's own published file (`PriceSource::YoagentMain`)
and the `fetch_cached` cache: unknown fields are ignored, logged at `warn`
and returned to you (`FetchReport::ignored_fields`,
`CachedPrices::ignored_fields`).
### Format evolution
- A field that **changes what is billed** (a new rate, a new kind of tier)
**bumps `schema`**. An older yoagent then rejects the file with
`PriceError::UnsupportedSchema`, rather than billing without the field.
- A **metadata-only** field (like `note`) does **not** bump `schema`. Older
releases ignore it in this crate's published file.
A test pins the set of field names to the schema version. Changing the set
fails CI until someone decides which of the two cases it is.
In code the file is a `PriceTable`, plain data:
```rust
use yoagent::provider::PriceTable;
let builtin = PriceTable::builtin(); // the bundled snapshot, as a value — installs nothing
let opus = builtin.cost("anthropic", "claude-opus-5-5"); // Option<CostConfig>
for (provider, id, entry) in builtin.iter() {
println!("{provider}/{id}: ${}/M in, verified {:?}", entry.cost.input_per_million, entry.verified);
}
let mine = PriceTable::from_path("my-prices.json")?; // or from_json_str
let combined = builtin.layered(&mine); // mine wins per (provider, id)
println!("{}", combined.to_json()); // back to the same format
```
`PriceError` is `Clone` and `#[non_exhaustive]`. `Json`, `Io` and `Http`
errors name the file or URL they came from.
## Which constructors are priced
Constructors look `(provider, id)` up in the process-wide table **when the
config is built**. Until the process opts in (above) the table is empty and
every lookup is `None`. Once it has:
- **Named presets** (`claude_fable_5`, `claude_fable_5_1`, `claude_opus_5_5`,
`claude_opus_5`, `claude_opus_4_8`, `claude_sonnet_5`, `claude_haiku_4_5`,
`gpt_5_5`, `gpt_6_astra`, `gpt_6_sol`, `gpt_6_luna`) are all listed in the
bundled snapshot.
- **Generic first-party constructors** — those whose provider is in
`PRICED_PROVIDERS`: `anthropic`, `openai` and `openai_responses` (both use
provider `openai`), `google`, `xai`, `groq`, `deepseek`, `mistral`, `zai`,
`minimax`, `qwen`, `meta` — return `Some` when the table lists the id and
`None` otherwise. `ModelConfig::anthropic("claude-sonnet-5", ..)` is priced
exactly like `claude_sonnet_5()`; `meta` prices `muse-spark-1.1` and
`muse-spark-1.2` and leaves any other id (the far cheaper contributor tier,
say) unknown. `deepseek` prices `deepseek-flash`, `deepseek-v4-pro` and the
legacy `deepseek-v4-flash` (see below).
- **Gateways and custom endpoints** — `custom`, `openai_compat`, `local`,
`ollama`, `opencode_zen`, `opencode_go`, `mock` — are always `None`, even
for an id the table lists. A gateway's bill is not the vendor's list price.
`None` means **unknown**, never free. Set `config.cost` yourself for a model
the table does not list.
**DeepSeek is priced at its peak rate.** DeepSeek bills half its peak rate
off-peak: every hour except 01:00–04:00 and 06:00–10:00 UTC on weekdays,
about 79% of the week. A `CostConfig` has no time of day, so the snapshot
carries the peak rate, the upper bound: off-peak, `cost_usd` and
`total_cost_usd()` are up to twice the bill, and a `Budget` stops early rather
than late. To count off-peak spend instead, install the off-peak rates as an
override (`install_override` or `YOAGENT_PRICES`, below):
```json
{ "schema": 1, "providers": { "deepseek": {
"deepseek-flash": { "input": 0.15, "output": 0.6, "cache_read": 0.003, "cache_write": 0.15 },
"deepseek-v4-flash": { "input": 0.15, "output": 0.6, "cache_read": 0.003, "cache_write": 0.15 },
"deepseek-v4-pro": { "input": 0.66, "output": 1.98, "cache_read": 0.022, "cache_write": 0.66 } } } }
```
Each model needs its own entry (one left out keeps the peak rate), and
`cache_write` is stated as the input rate: DeepSeek reports no cache-write
tokens, and leaving it out makes the override report a warning.
## Overriding prices at runtime
A price change should not have to wait for a yoagent release. Constructors
read a process-wide **resolved table**, managed by the
`yoagent::provider::prices::global` module. It is built in layers, highest
precedence first:
| 1 | User override | `global::install_override(table)`, or the file named by `YOAGENT_PRICES` |
| 2 | Fetched | `prices::enable_live*`, or `global::install_fetched(table)` — see [Live sources](#live-sources) |
| 3 | Bundled snapshot | `src/provider/prices.json`, compiled in; **off** until `prices::enable_bundled()` or `enable_live*` |
With no layer, the table is empty and nothing is priced. Each layer alone
prices exactly the models it lists.
A `cost` you set on a config yourself wins over every layer, because only
constructors read them. But `config.reprice()` and `config.with_prices()`
overwrite it (see [Re-pricing a config](#re-pricing-a-config)).
Each layer replaces **whole entries** per `(provider, id)`. An override file
may be partial: it overrides exactly the models it lists. An entry it lists
replaces the lower entry entirely, so a rate it omits is not inherited — a
missing cache rate bills at the input rate.
```rust
use yoagent::provider::prices::global;
use yoagent::provider::{ModelConfig, PriceTable};
// At startup, before building any config:
let report = global::install_override(PriceTable::from_path("prices.override.json")?);
for c in &report.changes {
tracing::info!("price override: {c}"); // e.g. "anthropic/claude-sonnet-5: input 2 -> 1.8, ..."
}
let config = ModelConfig::claude_sonnet_5(); // priced from the override
// Models the override does not list stay unpriced unless the snapshot (or a
// fetched table) is enabled under it: `prices::enable_bundled()`.
```
Or, with no code change:
```text
YOAGENT_PRICES=/etc/myapp/prices.json ./myapp
```
### What `install_override` reports
The whole install holds one lock and returns an `OverrideReport`
(`#[must_use]`), computed by comparing the resolved table before and after:
- `changes`: the models the new override prices differently. Entries no
constructor reads are excluded.
- `reverted`: models whose price changed because a previous user layer listed
them and this one does not. They revert to the fetched or bundled price,
or become unpriced.
- `inert`: entries whose provider nothing looks up — not in
`PRICED_PROVIDERS`, and (with the `decision` feature) not a decision price
key (`typesafe`, `cloudflare`, `openai-decisions`). Only `with_prices` reads
them. A gateway name like `openrouter` or `opencode-zen` is the typical
case.
- `warnings`: everything below, also logged at `warn`.
An entry replaces the lower entry whole, and the mistakes that makes easy
are warned about:
- **Inert entries**, as above.
- **Dropped tiers.** The entry has no context tiers, but the entry it
replaces — bundled or fetched — had some. Every request then bills at the
base rates.
- **Unset cache rate.** The entry leaves a cache rate unset, so it bills at
the input rate, where the replaced entry set one.
- **Replacing a user layer**, especially one loaded from `YOAGENT_PRICES`.
`install_override` replaces the layer; it never merges into it.
`global::clear_override()` removes the user layer. `global::resolved()`
returns a snapshot of what a constructor would use now.
### `YOAGENT_PRICES`
- It is read **once**, the first time anything touches the process-wide
table. Unset or empty means no file.
- It is honoured with nothing else enabled, and is then the opt-in for
exactly the models it lists; everything else stays unpriced unless the
process also calls `prices::enable_bundled()` or `enable_live*`.
- A missing, unreadable or invalid file does not panic. It is logged with
`tracing::warn!` and ignored, and no user layer is installed.
- The outcome is visible to the host, not just in the log:
`global::env_override_status()` returns an `EnvOverride`:
- `Unset`: the variable was unset or empty;
- `Loaded { path, entries, warnings }`: the file loaded, with the same
warnings `install_override` reports;
- `Rejected { path, error }`: the file was ignored.
A host that would rather fail than run on prices it did not ask for can
check this at startup. `global::load_env_override()` reads and strictly
parses the file right away and returns
`Result<Option<PriceTable>, PriceError>`.
- On wasm32 there are no environment variables or files: `YOAGENT_PRICES` is
always `Unset` and `PriceTable::from_path` fails. Install an override with
`global::install_override(PriceTable::from_json_str(json)?)` instead.
- This crate's own unit tests never read `YOAGENT_PRICES`. Integration tests
and doctests that assert list prices call `global::clear_override()`
first, and the suite passes with the variable set.
### Constructors resolve when they run
A config built before an install keeps the price it was built with. Install
prices first and build configs afterwards, or re-price what you already
hold: `config.reprice()`, `agent.reprice()`, or `SubAgentTool::reprice()`
(builder-style, before the tool is registered).
## Live sources
yoagent **never fetches prices on its own**. When you ask —
`prices::enable_live(&source)` / `enable_live_cached`, which also install the
result, or `PriceTable::fetch(&source)`, which only returns it — it downloads
a table from a `PriceSource`:
| `PriceSource::YoagentMain` | This crate's own `src/provider/prices.json` on GitHub `main`. Same format, same review, same audit as a release — a checked price fix merged to `main` reaches you without waiting for one. | leniently |
| `PriceSource::ModelsDev` | [models.dev](https://models.dev/api.json), mapped into this format. Covers thousands of models. | mapped |
| `PriceSource::ModelsDevAt(url)` | A models.dev-format document elsewhere (a mirror, a pinned snapshot). | mapped |
| `PriceSource::Url(url)` | Any URL serving this crate's format — typically a file you maintain. | strictly |
`PriceTable::fetch_with(&source, FetchOptions)` takes a timeout (default
10 s: `FetchOptions::new().with_timeout(..)`; a per-request timeout, so it also
applies on wasm32) and returns a `FetchReport`:
- `table`: the fetched prices;
- `skipped`: every model a models.dev source listed but could not map, and
why (`SkippedModel`);
- `ignored_fields`: fields a lenient parse ignored.
`PriceTable::from_models_dev_json_report` returns the same report for a
document you already have.
### Caching
`PriceTable::fetch_cached(&source, path, CacheOptions)` wraps a fetch in a
cache file. It never fails. `CacheOptions::new()` defaults to refetching
after a day (`max_age`), accepting a stale cache at most a week old when
offline (`max_stale`), and a 10 s timeout. Each has a `with_*` setter.
`fetch_cached` needs the default `native` feature (its disk cache); on wasm32
use `PriceTable::fetch` / `fetch_with`.
`prices::enable_live_cached(&source, path, CacheOptions)` is this plus the
install, in one call. By hand:
```rust
use yoagent::provider::prices::global;
use yoagent::provider::{CacheOptions, PriceSource, PriceTable};
let prices = PriceTable::fetch_cached(
&PriceSource::YoagentMain,
cache_dir.join("yoagent-prices.json"),
CacheOptions::new(),
)
.await;
if let Some(e) = prices.origin.fetch_error() {
tracing::warn!("price refresh failed ({e}); using {:?}", prices.origin);
}
yoagent::provider::prices::enable_bundled(); // the floor under the fetched table
if !prices.origin.is_builtin() {
let changes = global::install_fetched(prices.table);
for c in changes.iter().filter(|c| c.before.is_some()) {
tracing::info!("price changed: {c}");
}
}
// ...then build configs.
```
The result is a `CachedPrices`. Its `origin` says where the table came from
and carries only the data that makes sense for that origin:
1. **`Cache { age }`.** A cache of this source younger than `max_age` was
used without a request.
2. **`Fetched { cache_write_error }`.** The source was fetched and the cache
rewritten, in this crate's format, recording the source URL. If the write
failed, the table is still good and the error is here.
3. **`StaleCache { age, fetch_error }`.** The fetch failed, so an expired
cache no older than `max_stale` was used.
4. **`Builtin { fetch_error }`.** The fetch failed and no cache was usable:
the table is the bundled snapshot. Enable it with
`prices::enable_bundled()` rather than installing it as a fetched layer.
`CachedPrices` also carries `skipped` and `ignored_fields` (as in
`FetchReport`) and `cache_problem`: a cache file that existed but could not
be used. A `CacheProblem` is one of:
- `Unreadable`: the file could not be read;
- `Invalid`: the file is not a valid price cache;
- `SourceMismatch { cached, expected }`: the file caches another source.
Every cache problem is also logged at `warn`; only a *missing* file is
silent.
Some edge cases:
- **Unknown age.** A cache whose modification time is in the future, or
unavailable, is never fresh. It serves as a stale fallback only when
`max_stale` is `Duration::MAX`.
- **One path per source.** A cache of a different source is a miss, not a
hit, so sharing a path just refetches.
### Installing: replace or only add
`global::install_fetched(table)` is
`global::install_fetched_with(table, InstallPolicy::ReplaceAll)`. It replaces
the fetched layer, so the table overrides the bundled snapshot (when enabled)
for every model it lists. With the snapshot off, it prices exactly the models
it lists. Replacing a non-empty fetched layer is logged at `warn`.
`InstallPolicy::AddOnly` merges into the current fetched layer only the
models that no lower layer — the bundled snapshot (when enabled) or the
current fetched layer — lists. A fetched table can then extend coverage (models.dev's thousands of
models) without overriding any price already in effect.
Both are `#[must_use]`. They return `Vec<PriceChange>`, computed against the
bundled (when enabled) plus fetched table **before** the call. So a model the
previous fetched layer listed and the new one does not shows up too, with
`after: None` or its bundled price. A change the user layer overrides is still
reported, marked `shadowed: true`: it does not change what is billed until
the override is cleared.
`global::clear_fetched()` removes the fetched layer.
### Trust
With the default policy, a fetched table **overrides the bundled snapshot**
for every model it lists.
- **`YoagentMain`** is as trustworthy as a release — it is the file releases
are cut from. Suppose `main` moves to a newer schema than your yoagent
reads, because a field that changes billing was added. The fetch then
fails with `PriceError::UnsupportedSchema`, and nothing changes.
Metadata-only fields do not bump the schema, and this source is parsed
leniently, so older releases keep reading it (see
[Format evolution](#format-evolution)).
- **`ModelsDev` is community-maintained and not authoritative.** It has been
provably wrong before — it mis-stated Claude context-tier data and DeepSeek
V4 Pro's price. The mapping is conservative:
- A model whose cost carries structure `CostConfig` cannot express is
**skipped**, not approximated. That covers a separate reasoning rate, a
non-context tier and an unknown key — and DeepSeek's models, priced by
time of day: models.dev lists the off-peak rate, which must not replace
the snapshot's peak one (they are reported as skipped, without a warning).
- A skipped model that the bundled snapshot lists is **named in a `warn`
log**. It keeps the snapshot's price when the snapshot is enabled
(`enable_live*` always enables it).
- Audio rates are ignored.
- Provider keys are models.dev's own, with two renames to this crate's
names: `alibaba` becomes `qwen`, and `opencode` becomes `opencode-zen`.
- A document from which nothing maps is an error, never an empty table.
Either way, disagreements are visible, not silent. While the bundled snapshot
is enabled, `install_fetched` (and `enable_live*`) compare the new layer with
the snapshot **as billed**: a zero cache rate counts as the input rate. They
log, at `warn`, how many snapshot models it prices differently, spelling out
the first five and counting the rest:
```text
WARN yoagent prices: the fetched table disagrees with the bundled snapshot on 7 model(s)
and takes precedence for them: anthropic/claude-sonnet-5: input 2 -> 1.5, ...; and 2 more
```
`fetched.changes_from(&PriceTable::builtin())` computes that list without
installing anything. Use it to decide whether to install at all, or install
with `InstallPolicy::AddOnly`.
## Re-pricing a config
There are two ways to re-price a config you already built, and they differ
on purpose.
**`config.reprice()`** repeats the constructor's own lookup against the
process-wide table **now**. Use it for configs built before an opt-in, an
override or a fetched layer was installed. `Agent::reprice()` and `SubAgentTool::reprice()`
do the same for the config they hold; `Agent::reprice()` also re-prices an
`LlmCompaction` summarizer (`LlmCompaction::reprice()`).
With only a `YOAGENT_PRICES` file or `install_override` / `install_fetched` in
effect (no bundled snapshot), a first-party lookup that misses logs a warning
once per model: it is unpriced, and `enable_bundled()` would price it.
- The result is exactly what the constructor would set today, **including
`None`** when the model is no longer listed.
- **A `cost` you assigned yourself is replaced.**
- It acts only on configs built by a first-party constructor that looks
prices up — the named presets and `anthropic`, `openai`,
`openai_responses`, `google`, `xai`, `groq`, `deepseek`, `mistral`, `zai`,
`minimax`, `qwen`, `meta` — and only while their `provider` is still in
`PRICED_PROVIDERS`.
- Gateways, custom endpoints, configs deserialized from disk and configs
whose `provider` you changed are returned unchanged.
```rust
let config = ModelConfig::claude_sonnet_5(); // built before the override: unpriced
let _ = global::install_override(mine);
let config = config.reprice(); // now priced from the override
```
**`config.with_prices(&table)`** re-resolves one config against a table you
hold, without touching any global state.
- A model the table lists gets its rates, replacing any `cost` you set.
- A model it does not list **keeps its current cost**: `with_prices` never
clears a price.
- It needs no opt-in: the table you pass is the price list
(`with_prices(&PriceTable::builtin())` prices one config from the snapshot
without touching process-wide state).
- It applies to **any** config, gateways and custom endpoints included. It
looks them up under their own `provider`, such as `opencode-zen`: calling
it is you saying what you pay there.
```rust
let mine = PriceTable::from_json_str(r#"{"schema": 1, "providers": {
"anthropic": {"claude-sonnet-5": {"input": 1.8, "output": 9.0,
"cache_read": 0.18, "cache_write": 2.25}}}}"#)?;
let config = ModelConfig::claude_sonnet_5().with_prices(&mine); // your negotiated rate
```
## Keeping the data honest
`tests/price_audit.rs` diffs **every** entry of the bundled snapshot against
[models.dev](https://models.dev) — rates, tiers and thresholds — and fails on
drift, on an entry that vanished upstream, and on a comparison that silently
checked nothing. The comparison logic also runs offline in CI, against a
checked-in slice of models.dev. models.dev is community-maintained, so a
failure sends a human to the entry's `source`; the audit never edits the
file. Where the vendor and models.dev legitimately differ, the audit records
the difference and asserts it both ways rather than skipping the field: for
DeepSeek, the snapshot's rates must be exactly twice models.dev's (which lists
the off-peak ones), and models.dev's separate `reasoning` rate must equal its
output rate. Run the live audit before a release:
```text
cargo test --all-features --test price_audit -- --ignored --nocapture
```