klieo_runlog/pricing/mod.rs
1//! Pricing tables and per-(provider, model) USD rate lookup.
2//!
3//! Used by [`crate::projector::project_with_price_table`] to compute a
4//! [`crate::types::Cost`] across all `Episode::LlmCall` steps in a run.
5//!
6//! ## Rate sources (2026-Q1 published list prices, USD)
7//!
8//! - Anthropic — <https://www.anthropic.com/pricing>
9//! - OpenAI — <https://openai.com/api/pricing/>
10//! - Google — <https://ai.google.dev/gemini-api/docs/pricing>
11//! - Ollama — local execution, zero list price
12//!
13//! These figures are list-price snapshots; revalidate against the upstream
14//! pricing pages on the URLs above before billing or commercial routing
15//! decisions. A periodic refresh job for live pricing is a deferred follow-up
16//! — until then, callers override entries via [`PriceTable::set`].
17//!
18//! ## Model-id matching strategy
19//!
20//! Provider naming conventions vary widely: Anthropic ships date-suffixed
21//! aliases (`claude-3-5-sonnet-20241022`), Ollama uses colon tags
22//! (`qwen2.5:14b`), and others ship versioned -N family heads
23//! (`claude-sonnet-4-6`). The price table keys family aliases with a literal
24//! `-x` suffix (e.g. `claude-sonnet-4-x`), and [`PriceTable::lookup`]
25//! resolves a `(provider, model)` query through this precedence chain:
26//!
27//! 1. **Exact match**: `(provider, model)` verbatim.
28//! 2. **Date-stripped**: if `model` ends in an ISO date `-YYYY-MM-DD` or a
29//! compact date `-YYYYMMDD`, drop the date and re-resolve.
30//! 3. **Colon-stripped**: if `model` contains `:` (Ollama tag form), try the
31//! head before the first `:` and re-resolve.
32//! 4. **Trailing-digits rewrite**: if `model` ends in `-<digits>`, rewrite to
33//! `-x` (e.g. `claude-sonnet-4-6` → `claude-sonnet-4-x`).
34//! 5. **Provider wildcard**: try `(provider, "*")` so the built-in Ollama `*`
35//! rate hits real Ollama model strings without callers passing `"*"`.
36//! 6. **Miss**: return `None`.
37//!
38//! The trailing-digits rewrite is conservative: it only fires when the
39//! trailing segment is purely digits, so `gpt-4o-mini` (alpha tail) does not
40//! collapse to `gpt-4o`. Callers wanting non-default mappings populate the
41//! table explicitly via [`PriceTable::set`].
42
43mod model_alias;
44mod tables;
45
46use crate::types::Cost;
47use model_alias::{family_alias, strip_trailing_date};
48use serde::{Deserialize, Serialize};
49use std::collections::HashMap;
50use thiserror::Error;
51
52/// Token-count denominator for the `*_per_1k` rate convention: rates are
53/// quoted per 1,000 tokens, so cost = (tokens / 1000) * rate.
54const TOKENS_PER_RATE_UNIT: f64 = 1_000.0;
55
56/// Validation failure for [`Rates`] construction.
57///
58/// Returned by [`Rates::try_new`] and [`PriceTable::set`] when a rate field
59/// is non-finite (`NaN`, `+inf`, `-inf`) or negative. Negative or non-finite
60/// rates would silently corrupt downstream USD totals — billing-grade input
61/// validation rejects them at the boundary.
62#[derive(Debug, Clone, Error, PartialEq)]
63#[non_exhaustive]
64pub enum RatesError {
65 /// One of the rate fields was `NaN` or infinite.
66 #[error("rate must be finite: prompt={prompt_usd_per_1k}, completion={completion_usd_per_1k}")]
67 NotFinite {
68 /// Offending prompt rate.
69 prompt_usd_per_1k: f64,
70 /// Offending completion rate.
71 completion_usd_per_1k: f64,
72 },
73 /// One of the rate fields was negative.
74 #[error(
75 "rate must be non-negative: prompt={prompt_usd_per_1k}, completion={completion_usd_per_1k}"
76 )]
77 Negative {
78 /// Offending prompt rate.
79 prompt_usd_per_1k: f64,
80 /// Offending completion rate.
81 completion_usd_per_1k: f64,
82 },
83 /// A cache-tier rate was non-finite or negative.
84 #[error(
85 "cache rate must be finite and non-negative: \
86 read={cache_read_usd_per_1k}, creation={cache_creation_usd_per_1k}"
87 )]
88 CacheRateInvalid {
89 /// Offending cache-read rate.
90 cache_read_usd_per_1k: f64,
91 /// Offending cache-creation rate.
92 cache_creation_usd_per_1k: f64,
93 },
94}
95
96/// Wire-format mirror of [`Rates`] used to gate `Deserialize` through
97/// [`Rates::try_new`].
98///
99/// JSON / serde deserialisation routes through `Rates::try_from(RatesUnchecked)`
100/// so any out-of-range or non-finite input fails closed with a serde error
101/// rather than landing as a poisoned [`Rates`] that would silently corrupt
102/// signed audit-evidence totals.
103#[derive(Debug, Clone, Copy, Deserialize)]
104pub(crate) struct RatesUnchecked {
105 pub(crate) prompt_usd_per_1k: f64,
106 pub(crate) completion_usd_per_1k: f64,
107 #[serde(default)]
108 pub(crate) cache_read_usd_per_1k: f64,
109 #[serde(default)]
110 pub(crate) cache_creation_usd_per_1k: f64,
111}
112
113impl TryFrom<RatesUnchecked> for Rates {
114 type Error = RatesError;
115
116 fn try_from(value: RatesUnchecked) -> Result<Self, Self::Error> {
117 let base = Rates::try_new(value.prompt_usd_per_1k, value.completion_usd_per_1k)?;
118 let (read, creation) = (value.cache_read_usd_per_1k, value.cache_creation_usd_per_1k);
119 if !read.is_finite() || !creation.is_finite() || read < 0.0 || creation < 0.0 {
120 return Err(RatesError::CacheRateInvalid {
121 cache_read_usd_per_1k: read,
122 cache_creation_usd_per_1k: creation,
123 });
124 }
125 Ok(base.with_cache_rates(read, creation))
126 }
127}
128
129/// Per-1k-token USD rates for a single (provider, model) tier.
130///
131/// # Construction
132///
133/// Construct via [`Rates::new`] (const-context, debug-validated) or
134/// [`Rates::try_new`] (runtime-validated, returns [`RatesError`]). Direct
135/// struct-literal init is restricted to in-crate code paths via `pub(crate)`
136/// fields so external callers cannot bypass the finite + non-negative
137/// contract.
138#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
139#[serde(try_from = "RatesUnchecked")]
140pub struct Rates {
141 /// USD cost per 1,000 prompt tokens.
142 pub(crate) prompt_usd_per_1k: f64,
143 /// USD cost per 1,000 completion tokens.
144 pub(crate) completion_usd_per_1k: f64,
145 /// USD cost per 1,000 cache-read tokens (defaults to 0 — providers without
146 /// prompt caching, or tables that predate cache-aware pricing).
147 pub(crate) cache_read_usd_per_1k: f64,
148 /// USD cost per 1,000 cache-creation tokens (defaults to 0).
149 pub(crate) cache_creation_usd_per_1k: f64,
150}
151
152impl Rates {
153 /// Construct a rate pair without runtime validation.
154 ///
155 /// # Contract
156 ///
157 /// Both inputs MUST be finite (`!is_nan() && !is_infinite()`) and
158 /// non-negative. Violations panic in debug builds via `debug_assert!`
159 /// and are silently accepted in release builds for backward compat with
160 /// the const-context callers in the built-in rate table.
161 ///
162 /// Untrusted callers should prefer [`Rates::try_new`], which returns a
163 /// [`RatesError`] instead of relying on the debug-only assertion.
164 pub const fn new(prompt_usd_per_1k: f64, completion_usd_per_1k: f64) -> Self {
165 debug_assert!(
166 prompt_usd_per_1k.is_finite() && completion_usd_per_1k.is_finite(),
167 "Rates::new: both rates must be finite"
168 );
169 debug_assert!(
170 prompt_usd_per_1k >= 0.0 && completion_usd_per_1k >= 0.0,
171 "Rates::new: both rates must be non-negative"
172 );
173 Self {
174 prompt_usd_per_1k,
175 completion_usd_per_1k,
176 cache_read_usd_per_1k: 0.0,
177 cache_creation_usd_per_1k: 0.0,
178 }
179 }
180
181 /// Attach cache-tier rates (read = served-from-cache, creation =
182 /// written-to-cache). Both default to 0; set them where the provider bills
183 /// cached prompt tokens at a distinct tier (e.g. Anthropic). Debug-validated
184 /// finite + non-negative, like [`Rates::new`].
185 #[must_use]
186 pub const fn with_cache_rates(
187 mut self,
188 cache_read_usd_per_1k: f64,
189 cache_creation_usd_per_1k: f64,
190 ) -> Self {
191 debug_assert!(
192 cache_read_usd_per_1k.is_finite() && cache_creation_usd_per_1k.is_finite(),
193 "Rates::with_cache_rates: both rates must be finite"
194 );
195 debug_assert!(
196 cache_read_usd_per_1k >= 0.0 && cache_creation_usd_per_1k >= 0.0,
197 "Rates::with_cache_rates: both rates must be non-negative"
198 );
199 self.cache_read_usd_per_1k = cache_read_usd_per_1k;
200 self.cache_creation_usd_per_1k = cache_creation_usd_per_1k;
201 self
202 }
203
204 /// Construct a rate pair with runtime validation.
205 ///
206 /// Rejects `NaN`, `±inf`, and negative inputs with [`RatesError`]. This
207 /// is the constructor untrusted callers (config loaders, dynamic rate
208 /// updates) should use.
209 pub fn try_new(prompt_usd_per_1k: f64, completion_usd_per_1k: f64) -> Result<Self, RatesError> {
210 if !prompt_usd_per_1k.is_finite() || !completion_usd_per_1k.is_finite() {
211 return Err(RatesError::NotFinite {
212 prompt_usd_per_1k,
213 completion_usd_per_1k,
214 });
215 }
216 if prompt_usd_per_1k < 0.0 || completion_usd_per_1k < 0.0 {
217 return Err(RatesError::Negative {
218 prompt_usd_per_1k,
219 completion_usd_per_1k,
220 });
221 }
222 Ok(Self {
223 prompt_usd_per_1k,
224 completion_usd_per_1k,
225 cache_read_usd_per_1k: 0.0,
226 cache_creation_usd_per_1k: 0.0,
227 })
228 }
229
230 /// Compute the [`Cost`] for prompt + completion tokens at these rates (no
231 /// cache tokens). Equivalent to [`Rates::cost_for_cached`] with both cache
232 /// counts 0; `Cost::cache_usd` is 0.
233 pub fn cost_for(&self, prompt_tokens: u32, completion_tokens: u32) -> Cost {
234 self.cost_for_cached(prompt_tokens, completion_tokens, 0, 0)
235 }
236
237 /// Compute the [`Cost`] across all four token tiers — prompt, completion,
238 /// cache-read, cache-creation — each at its own rate. `total_usd` is their
239 /// sum. Token counts are `u32`; conversion to `f64` is lossless across the
240 /// full `u32` range.
241 pub fn cost_for_cached(
242 &self,
243 prompt_tokens: u32,
244 completion_tokens: u32,
245 cache_read_tokens: u32,
246 cache_creation_tokens: u32,
247 ) -> Cost {
248 // Defense-in-depth: the constructors guarantee finite + non-negative,
249 // and pub(crate) fields block external literal-init bypass — but if an
250 // in-crate edit ever lands an invalid Rates, surface it loudly in dev.
251 debug_assert!(
252 self.prompt_usd_per_1k.is_finite() && self.prompt_usd_per_1k >= 0.0,
253 "Rates::cost_for: prompt rate must be finite and non-negative"
254 );
255 debug_assert!(
256 self.completion_usd_per_1k.is_finite() && self.completion_usd_per_1k >= 0.0,
257 "Rates::cost_for: completion rate must be finite and non-negative"
258 );
259 let per_1k = |tokens: u32, rate: f64| (f64::from(tokens) / TOKENS_PER_RATE_UNIT) * rate;
260 let prompt_usd = per_1k(prompt_tokens, self.prompt_usd_per_1k);
261 let completion_usd = per_1k(completion_tokens, self.completion_usd_per_1k);
262 let cache_usd = per_1k(cache_read_tokens, self.cache_read_usd_per_1k)
263 + per_1k(cache_creation_tokens, self.cache_creation_usd_per_1k);
264 Cost {
265 cache_usd,
266 prompt_usd,
267 completion_usd,
268 total_usd: prompt_usd + completion_usd + cache_usd,
269 }
270 }
271
272 /// USD cost per 1,000 prompt tokens.
273 pub fn prompt_usd_per_1k(&self) -> f64 {
274 self.prompt_usd_per_1k
275 }
276
277 /// USD cost per 1,000 completion tokens.
278 pub fn completion_usd_per_1k(&self) -> f64 {
279 self.completion_usd_per_1k
280 }
281
282 /// USD cost per 1,000 cache-read tokens.
283 pub fn cache_read_usd_per_1k(&self) -> f64 {
284 self.cache_read_usd_per_1k
285 }
286
287 /// USD cost per 1,000 cache-creation tokens.
288 pub fn cache_creation_usd_per_1k(&self) -> f64 {
289 self.cache_creation_usd_per_1k
290 }
291}
292
293/// `(provider, model)` keyed table of [`Rates`].
294///
295/// See [module docs](self) for the lookup-fallback rule.
296#[derive(Debug, Clone, Default)]
297pub struct PriceTable {
298 rates: HashMap<(String, String), Rates>,
299}
300
301impl PriceTable {
302 /// Construct an empty price table.
303 pub fn new() -> Self {
304 Self {
305 rates: HashMap::new(),
306 }
307 }
308
309 /// Construct a price table pre-populated with built-in 2026-Q1 list-price
310 /// rates for the providers shipped with klieo.
311 ///
312 /// See the [module-level rate-source documentation](self) for the
313 /// upstream pricing URLs.
314 pub fn with_default_rates() -> Self {
315 tables::with_default_rates()
316 }
317
318 /// Insert or overwrite the rates for a `(provider, model)` pair.
319 ///
320 /// Validates `rates` via the same finite + non-negative contract enforced
321 /// by [`Rates::try_new`]; returns [`RatesError`] without mutating the
322 /// table on failure.
323 pub fn set(
324 &mut self,
325 provider: impl Into<String>,
326 model: impl Into<String>,
327 rates: Rates,
328 ) -> Result<(), RatesError> {
329 // Re-validate at the boundary: the const Rates::new debug-asserts but
330 // is silent in release builds, and untrusted callers may have
331 // bypassed that path entirely.
332 let _ = Rates::try_new(rates.prompt_usd_per_1k, rates.completion_usd_per_1k)?;
333 self.rates.insert((provider.into(), model.into()), rates);
334 Ok(())
335 }
336
337 /// Look up the rates for a `(provider, model)` pair.
338 ///
339 /// Resolves through the precedence chain documented at the
340 /// [module level](self): exact match → date-stripped → colon-stripped →
341 /// trailing-digits rewrite → `(provider, "*")` wildcard.
342 pub fn lookup(&self, provider: &str, model: &str) -> Option<Rates> {
343 let provider = normalize_provider(provider);
344 // 1. exact
345 if let Some(r) = self.lookup_exact(provider, model) {
346 return Some(r);
347 }
348 // 2. date-stripped — strip the date suffix and try the head, then the
349 // head with `-x` appended (matching the family-alias convention
350 // used by the built-in Anthropic tiers).
351 if let Some(stripped) = strip_trailing_date(model) {
352 if let Some(r) = self.lookup_exact(provider, &stripped) {
353 return Some(r);
354 }
355 let family = format!("{stripped}-x");
356 if let Some(r) = self.lookup_exact(provider, &family) {
357 return Some(r);
358 }
359 // Recurse so a stripped form like `qwen2.5:14b-2024-11-20` (date
360 // first, then colon tag) still falls through the rest of the
361 // chain.
362 if let Some(r) = self.lookup(provider, &stripped) {
363 return Some(r);
364 }
365 }
366 // 3. colon-stripped (Ollama tag)
367 if let Some((head, _tag)) = model.split_once(':') {
368 if let Some(r) = self.lookup_exact(provider, head) {
369 return Some(r);
370 }
371 // Also try the family-alias rewrite on the head form.
372 if let Some(family) = family_alias(head) {
373 if let Some(r) = self.lookup_exact(provider, &family) {
374 return Some(r);
375 }
376 }
377 }
378 // 4. trailing-digits rewrite (-N → -x)
379 if let Some(family) = family_alias(model) {
380 if let Some(r) = self.lookup_exact(provider, &family) {
381 return Some(r);
382 }
383 }
384 // 5. provider wildcard
385 self.lookup_exact(provider, "*")
386 }
387
388 fn lookup_exact(&self, provider: &str, model: &str) -> Option<Rates> {
389 self.rates
390 .get(&(provider.to_string(), model.to_string()))
391 .copied()
392 }
393}
394
395/// Canonicalize a client-reported provider name to its price-table key. LLM
396/// clients name themselves by wire identity (`gemini`), but the built-in table
397/// keys Google models under `google`.
398fn normalize_provider(provider: &str) -> &str {
399 match provider {
400 "gemini" => "google",
401 other => other,
402 }
403}
404
405#[cfg(test)]
406mod tests;