Skip to main content

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;