Repository navigation
config.toml silently accepts a misspelled price key and then bills at zero #438
Description
Summary
config.toml is the sole catalogue source for model prices, and its schema accepts any key: no struct in src/ carries #[serde(deny_unknown_fields)]. An operator who misspells a price field — or writes a [[models.tiers]] key directly under [[models]], or writes context_window for context_length — gets a config that loads, validates, starts, and settles money at a price they never declared. Nothing errors and nothing is logged.
This is a core-config-validation defect, not an extension-surface one: config.toml is not a plugin point that an extension could supply a loader for. It belongs to the core.
Measured, not inferred
Probe run on the current tip (a49f130) through the same entry point the binary uses, Config::load — which is toml::from_str followed by Config::validate(). Each variant is the shipped config/config.example.toml with one edit, so the rest of the file is known-good. The model inspected is deepseek-flash, whose cache_hit_input_per_m is 0.02 in the example.
| Probe | Edit to the example config | Loader said | Parsed result |
|---|---|---|---|
| control | none | LOADED OK |
cache_write_input_per_m=0.0, cache_hit_input_per_m=0.02 |
| A | added cache_write_input_per_mm = 3.5 and cache_hit_input_per_mm = 0.09 under [[models]] |
LOADED OK |
cache_write_input_per_m=0.0, cache_hit_input_per_m=0.02 — both typos silently discarded, the 3.5 never reaches the field |
| B | wrote from_tokens = 512001 and output_per_m_tier = 6.0 under [[models]] instead of [[models.tiers]] |
LOADED OK |
tiers=0 — the operator believes the model is tiered; it is not |
| C | renamed context_length to context_window |
LOADED OK |
context_length=0 — the model reports no context window |
The three carriers
- The loader discards unknown keys.
grep -rn deny_unknown_fields src/returns nothing.Config::load(src/config.rs:500) istoml::from_strplusvalidate(), andtomlignores keys the struct has no field for. validate()cannot catch it, because it checks signs, not presence. It rejects a negativecache_write_input_per_mand negative or non-increasing tier prices — but a typo leaves the field at its#[serde(default)]value of0.0, which is a legal price meaning "free". Nothing distinguishes "the operator chose 0" from "the operator misspelled the key". Startup (src/main.rs:497) prints to stderr, in its own Chinese, "failed to load config" followed by the error and a hint to copy the example config, then exits non-zero — but only for a genuineErr, which this is not.- Billing consumes the parsed value directly.
billing::raw_cost(src/billing.rs:52) computescache_write_tokens * cache_write_input_per_m / 1_000_000.0. Measured on the typed-in value from probe A: 60,000 cache-write tokens settle at 0.000000 as loaded, against 0.210000 at the 3.5 the operator wrote. The typo is a silent revenue gap on every request that writes cache.
Variants B and C are not about money, but they are the same defect shape and the same fix: a declared setting that the runtime never sees.
Blast radius of a fix, stated plainly
Adding #[serde(deny_unknown_fields)] is a startup behaviour change: any existing deployment whose config.toml carries a stray or outdated key will stop loading, where it loads today. That is the intent — a config that says something the runtime ignores should not look healthy — but it makes the upgrade fail-closed, so it must ship with a migration note telling deployers to grep their own config first (grep -n for each documented key, or simply compare against config/config.example.toml).
Two mitigations worth weighing, and the reason the scope is worth deciding in the open:
- Applied to
Model,ModelTier,Plan,EndpointandProvideronly, the change is narrow: those are the tables an operator hand-edits to set prices and routing. Applied toConfigas a whole it also covers[server],[log],[mail],[archive],[rollup],[usage_retention],[billing]and[points], where a misspelling is equally silent but costs no money. - A softer first step is a startup warning that lists keys present in the file but absent from the schema, which needs no migration and cannot break an existing deployment. It costs a scan of the raw TOML, and it does not remove the "0 is indistinguishable from a typo" problem for the price fields specifically.
The shipped config/config.example.toml is clean today — I checked every [[models]] key against the Model struct's field list and found none unknown — so this is a hazard in operator-edited copies, not a defect in the example.
Related
#437 tracks the release gap; this issue is independent of it and would need its own release to reach deployers.
I'd like to work on this. My intent, subject to a maintainer's word on the blast radius above: deny_unknown_fields on the model-price structures with a test that a misspelled price key must fail to load while a valid config still loads, and a migration note in the PR body.
Activity
fix(config): reject keys the schema does not declare (#439)