yoagent 0.26.2

The agent loop for Rust: stream from 7 LLM protocols, run tools, loop until done. Native or wasm32 (Cloudflare Workers).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
# 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`), and the model calls extensions make
outside the run's own turns at whatever model each one reports through
`RunContext::spend_handle` (`SessionStats::extensions`, an `ExtensionSpend`;
`Agent::extension_spend()`). `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");
}
```

| Function | Network | Targets | What it installs |
|----------|---------|---------|------------------|
| `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"
      }
    }
  }
}
```

| Field | Meaning |
|-------|---------|
| `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_sonnet_5_5`,
  `claude_haiku_5_5`, `claude_haiku_4_5`, `gpt_5_5`, `gpt_6_astra`,
  `gpt_6_sol`, `gpt_6_1_sol`, `gpt_6_luna`, `gemini_4_argon`,
  `gemini_3_8_flash`, `grok_4_7`, `mistral_large_4`, `glm_5_3`,
  `glm_5_3_flash`, `qwen_3_8_max`, `qwen_3_8_flash`) are all listed in the
  bundled snapshot. Two carry the **regular** rate during a vendor
  promotion (`gemini_4_argon` to 2026-11-30, `gemini_3_8_flash` to
  2026-12-31), so their costs read up to twice the bill until it ends — an upper bound, like DeepSeek's peak rate.
  That holds for the bundled snapshot: `enable_live*` installs models.dev's
  current, promotional rate over it (the true bill today), and a live table
  or cache fetched before a promotion ends keeps that rate until refetched.
- **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` and `deepseek-v4-flash-vision-exp` (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:

| # | Layer | Set by |
|---|-------|--------|
| 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`:

| Source | What it is | Parsed |
|--------|------------|--------|
| `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
```