Skip to main content

firstpass_core/
cost.rs

1//! Cost model: per-model token pricing and the counterfactual baseline (SPEC §9.1).
2//!
3//! The whole product claim is "cheapest model that passes," so cost math is load-bearing.
4//! Two numbers matter per trace: what the escalation ladder actually spent, and the
5//! **counterfactual baseline** — what always calling the top rung would have cost. Their
6//! difference is the savings Firstpass proves.
7
8use crate::error::{Error, Result};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Published price of a model, in USD per 1,000,000 tokens.
13#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
14pub struct ModelPrice {
15    /// USD per 1M input (prompt) tokens.
16    pub input_per_mtok: f64,
17    /// USD per 1M output (completion) tokens.
18    pub output_per_mtok: f64,
19}
20
21impl ModelPrice {
22    /// Cost in USD of `input`/`output` tokens at this price.
23    #[must_use]
24    pub fn cost(&self, input: u64, output: u64) -> f64 {
25        (input as f64 / 1e6) * self.input_per_mtok + (output as f64 / 1e6) * self.output_per_mtok
26    }
27}
28
29/// A lookup from `provider/model` to [`ModelPrice`].
30///
31/// ponytail: the embedded [`PriceTable::defaults`] are a calibration knob, not gospel —
32/// list prices drift and enterprise contracts differ. In prod, load overrides from config
33/// via [`PriceTable::with_override`]; the defaults just make the common case work out of the box.
34#[derive(Debug, Clone, Default)]
35pub struct PriceTable {
36    prices: HashMap<String, ModelPrice>,
37}
38
39impl PriceTable {
40    /// An empty table.
41    #[must_use]
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Built-in prices for the common frontier models (USD / 1M tokens, approximate list
47    /// prices — a starting point to be overridden per deployment).
48    #[must_use]
49    pub fn defaults() -> Self {
50        let mut prices = HashMap::new();
51        let mut put = |k: &str, i: f64, o: f64| {
52            prices.insert(
53                k.to_owned(),
54                ModelPrice {
55                    input_per_mtok: i,
56                    output_per_mtok: o,
57                },
58            );
59        };
60        // Anthropic
61        put("anthropic/claude-haiku-4-5", 1.0, 5.0);
62        put("anthropic/claude-sonnet-5", 3.0, 15.0);
63        put("anthropic/claude-opus-4-8", 15.0, 75.0);
64        // OpenAI
65        put("openai/gpt-4.1-mini", 0.4, 1.6);
66        put("openai/gpt-5.5", 5.0, 15.0);
67        // Google
68        put("google/gemini-3.1-flash", 0.35, 1.05);
69        put("google/gemini-3.1-pro", 3.5, 10.5);
70        Self { prices }
71    }
72
73    /// Insert or replace a model's price, returning `self` for chaining.
74    #[must_use]
75    pub fn with_override(mut self, model: impl Into<String>, price: ModelPrice) -> Self {
76        self.prices.insert(model.into(), price);
77        self
78    }
79
80    /// Look up a model's price by its `provider/model` key.
81    #[must_use]
82    pub fn get(&self, model: &str) -> Option<ModelPrice> {
83        self.prices.get(model).copied()
84    }
85
86    /// Cost in USD of a call to `model` with the given token counts.
87    ///
88    /// # Errors
89    /// Returns [`Error::UnknownModel`] if the model has no price entry.
90    pub fn cost_usd(&self, model: &str, input: u64, output: u64) -> Result<f64> {
91        self.get(model)
92            .map(|p| p.cost(input, output))
93            .ok_or_else(|| Error::UnknownModel(model.to_owned()))
94    }
95
96    /// The counterfactual baseline: what the request would have cost had it gone straight to
97    /// `top_model` (the ladder's top rung) with the served token counts.
98    ///
99    /// This is an estimate — the top model might have emitted a different number of tokens —
100    /// but token counts of served output are the fair, auditable proxy the trace records.
101    ///
102    /// # Errors
103    /// Returns [`Error::UnknownModel`] if `top_model` has no price entry.
104    pub fn baseline_usd(&self, top_model: &str, input: u64, output: u64) -> Result<f64> {
105        self.cost_usd(top_model, input, output)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn cost_math_is_correct() {
115        let p = ModelPrice {
116            input_per_mtok: 3.0,
117            output_per_mtok: 15.0,
118        };
119        // 1000 in * $3/M + 500 out * $15/M = 0.003 + 0.0075 = 0.0105
120        assert!((p.cost(1000, 500) - 0.0105).abs() < 1e-12);
121        assert_eq!(p.cost(0, 0), 0.0);
122    }
123
124    #[test]
125    fn table_lookup_and_unknown_model() {
126        let t = PriceTable::defaults();
127        assert!(t.cost_usd("anthropic/claude-haiku-4-5", 1000, 1000).is_ok());
128        match t.cost_usd("acme/nope", 1, 1) {
129            Err(Error::UnknownModel(m)) => assert_eq!(m, "acme/nope"),
130            other => panic!("expected UnknownModel, got {other:?}"),
131        }
132    }
133
134    #[test]
135    fn baseline_exceeds_cheap_rung_for_same_tokens() {
136        // The core value prop, as a math invariant: top rung costs more than the cheap rung.
137        let t = PriceTable::defaults();
138        let (i, o) = (2000, 800);
139        let cheap = t.cost_usd("anthropic/claude-haiku-4-5", i, o).unwrap();
140        let baseline = t.baseline_usd("anthropic/claude-opus-4-8", i, o).unwrap();
141        assert!(baseline > cheap);
142        assert!(baseline - cheap > 0.0); // there are savings to prove
143    }
144
145    #[test]
146    fn overrides_win() {
147        let t = PriceTable::new().with_override(
148            "x/y",
149            ModelPrice {
150                input_per_mtok: 2.0,
151                output_per_mtok: 2.0,
152            },
153        );
154        assert_eq!(t.cost_usd("x/y", 1_000_000, 0).unwrap(), 2.0);
155    }
156}