Skip to main content

layover_core/cost/
rates.rs

1//! Turning token counts into dollars, when the runner will not.
2//!
3//! This is a fallback, never a substitute. Everything it produces is labelled
4//! [`CostSource::RateCard`] so it can never be mistaken for a measured figure — see the module
5//! documentation on [`crate::cost`] for why that distinction is load-bearing.
6
7use jiff::Timestamp;
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12use super::{CostSource, RunCost, TokenUsage};
13use crate::agent::AgentName;
14use crate::flight::{ItineraryId, RunId};
15
16/// What one model costs, in US dollars per million tokens.
17///
18/// Four separate rates rather than one, because providers price cached tokens far below fresh
19/// input — often ten to one — and a single blended rate is wrong by whatever the cache hit rate
20/// happens to be that day.
21#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, Serialize)]
22#[serde(deny_unknown_fields)]
23pub struct ModelRates {
24    /// Dollars per million prompt tokens.
25    #[serde(default)]
26    pub input_usd: f64,
27    /// Dollars per million generated tokens.
28    #[serde(default)]
29    pub output_usd: f64,
30    /// Dollars per million tokens served from the prompt cache.
31    #[serde(default)]
32    pub cache_read_usd: f64,
33    /// Dollars per million tokens written to the prompt cache.
34    #[serde(default)]
35    pub cache_write_usd: f64,
36}
37
38impl ModelRates {
39    /// Returns the cost of `usage`, or `None` if any rate is not a usable number.
40    ///
41    /// A negative or non-finite rate is a configuration mistake, and guessing past it would
42    /// produce a total that looks authoritative and is not.
43    #[must_use]
44    pub fn cost_of(&self, usage: TokenUsage) -> Option<f64> {
45        let rates = [
46            (self.input_usd, usage.input),
47            (self.output_usd, usage.output),
48            (self.cache_read_usd, usage.cache_read),
49            (self.cache_write_usd, usage.cache_write),
50        ];
51
52        let mut total = 0.0;
53        for (rate, tokens) in rates {
54            if !rate.is_finite() || rate < 0.0 {
55                return None;
56            }
57            #[allow(clippy::cast_precision_loss)]
58            let tokens = tokens as f64;
59            total += rate * tokens / 1_000_000.0;
60        }
61
62        total.is_finite().then_some(total)
63    }
64}
65
66/// Published prices, keyed by model identifier.
67///
68/// Deliberately not bundled with Layover. Prices change, they differ per provider and per context
69/// tier, and a stale table baked into a release is exactly how a cost estimate drifts by a factor
70/// of two without anyone noticing. Whoever runs the factory owns this.
71#[derive(Debug, Clone, Default, Deserialize, Serialize)]
72#[serde(transparent)]
73pub struct RateCard {
74    models: BTreeMap<String, ModelRates>,
75}
76
77impl RateCard {
78    /// Creates an empty rate card.
79    #[must_use]
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Adds rates for a model.
85    #[must_use]
86    pub fn with(mut self, model: impl Into<String>, rates: ModelRates) -> Self {
87        self.models.insert(model.into(), rates);
88        self
89    }
90
91    /// Returns the rates for `model`, if any are published.
92    #[must_use]
93    pub fn rates_for(&self, model: &str) -> Option<&ModelRates> {
94        self.models.get(model)
95    }
96
97    /// Returns `true` when no rates are published at all.
98    #[must_use]
99    pub fn is_empty(&self) -> bool {
100        self.models.is_empty()
101    }
102
103    /// Estimates what `usage` cost on `model`.
104    ///
105    /// Returns `None` when the model is unknown or its rates are unusable, which the caller must
106    /// treat as [`CostSource::Unreported`] rather than as zero.
107    #[must_use]
108    pub fn estimate(&self, model: &str, usage: TokenUsage) -> Option<f64> {
109        self.rates_for(model)?.cost_of(usage)
110    }
111
112    /// Builds a [`RunCost`] for a run that reported tokens but no dollars.
113    ///
114    /// Falls back to [`CostSource::Unreported`] — not to a zero that would flatter the totals —
115    /// when there is no model, no published rate, or no usage to price.
116    #[must_use]
117    pub fn price(
118        &self,
119        run: RunId,
120        itinerary: ItineraryId,
121        agent: AgentName,
122        model: Option<String>,
123        usage: TokenUsage,
124    ) -> RunCost {
125        let estimate = model
126            .as_deref()
127            .filter(|_| !usage.is_empty())
128            .and_then(|model| self.estimate(model, usage));
129
130        let (usd, source) = match estimate {
131            Some(usd) => (usd, CostSource::RateCard),
132            None => (0.0, CostSource::Unreported),
133        };
134
135        RunCost {
136            run,
137            itinerary,
138            agent,
139            pipeline: None,
140            model,
141            usage,
142            usd,
143            source,
144            at: Timestamp::now(),
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    /// Anthropic's published Claude Opus pricing, used here as a realistic shape.
154    fn opus() -> ModelRates {
155        ModelRates {
156            input_usd: 5.0,
157            output_usd: 25.0,
158            cache_read_usd: 0.5,
159            cache_write_usd: 6.25,
160        }
161    }
162
163    fn card() -> RateCard {
164        RateCard::new().with("claude-opus-5", opus())
165    }
166
167    fn usage() -> TokenUsage {
168        TokenUsage {
169            input: 1_000_000,
170            output: 1_000_000,
171            cache_read: 1_000_000,
172            cache_write: 1_000_000,
173        }
174    }
175
176    fn priced(card: &RateCard, model: Option<&str>, usage: TokenUsage) -> RunCost {
177        card.price(
178            RunId::generate(),
179            ItineraryId::generate(),
180            "analyst".into(),
181            model.map(ToOwned::to_owned),
182            usage,
183        )
184    }
185
186    #[test]
187    fn a_million_of_each_costs_the_sum_of_the_rates() {
188        let cost = card().estimate("claude-opus-5", usage()).expect("priced");
189
190        assert!((cost - 36.75).abs() < 1e-9, "got {cost}");
191    }
192
193    #[test]
194    fn cached_tokens_are_priced_apart_from_fresh_input() {
195        // Blending them would be wrong by whatever the cache hit rate happens to be.
196        let fresh = card()
197            .estimate(
198                "claude-opus-5",
199                TokenUsage {
200                    input: 1_000_000,
201                    ..TokenUsage::default()
202                },
203            )
204            .expect("priced");
205        let cached = card()
206            .estimate(
207                "claude-opus-5",
208                TokenUsage {
209                    cache_read: 1_000_000,
210                    ..TokenUsage::default()
211                },
212            )
213            .expect("priced");
214
215        assert!((fresh - 5.0).abs() < 1e-9);
216        assert!((cached - 0.5).abs() < 1e-9);
217        assert!(cached < fresh);
218    }
219
220    #[test]
221    fn an_estimate_is_labelled_as_an_estimate() {
222        let cost = priced(&card(), Some("claude-opus-5"), usage());
223
224        assert_eq!(cost.source, CostSource::RateCard);
225        assert!(
226            !cost.source.is_measured(),
227            "an estimate must never count as measured"
228        );
229    }
230
231    #[test]
232    fn an_unknown_model_is_unreported_rather_than_free() {
233        // Returning zero would quietly shrink the bill and make the rail look healthy.
234        let cost = priced(&card(), Some("some-new-model"), usage());
235
236        assert_eq!(cost.source, CostSource::Unreported);
237        assert!((cost.usd - 0.0).abs() < f64::EPSILON);
238    }
239
240    #[test]
241    fn a_run_with_no_model_cannot_be_priced() {
242        assert_eq!(
243            priced(&card(), None, usage()).source,
244            CostSource::Unreported
245        );
246    }
247
248    #[test]
249    fn a_run_with_no_tokens_cannot_be_priced() {
250        assert_eq!(
251            priced(&card(), Some("claude-opus-5"), TokenUsage::default()).source,
252            CostSource::Unreported
253        );
254    }
255
256    #[test]
257    fn a_nonsense_rate_is_refused_rather_than_propagated() {
258        for bad in [f64::NAN, f64::INFINITY, -1.0] {
259            let card = RateCard::new().with(
260                "broken",
261                ModelRates {
262                    output_usd: bad,
263                    ..ModelRates::default()
264                },
265            );
266
267            assert_eq!(
268                card.estimate("broken", usage()),
269                None,
270                "{bad} must not produce a price"
271            );
272        }
273    }
274
275    #[test]
276    fn rates_parse_from_configuration() {
277        let card: RateCard = toml::from_str(
278            r"
279            [claude-opus-5]
280            input_usd = 5.0
281            output_usd = 25.0
282            cache_read_usd = 0.5
283            cache_write_usd = 6.25
284            ",
285        )
286        .expect("a rate card parses");
287
288        assert!(!card.is_empty());
289        assert_eq!(card.rates_for("claude-opus-5"), Some(&opus()));
290    }
291
292    #[test]
293    fn an_unknown_rate_field_is_rejected() {
294        // A typo such as `output_used` would otherwise silently price output at zero.
295        let error = toml::from_str::<RateCard>(
296            r"
297            [claude-opus-5]
298            output_used = 25.0
299            ",
300        );
301
302        assert!(error.is_err());
303    }
304}