Skip to content

config.toml silently accepts a misspelled price key and then bills at zero #438

Closed

Description

@argszero

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

  1. The loader discards unknown keys. grep -rn deny_unknown_fields src/ returns nothing. Config::load (src/config.rs:500) is toml::from_str plus validate(), and toml ignores keys the struct has no field for.
  2. validate() cannot catch it, because it checks signs, not presence. It rejects a negative cache_write_input_per_m and negative or non-increasing tier prices — but a typo leaves the field at its #[serde(default)] value of 0.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 genuine Err, which this is not.
  3. Billing consumes the parsed value directly. billing::raw_cost (src/billing.rs:52) computes cache_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, Endpoint and Provider only, the change is narrow: those are the tables an operator hand-edits to set prices and routing. Applied to Config as 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

  1. added a commit that references this issue on Oct 25, 2026
    db0160f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions