Skip to main content

ironflow_core/
pricing.rs

1//! Unified LLM cost attribution per step and workflow.
2//!
3//! Provides a provider-agnostic pricing interface ([`PricingSource`]) with a
4//! built-in static implementation ([`StaticPricing`]) that covers all supported
5//! model families. Cost is computed into a [`CostBreakdown`] with uncached
6//! prompt, cache read, cache write and completion components, rounded to 6
7//! decimal places. Per-model rates, including prompt-cache rates, are exposed
8//! through [`ModelPricing`].
9//!
10//! The [`spawn_log`] helper emits cost telemetry in a fire-and-forget task so
11//! tracking never blocks step execution.
12//!
13//! # Examples
14//!
15//! ```no_run
16//! use ironflow_core::pricing::{CostBreakdown, PricingSource, StaticPricing};
17//!
18//! let pricing = StaticPricing::new();
19//! let breakdown = CostBreakdown::compute(&pricing, "claude-sonnet-4-6", 1000, 500);
20//! println!("total: ${:.6}", breakdown.total_usd);
21//! ```
22
23use tracing::{info, warn};
24
25/// A source of per-model token pricing.
26///
27/// Implementations return the cost per million tokens (input, output) for a
28/// given model identifier. A `None` return means the model is not in the
29/// catalog; callers should apply a fallback.
30///
31/// # Examples
32///
33/// ```no_run
34/// use ironflow_core::pricing::{PricingSource, StaticPricing};
35///
36/// let pricing = StaticPricing::new();
37/// if let Some((input, output)) = pricing.price_per_1m("claude-sonnet-4-6") {
38///     println!("input: ${input}/Mtok, output: ${output}/Mtok");
39/// }
40/// ```
41pub trait PricingSource: Send + Sync {
42    /// Return `(input_per_1m_usd, output_per_1m_usd)` for the given model.
43    ///
44    /// Returns `None` when the model is not in the catalog.
45    fn price_per_1m(&self, model: &str) -> Option<(f64, f64)>;
46
47    /// Return the full [`ModelPricing`] (including prompt-cache rates) for the
48    /// given model.
49    ///
50    /// The default implementation derives it from
51    /// [`price_per_1m`](PricingSource::price_per_1m) with
52    /// [`ModelPricing::without_cache`], so cache tokens are billed at the
53    /// input rate. Returns `None` when the model is not in the catalog.
54    ///
55    /// # Examples
56    ///
57    /// ```no_run
58    /// use ironflow_core::pricing::{PricingSource, StaticPricing};
59    ///
60    /// let pricing = StaticPricing::new();
61    /// if let Some(p) = pricing.model_pricing("claude-sonnet-4-6") {
62    ///     println!("cache read: ${}/Mtok", p.cache_read_per_1m);
63    /// }
64    /// ```
65    fn model_pricing(&self, model: &str) -> Option<ModelPricing> {
66        self.price_per_1m(model)
67            .map(|(i, o)| ModelPricing::without_cache(i, o))
68    }
69}
70
71/// Per-million-token rates for a single model, in USD.
72///
73/// Separates uncached input, output, prompt-cache reads and prompt-cache
74/// writes, which providers bill at different rates.
75///
76/// # Examples
77///
78/// ```
79/// use ironflow_core::pricing::ModelPricing;
80///
81/// let p = ModelPricing::without_cache(3.0, 15.0);
82/// assert_eq!(p.cache_read_per_1m, 3.0);
83/// assert_eq!(p.cache_write_per_1m, 3.0);
84/// ```
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct ModelPricing {
87    /// Price per million uncached input tokens.
88    pub input_per_1m: f64,
89    /// Price per million output tokens.
90    pub output_per_1m: f64,
91    /// Price per million input tokens served from the prompt cache.
92    pub cache_read_per_1m: f64,
93    /// Price per million input tokens written to the prompt cache.
94    pub cache_write_per_1m: f64,
95}
96
97impl ModelPricing {
98    /// Build pricing for a model without a known cache rate.
99    ///
100    /// Both cache rates are set equal to `input_per_1m`, so costs are never
101    /// underestimated.
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use ironflow_core::pricing::ModelPricing;
107    ///
108    /// let p = ModelPricing::without_cache(0.5, 1.5);
109    /// assert_eq!(p.input_per_1m, 0.5);
110    /// assert_eq!(p.output_per_1m, 1.5);
111    /// assert_eq!(p.cache_read_per_1m, 0.5);
112    /// ```
113    #[must_use]
114    pub fn without_cache(input_per_1m: f64, output_per_1m: f64) -> Self {
115        Self {
116            input_per_1m,
117            output_per_1m,
118            cache_read_per_1m: input_per_1m,
119            cache_write_per_1m: input_per_1m,
120        }
121    }
122}
123
124/// Computed cost for a single LLM call, split into uncached prompt, cache
125/// read, cache write and completion.
126///
127/// All values are in USD, rounded to 6 decimal places.
128///
129/// # Examples
130///
131/// ```no_run
132/// use ironflow_core::pricing::{CostBreakdown, StaticPricing};
133///
134/// let pricing = StaticPricing::new();
135/// let bd = CostBreakdown::compute(&pricing, "claude-sonnet-4-6", 10_000, 2_000);
136/// assert!(bd.total_usd > 0.0);
137/// assert!((bd.total_usd - bd.prompt_usd - bd.completion_usd).abs() < 1e-9);
138/// ```
139#[derive(Debug, Clone, Copy, PartialEq)]
140pub struct CostBreakdown {
141    /// Cost of uncached prompt (input) tokens in USD.
142    pub prompt_usd: f64,
143    /// Cost of input tokens served from the prompt cache in USD.
144    pub cache_read_usd: f64,
145    /// Cost of input tokens written to the prompt cache in USD.
146    pub cache_write_usd: f64,
147    /// Cost of completion (output) tokens in USD.
148    pub completion_usd: f64,
149    /// Total cost in USD: sum of `prompt_usd`, `cache_read_usd`,
150    /// `cache_write_usd` and `completion_usd`.
151    pub total_usd: f64,
152}
153
154/// Round to 6 decimal places.
155fn round6(v: f64) -> f64 {
156    (v * 1_000_000.0).round() / 1_000_000.0
157}
158
159/// Conservative fallback price per million tokens (input, output).
160/// Uses the Claude Sonnet rate ($3/$15), shared across Sonnet 4.5/4.6.
161const SONNET_FALLBACK: (f64, f64) = (3.0, 15.0);
162
163impl CostBreakdown {
164    /// Compute the cost for an LLM call using the given pricing source.
165    ///
166    /// When the model is unknown the fallback Sonnet price is used and a
167    /// warning is logged. The cost is never zero for a non-zero token count.
168    ///
169    /// # Examples
170    ///
171    /// ```no_run
172    /// use ironflow_core::pricing::{CostBreakdown, StaticPricing};
173    ///
174    /// let pricing = StaticPricing::new();
175    /// let bd = CostBreakdown::compute(&pricing, "claude-opus-5", 5000, 1000);
176    /// println!("${:.6}", bd.total_usd);
177    /// ```
178    pub fn compute(
179        source: &dyn PricingSource,
180        model: &str,
181        input_tokens: u64,
182        output_tokens: u64,
183    ) -> Self {
184        Self::compute_with_cache(source, model, input_tokens, 0, 0, output_tokens)
185    }
186
187    /// Compute the cost for an LLM call, pricing prompt-cache tokens separately.
188    ///
189    /// `input_tokens` is the uncached input only. Cache reads and writes are
190    /// billed at the rates returned by [`PricingSource::model_pricing`]. When
191    /// the model is unknown the fallback Sonnet price is used (cache rates
192    /// equal to the input rate) and a warning is logged.
193    ///
194    /// # Examples
195    ///
196    /// ```no_run
197    /// use ironflow_core::pricing::{CostBreakdown, StaticPricing};
198    ///
199    /// let pricing = StaticPricing::new();
200    /// let bd = CostBreakdown::compute_with_cache(
201    ///     &pricing,
202    ///     "claude-sonnet-4-6",
203    ///     1_000,
204    ///     50_000,
205    ///     2_000,
206    ///     500,
207    /// );
208    /// println!("cache read: ${:.6}", bd.cache_read_usd);
209    /// ```
210    pub fn compute_with_cache(
211        source: &dyn PricingSource,
212        model: &str,
213        input_tokens: u64,
214        cache_read_tokens: u64,
215        cache_write_tokens: u64,
216        output_tokens: u64,
217    ) -> Self {
218        let rates = source.model_pricing(model).unwrap_or_else(|| {
219            warn!(
220                model,
221                fallback_input = SONNET_FALLBACK.0,
222                fallback_output = SONNET_FALLBACK.1,
223                "unknown model, falling back to Claude Sonnet pricing"
224            );
225            ModelPricing::without_cache(SONNET_FALLBACK.0, SONNET_FALLBACK.1)
226        });
227
228        let prompt_usd = round6(input_tokens as f64 / 1_000_000.0 * rates.input_per_1m);
229        let cache_read_usd =
230            round6(cache_read_tokens as f64 / 1_000_000.0 * rates.cache_read_per_1m);
231        let cache_write_usd =
232            round6(cache_write_tokens as f64 / 1_000_000.0 * rates.cache_write_per_1m);
233        let completion_usd = round6(output_tokens as f64 / 1_000_000.0 * rates.output_per_1m);
234        let total_usd = round6(prompt_usd + cache_read_usd + cache_write_usd + completion_usd);
235
236        Self {
237            prompt_usd,
238            cache_read_usd,
239            cache_write_usd,
240            completion_usd,
241            total_usd,
242        }
243    }
244}
245
246/// Static, hardcoded pricing table for all supported model families.
247///
248/// Resolution is by substring: the most specific (longest) matching entry
249/// wins. For example, `"claude-sonnet-4-6[1m]"` matches the `"claude-sonnet-4-6"`
250/// entry because the full model string contains that substring.
251///
252/// # Examples
253///
254/// ```no_run
255/// use ironflow_core::pricing::{PricingSource, StaticPricing};
256///
257/// let p = StaticPricing::new();
258/// // Exact match
259/// assert!(p.price_per_1m("claude-opus-5").is_some());
260/// // Substring match (model with [1m] suffix)
261/// assert!(p.price_per_1m("claude-opus-5[1m]").is_some());
262/// ```
263pub struct StaticPricing {
264    entries: Vec<(&'static str, f64, f64)>,
265}
266
267impl StaticPricing {
268    /// Create a new static pricing table with all known models.
269    #[must_use]
270    pub fn new() -> Self {
271        let mut entries = vec![
272            // ── Anthropic ─────────────────────────────────────
273            ("claude-fable-5", 10.0, 50.0),
274            ("claude-fable-5-1", 10.0, 50.0),
275            ("claude-mythos-5", 10.0, 50.0),
276            ("claude-mythos-5-1", 10.0, 50.0),
277            ("claude-opus-5-5", 4.0, 20.0),
278            ("claude-opus-5", 5.0, 25.0),
279            ("claude-sonnet-5", 2.0, 10.0),
280            ("claude-opus-4-8", 5.0, 25.0),
281            ("claude-opus-4-7", 5.0, 25.0),
282            ("claude-sonnet-4-6", 3.0, 15.0),
283            ("claude-opus-4-6", 5.0, 25.0),
284            ("claude-sonnet-4-5", 3.0, 15.0),
285            ("claude-haiku-4-5", 1.0, 5.0),
286            // Aliases used by ClaudeCodeProvider
287            ("sonnet", 3.0, 15.0),
288            ("opus", 5.0, 25.0),
289            ("haiku", 1.0, 5.0),
290            // ── OpenAI ────────────────────────────────────────
291            ("gpt-5.5", 5.0, 30.0),
292            ("gpt-5.4-mini", 0.75, 4.5),
293            ("gpt-5.4-nano", 0.20, 1.25),
294            ("gpt-5.4", 2.5, 15.0),
295            ("gpt-4.1-mini", 0.40, 1.60),
296            ("gpt-4.1-nano", 0.10, 0.40),
297            ("gpt-4.1", 2.0, 8.0),
298            ("gpt-4o-mini", 0.15, 0.60),
299            ("gpt-4o", 2.5, 10.0),
300            // ── Mistral ───────────────────────────────────────
301            ("mistral-medium-3.5", 1.5, 7.5),
302            ("mistral-large", 0.50, 1.50),
303            ("mistral-small", 0.10, 0.30),
304            ("mistral-medium", 1.0, 3.0),
305            ("codestral", 0.30, 0.90),
306            // ── Google Gemini ─────────────────────────────────
307            ("gemini-3.5-flash", 0.15, 0.60),
308            ("gemini-3.1-flash-lite", 0.05, 0.20),
309            ("gemini-2.5-pro", 1.25, 10.0),
310            ("gemini-2.5-flash", 0.15, 0.60),
311            ("gemini-2.5-flash-lite", 0.05, 0.20),
312            // ── NVIDIA NIM ────────────────────────────────────
313            ("nemotron-nano-9b", 0.04, 0.16),
314            ("nemotron-super-49b", 0.10, 0.40),
315            ("nemotron-ultra-253b", 0.90, 0.90),
316        ];
317        // Sort by key length descending for longest-match-first resolution.
318        entries.sort_by_key(|e| std::cmp::Reverse(e.0.len()));
319        Self { entries }
320    }
321}
322
323impl Default for StaticPricing {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329/// Return `(read_multiplier, write_multiplier)` applied to the input rate for
330/// prompt-cache tokens, keyed by the matched pricing table entry.
331///
332/// Returns `None` for model families without a known cache rate.
333fn cache_multipliers(key: &str) -> Option<(f64, f64)> {
334    if key.starts_with("claude-") || matches!(key, "sonnet" | "opus" | "haiku") {
335        Some((0.1, 1.25))
336    } else if key.starts_with("gpt-5") {
337        Some((0.1, 1.0))
338    } else if key.starts_with("gpt-4.1") {
339        Some((0.25, 1.0))
340    } else if key.starts_with("gpt-4o") {
341        Some((0.5, 1.0))
342    } else if key.starts_with("gemini-") {
343        Some((0.25, 1.0))
344    } else {
345        None
346    }
347}
348
349impl StaticPricing {
350    /// Find the most specific table entry matching `model`.
351    fn find_entry(&self, model: &str) -> Option<&(&'static str, f64, f64)> {
352        self.entries.iter().find(|(key, _, _)| model.contains(key))
353    }
354}
355
356impl PricingSource for StaticPricing {
357    fn price_per_1m(&self, model: &str) -> Option<(f64, f64)> {
358        self.find_entry(model).map(|(_, inp, out)| (*inp, *out))
359    }
360
361    fn model_pricing(&self, model: &str) -> Option<ModelPricing> {
362        let (key, input, output) = *self.find_entry(model)?;
363        Some(match cache_multipliers(key) {
364            Some((read_mult, write_mult)) => ModelPricing {
365                input_per_1m: input,
366                output_per_1m: output,
367                cache_read_per_1m: input * read_mult,
368                cache_write_per_1m: input * write_mult,
369            },
370            None => ModelPricing::without_cache(input, output),
371        })
372    }
373}
374
375/// Emit a cost log line in a fire-and-forget tokio task.
376///
377/// The spawned task logs the cost breakdown via [`tracing::info!`] and never
378/// blocks the calling step. Errors in the task are silently absorbed.
379///
380/// # Examples
381///
382/// ```no_run
383/// use ironflow_core::pricing::{CostBreakdown, StaticPricing, spawn_log};
384///
385/// let pricing = StaticPricing::new();
386/// let bd = CostBreakdown::compute(&pricing, "claude-opus-5", 5000, 1000);
387/// spawn_log("my-step", "claude-opus-5", bd);
388/// ```
389pub fn spawn_log(step_name: &str, model: &str, breakdown: CostBreakdown) {
390    let step = step_name.to_string();
391    let model = model.to_string();
392    tokio::spawn(async move {
393        info!(
394            step = %step,
395            model = %model,
396            prompt_usd = breakdown.prompt_usd,
397            cache_read_usd = breakdown.cache_read_usd,
398            cache_write_usd = breakdown.cache_write_usd,
399            completion_usd = breakdown.completion_usd,
400            total_usd = breakdown.total_usd,
401            "agent step cost"
402        );
403    });
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn known_model_returns_correct_price() {
412        let pricing = StaticPricing::new();
413        let price = pricing.price_per_1m("claude-opus-5");
414        assert_eq!(price, Some((5.0, 25.0)));
415    }
416
417    #[test]
418    fn sonnet_5_and_opus_5_5_have_current_pricing() {
419        let pricing = StaticPricing::new();
420        assert_eq!(pricing.price_per_1m("claude-sonnet-5"), Some((2.0, 10.0)));
421        assert_eq!(pricing.price_per_1m("claude-opus-5-5"), Some((4.0, 20.0)));
422        assert_eq!(
423            pricing.price_per_1m("claude-mythos-5-1"),
424            Some((10.0, 50.0))
425        );
426    }
427
428    #[test]
429    fn substring_resolution_most_specific_wins() {
430        let pricing = StaticPricing::new();
431        // "claude-sonnet-4-6[1m]" contains "claude-sonnet-4-6" (17 chars)
432        // and also "sonnet" (6 chars). The longer key wins.
433        let price = pricing.price_per_1m("claude-sonnet-4-6[1m]");
434        assert_eq!(price, Some((3.0, 15.0)));
435
436        // "gpt-4.1-mini" contains "gpt-4.1-mini" (12 chars) and "gpt-4.1" (7 chars).
437        // The longer key wins.
438        let price = pricing.price_per_1m("gpt-4.1-mini");
439        assert_eq!(price, Some((0.40, 1.60)));
440    }
441
442    #[test]
443    fn unknown_model_falls_back_to_sonnet() {
444        let pricing = StaticPricing::new();
445        // Unknown model -> None from price_per_1m
446        assert!(pricing.price_per_1m("totally-unknown-model-xyz").is_none());
447
448        // CostBreakdown::compute applies the fallback
449        let bd =
450            CostBreakdown::compute(&pricing, "totally-unknown-model-xyz", 1_000_000, 1_000_000);
451        // Sonnet fallback: $3/Mtok input, $15/Mtok output
452        assert_eq!(bd.prompt_usd, 3.0);
453        assert_eq!(bd.completion_usd, 15.0);
454        assert_eq!(bd.total_usd, 18.0);
455    }
456
457    #[test]
458    fn cost_breakdown_rounds_to_six_decimals() {
459        let pricing = StaticPricing::new();
460        // 7 input tokens at $5/Mtok = 0.000035 (exact)
461        // 3 output tokens at $25/Mtok = 0.000075 (exact)
462        let bd = CostBreakdown::compute(&pricing, "claude-opus-5", 7, 3);
463        assert_eq!(bd.prompt_usd, 0.000035);
464        assert_eq!(bd.completion_usd, 0.000075);
465        assert_eq!(bd.total_usd, 0.00011);
466
467        // 1 input token at $3/Mtok = 0.000003 (exact)
468        // 1 output token at $15/Mtok = 0.000015 (exact)
469        let bd = CostBreakdown::compute(&pricing, "claude-sonnet-4-6", 1, 1);
470        assert_eq!(bd.prompt_usd, 0.000003);
471        assert_eq!(bd.completion_usd, 0.000015);
472        assert_eq!(bd.total_usd, 0.000018);
473
474        // Test rounding: 3 input tokens at $1.25/Mtok = 0.00000375 -> 0.000004
475        let bd = CostBreakdown::compute(&pricing, "gemini-2.5-pro", 3, 0);
476        assert_eq!(bd.prompt_usd, 0.000004);
477    }
478
479    #[test]
480    fn zero_tokens_returns_zero_cost() {
481        let pricing = StaticPricing::new();
482        let bd = CostBreakdown::compute(&pricing, "claude-opus-5", 0, 0);
483        assert_eq!(bd.prompt_usd, 0.0);
484        assert_eq!(bd.completion_usd, 0.0);
485        assert_eq!(bd.total_usd, 0.0);
486    }
487
488    #[tokio::test]
489    async fn spawn_log_does_not_block() {
490        use std::time::Duration;
491
492        tokio::time::timeout(Duration::from_secs(5), async {
493            let pricing = StaticPricing::new();
494            let bd = CostBreakdown::compute(&pricing, "claude-opus-5", 1000, 500);
495            spawn_log("test-step", "claude-opus-5", bd);
496            tokio::task::yield_now().await;
497        })
498        .await
499        .expect("spawn_log timed out");
500    }
501
502    #[test]
503    fn all_known_models_have_prices() {
504        let pricing = StaticPricing::new();
505        let models = [
506            "claude-fable-5",
507            "claude-fable-5-1",
508            "claude-mythos-5",
509            "claude-mythos-5-1",
510            "claude-opus-5-5",
511            "claude-opus-5",
512            "claude-sonnet-5",
513            "claude-opus-4-8",
514            "claude-opus-4-7",
515            "claude-sonnet-4-6",
516            "claude-opus-4-6",
517            "claude-sonnet-4-5",
518            "claude-haiku-4-5",
519            "sonnet",
520            "opus",
521            "haiku",
522            "gpt-5.5",
523            "gpt-5.4",
524            "gpt-4.1",
525            "gpt-4o",
526            "gpt-4o-mini",
527            "mistral-large",
528            "mistral-small",
529            "codestral",
530            "gemini-2.5-pro",
531            "gemini-2.5-flash",
532            "nemotron-nano-9b",
533            "nemotron-super-49b",
534            "nemotron-ultra-253b",
535        ];
536        for model in models {
537            assert!(
538                pricing.price_per_1m(model).is_some(),
539                "missing price for {model}"
540            );
541        }
542    }
543
544    #[test]
545    fn entries_sorted_by_length_descending() {
546        let pricing = StaticPricing::new();
547        for window in pricing.entries.windows(2) {
548            assert!(
549                window[0].0.len() >= window[1].0.len(),
550                "entries not sorted: {:?} before {:?}",
551                window[0].0,
552                window[1].0
553            );
554        }
555    }
556
557    #[test]
558    fn default_matches_new() {
559        let a = StaticPricing::new();
560        let b = StaticPricing::default();
561        assert_eq!(a.entries.len(), b.entries.len());
562        for (ea, eb) in a.entries.iter().zip(b.entries.iter()) {
563            assert_eq!(ea.0, eb.0);
564            assert_eq!(ea.1, eb.1);
565            assert_eq!(ea.2, eb.2);
566        }
567    }
568
569    #[test]
570    fn cost_breakdown_with_large_token_counts() {
571        let pricing = StaticPricing::new();
572        // 1M input tokens at $5/Mtok = $5.0
573        // 500K output tokens at $25/Mtok = $12.5
574        let bd = CostBreakdown::compute(&pricing, "claude-opus-5", 1_000_000, 500_000);
575        assert_eq!(bd.prompt_usd, 5.0);
576        assert_eq!(bd.completion_usd, 12.5);
577        assert_eq!(bd.total_usd, 17.5);
578    }
579
580    fn assert_close(actual: f64, expected: f64) {
581        assert!(
582            (actual - expected).abs() < 1e-9,
583            "expected {expected}, got {actual}"
584        );
585    }
586
587    #[test]
588    fn pricing_cache_read_rate_is_ten_percent_for_claude() {
589        let pricing = StaticPricing::new();
590        let p = pricing.model_pricing("claude-sonnet-4-6").unwrap();
591        assert_close(p.input_per_1m, 3.0);
592        assert_close(p.output_per_1m, 15.0);
593        assert_close(p.cache_read_per_1m, 0.3);
594        assert_close(p.cache_write_per_1m, 3.75);
595
596        let alias = pricing.model_pricing("sonnet").unwrap();
597        assert_close(alias.cache_read_per_1m, 0.3);
598    }
599
600    #[test]
601    fn pricing_unknown_cache_rate_defaults_to_input() {
602        let pricing = StaticPricing::new();
603        let p = pricing.model_pricing("mistral-large").unwrap();
604        assert_close(p.cache_read_per_1m, 0.50);
605        assert_close(p.cache_write_per_1m, 0.50);
606    }
607
608    #[test]
609    fn pricing_cache_read_breakdown_total_includes_four_parts() {
610        let pricing = StaticPricing::new();
611        let bd = CostBreakdown::compute_with_cache(
612            &pricing,
613            "claude-opus-5",
614            1_000_000,
615            1_000_000,
616            1_000_000,
617            1_000_000,
618        );
619        assert_eq!(bd.prompt_usd, 5.0);
620        assert_eq!(bd.cache_read_usd, 0.5);
621        assert_eq!(bd.cache_write_usd, 6.25);
622        assert_eq!(bd.completion_usd, 25.0);
623        assert_eq!(bd.total_usd, 36.75);
624    }
625
626    #[test]
627    fn pricing_compute_without_cache_matches_legacy() {
628        let pricing = StaticPricing::new();
629        let bd = CostBreakdown::compute(&pricing, "claude-opus-5", 1_000_000, 500_000);
630        assert_eq!(bd.cache_read_usd, 0.0);
631        assert_eq!(bd.cache_write_usd, 0.0);
632        assert_eq!(
633            bd,
634            CostBreakdown::compute_with_cache(&pricing, "claude-opus-5", 1_000_000, 0, 0, 500_000)
635        );
636    }
637
638    #[test]
639    fn pricing_unknown_model_cache_fallback() {
640        let pricing = StaticPricing::new();
641        assert!(pricing.model_pricing("totally-unknown-model-xyz").is_none());
642        let bd = CostBreakdown::compute_with_cache(
643            &pricing,
644            "totally-unknown-model-xyz",
645            0,
646            1_000_000,
647            1_000_000,
648            0,
649        );
650        // Sonnet fallback input rate applies to both cache components.
651        assert_eq!(bd.cache_read_usd, 3.0);
652        assert_eq!(bd.cache_write_usd, 3.0);
653        assert_eq!(bd.total_usd, 6.0);
654    }
655
656    #[test]
657    fn pricing_openai_cache_read_rate() {
658        let pricing = StaticPricing::new();
659        let p = pricing.model_pricing("gpt-4.1").unwrap();
660        assert_close(p.cache_read_per_1m, 0.5);
661        assert_close(p.cache_write_per_1m, 2.0);
662
663        let gpt5 = pricing.model_pricing("gpt-5.4").unwrap();
664        assert_close(gpt5.cache_read_per_1m, 0.25);
665
666        let gpt4o = pricing.model_pricing("gpt-4o").unwrap();
667        assert_close(gpt4o.cache_read_per_1m, 1.25);
668
669        let gemini = pricing.model_pricing("gemini-2.5-pro").unwrap();
670        assert_close(gemini.cache_read_per_1m, 0.3125);
671    }
672
673    #[test]
674    fn pricing_default_model_pricing_impl_without_cache() {
675        struct FlatPricing;
676
677        impl PricingSource for FlatPricing {
678            fn price_per_1m(&self, model: &str) -> Option<(f64, f64)> {
679                (model == "flat").then_some((2.0, 8.0))
680            }
681        }
682
683        let p = FlatPricing.model_pricing("flat").unwrap();
684        assert_eq!(p, ModelPricing::without_cache(2.0, 8.0));
685        assert_eq!(p.cache_read_per_1m, 2.0);
686        assert_eq!(p.cache_write_per_1m, 2.0);
687        assert!(FlatPricing.model_pricing("other").is_none());
688    }
689
690    #[test]
691    fn sonnet_fallback_never_zero() {
692        let pricing = StaticPricing::new();
693        // Even 1 token should produce a non-zero cost via fallback
694        let bd = CostBreakdown::compute(&pricing, "unknown-model", 1, 0);
695        assert!(bd.total_usd > 0.0);
696    }
697}