#[non_exhaustive]pub struct CostConfig {
pub input_per_million: f64,
pub output_per_million: f64,
pub cache_read_per_million: f64,
pub cache_write_per_million: f64,
pub context_tiers: Vec<ContextTier>,
}Expand description
Cost per million tokens (input/output).
§A cache rate left at zero bills at the input rate
cache_read_per_million or cache_write_per_million at 0.0 means “not
separately priced”, and cost_usd bills those tokens at
the applicable input rate — the base input_per_million, or a
ContextTier’s own input_per_million when that tier applies (a tier’s
zero cache rate falls back to the tier’s input rate, never to a base cache
rate). A vendor with no separate cache-write charge (OpenAI before
GPT-5.6, Meta) is priced correctly without setting one, and a missing
rate can no longer bill cached tokens as free. The rule applies only
where the input rate is non-zero, so a fully zero config still costs $0.
There is therefore no way to say “cache reads are free but input is
not”; set a tiny positive rate if a vendor ever charges that.
§These are a snapshot, not an authority
Nothing is priced by default: constructors leave cost at None
until the process opts in, with
prices::enable_bundled
(offline) or prices::enable_live (native). See
prices.
The bundled rates live in src/provider/prices.json (see
PriceTable), each entry verified against
the vendor’s published pricing on the date it records. Vendors reprice,
and a compiled-in number cannot notice — claude_sonnet_5 shipped Sonnet
4.6’s rates across 18 releases, v0.9.0 through v0.16.5, overstating every
cost_usd for that model by 50%, and nothing detected it. You can
override the bundled data without waiting for a release: live with
prices::enable_live (native), process-wide
with global::install_override
or the YOAGENT_PRICES environment variable (both apply to configs built
afterwards), or per config with ModelConfig::with_prices and
ModelConfig::reprice.
§Context tiers
Some vendors charge more above a prompt-size threshold. Set
context_tiers and cost_usd selects by the request’s
prompt tokens (input + cache_read + cache_write).
The OpenAI presets set one at 272K: gpt_5_5, gpt_6_astra,
gpt_6_sol, gpt_6_1_sol and gpt_6_luna (checked 2026-09-25 and
2026-10-18; gpt_5_5’s long-band cache-read rate is unverified — see its
docs). claude_haiku_5_5 sets one at 100K and grok_4_7 at 200K. The
other Anthropic presets do not: Anthropic bills their full 1M window at
standard rates (Haiku 5.5 is the exception). Meta’s page says there is no
long-context premium; Haiku 4.5 is flat because its window is 200K.
Note the derivation: prompt size is input + cache_read + cache_write, which
holds only where the provider subtracts cached tokens out of input.
bedrock.rs populates neither cache field, so a heavily-cached prompt reads
small there and would select the cheap tier. Fix that before tiering a model
Bedrock serves.
tests/price_audit.rs diffs every prices.json entry against models.dev;
run it before a release:
cargo test --test price_audit -- --ignored --nocaptureModelConfig::cost is a public field and CostConfig is Deserialize, so
a caller never has to wait for a release — override it for a negotiated
rate, or load rates from configuration:
yoagent::provider::prices::enable_bundled(); // opt in to the bundled prices
// A named preset is then priced; adjust one rate and keep the rest.
let mut config = ModelConfig::claude_sonnet_5();
config
.cost
.get_or_insert_with(CostConfig::default)
.input_per_million = 1.80; // your negotiated rate
assert_eq!(config.cost.as_ref().unwrap().output_per_million, 10.0);
// A model the price table does not list has no price (`cost: None`).
// `get_or_insert_with` works here too — `if let Some(c) =
// config.cost.as_mut()` would silently do nothing — but set every rate
// you pay, since the rest start at zero (an unset cache rate then bills
// at the input rate):
let mut mistral = ModelConfig::mistral("mistral-large-latest", "Mistral Large");
assert!(mistral.cost.is_none());
// The rates you pay, from Mistral's pricing page (these are placeholders).
mistral.cost = Some(CostConfig::new(2.0, 6.0).with_cache_read(0.2));
assert_eq!(mistral.cost.as_ref().unwrap().input_per_million, 2.0);
// A model you run for free is `Some` with zero rates, and costs $0:
let mut local = ModelConfig::local("http://localhost:1234/v1", "qwen3");
local.cost = Some(CostConfig::new(0.0, 0.0));Fields (Non-exhaustive)§
This struct is marked as non-exhaustive
Struct { .. } syntax; cannot be matched against without a wildcard ..; and struct update syntax will not work.input_per_million: f64§output_per_million: f64§cache_read_per_million: f64Rate for cache-hit prompt tokens. 0.0 (the default) bills them at
input_per_million; see the zero-rate rule.
cache_write_per_million: f64Rate for prompt tokens written to the cache. 0.0 (the default)
bills them at input_per_million; see
the zero-rate rule.
context_tiers: Vec<ContextTier>Rates that replace the above once a request’s prompt exceeds a threshold, ascending by threshold. Empty means one flat rate at every size.
A Vec rather than a single tier because vendors publish multi-step
schedules and models.dev already represents this as an array — making
it one tier would buy a second breaking release the first time a
three-tier model appears, and it would break the serde key as well as
the field type, invalidating every persisted config.
Implementations§
Source§impl CostConfig
impl CostConfig
Sourcepub fn new(input_per_million: f64, output_per_million: f64) -> Self
pub fn new(input_per_million: f64, output_per_million: f64) -> Self
The two rates every priced model has.
Cache rates are set with with_cache_read and
with_cache_write rather than as positional
arguments. Four same-typed f64s in a row is a transposition waiting to
happen, and no vendor publishes them in one order: Anthropic lists
input / cache write / cache read / output, OpenAI lists input / cached
input / output. Transcribing top-to-bottom from either page produced a
wrong-but-compiling config, and is_configured returns true for a
transposed one, so every downstream guard passes. That is exactly how
claude_sonnet_5 billed 50% high for 18 releases. Two arguments still
transpose, but output is always dearer than input, so the mistake is
visible.
CostConfig is #[non_exhaustive], so downstream crates build it here
rather than with a struct literal.
Sourcepub fn with_cache_read(self, per_million: f64) -> Self
pub fn with_cache_read(self, per_million: f64) -> Self
Rate for reading a cached prompt prefix. Unset (0.0), cache reads
bill at the input rate.
Sourcepub fn with_cache_write(self, per_million: f64) -> Self
pub fn with_cache_write(self, per_million: f64) -> Self
Rate for writing a prompt prefix into the cache. Unset (0.0), cache
writes bill at the input rate — right for a vendor with no separate
write charge.
Sourcepub fn with_context_tier(self, tier: ContextTier) -> Self
pub fn with_context_tier(self, tier: ContextTier) -> Self
Charge higher rates above a prompt-size threshold.
Repeatable. Tiers are kept sorted by threshold so cost_usd can take
the last one the prompt clears, and so declaration order cannot change
what a config costs.
Sourcepub fn is_configured(&self) -> bool
pub fn is_configured(&self) -> bool
Whether any rate is set.
Not a “known price” check. In memory an all-zero CostConfig means
free, and unknown pricing is ModelConfig::cost == None. This
predicate matters at one point: deserializing a ModelConfig, where an
all-zero cost object is the pre-0.19 encoding of “unknown” and loads
as None (see ModelConfig::cost).
Sourcepub fn cost_usd(&self, usage: &Usage) -> f64
pub fn cost_usd(&self, usage: &Usage) -> f64
Dollar cost of a usage record at these per-million-token rates.
A cache rate of 0.0 bills at the applicable band’s input rate (see
the zero-rate rule).
Consumed by crate::Agent::session_cost_usd; also usable directly
in after_turn callbacks for per-turn cost tracking.