Skip to main content

ingot_runtime/
price.rs

1//! What a model call costs, so a `cost` budget can be charged.
2//!
3//! [Runtime 0.1 §8](../../../specs/runtime/v0.1.md) says a backend that can
4//! price a request should enforce `budget.cost`, and one that cannot **must not
5//! pretend to**. This module is the first half; the interpreter is the second.
6//!
7//! # Why the operator supplies the prices
8//!
9//! A price table is provider- and time-dependent, so it cannot live in an
10//! artifact: an artifact carrying one would be stale the moment it was
11//! published, and a reproducible artifact whose meaning changed with the
12//! vendor's price list would not be reproducible at all. It cannot live in this
13//! binary either, for the same reason with a slower clock.
14//!
15//! So it lives where the API keys and the tool servers already live: in the
16//! project manifest, which is deployment configuration the operator owns and
17//! updates. A run with no prices configured charges nothing and says so.
18//!
19//! # Why integers
20//!
21//! Money is decimal and binary floats are not, which is why
22//! [`ingot_ir::Cost`] stores an amount as a decimal string with six fractional
23//! digits. Accumulation here uses the same unit as an integer — millionths of
24//! one currency unit — so a total is exact and identical on every platform.
25//! Nothing in a cost calculation touches an `f64`.
26
27use std::collections::BTreeMap;
28
29use serde::{Deserialize, Serialize};
30
31use crate::provider::Usage;
32
33/// One millionth of a currency unit, which is the precision
34/// [`ingot_ir::format_amount`] already renders.
35pub type Micros = u128;
36
37/// Millionths per whole unit.
38pub const MICROS: Micros = 1_000_000;
39
40/// Prices are quoted per this many tokens, which is how every vendor quotes
41/// them.
42pub const TOKENS_PER_QUOTE: Micros = 1_000_000;
43
44/// What one model costs, as the operator wrote it in the manifest.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields, rename_all = "kebab-case")]
47pub struct ModelPrice {
48    /// The model string **as the provider reports it**, matched exactly.
49    ///
50    /// Not a pattern: a prefix rule would silently price `claude-opus-5-mini`
51    /// at `claude-opus-5`'s rate, and a wrong price is worse than none. The run
52    /// names the unpriced model, so configuring it is a copy and a paste.
53    pub model: String,
54    /// Cost of a million input tokens, as a decimal string.
55    pub input: String,
56    /// Cost of a million output tokens, as a decimal string.
57    pub output: String,
58    /// Cost of a million cached input tokens, when the vendor discounts them.
59    /// Absent means cached input is charged at the `input` rate.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub cache_read: Option<String>,
62    /// The currency these amounts are in, e.g. `usd`.
63    pub currency: String,
64}
65
66/// Every price a run was given.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(transparent)]
69pub struct Pricing {
70    models: Vec<ModelPrice>,
71}
72
73/// What a priced call cost, or why it could not be priced.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum Charge {
76    /// Millionths of `currency`.
77    Priced { micros: Micros, currency: String },
78    /// No price is configured for the model that answered.
79    Unpriced,
80    /// A price exists and is in a different currency than the budget states.
81    /// Converting would need a rate, which is a second time-dependent input the
82    /// toolchain does not have.
83    WrongCurrency { priced_in: String },
84}
85
86impl Pricing {
87    pub fn new(models: Vec<ModelPrice>) -> Pricing {
88        Pricing { models }
89    }
90
91    pub fn is_empty(&self) -> bool {
92        self.models.is_empty()
93    }
94
95    /// Every model this run can price, for a readiness report.
96    pub fn models(&self) -> impl Iterator<Item = &str> {
97        self.models.iter().map(|price| price.model.as_str())
98    }
99
100    /// What `usage` cost on `model`, in the currency the budget is stated in.
101    pub fn charge(&self, model: &str, usage: Usage, budget_currency: &str) -> Charge {
102        let Some(price) = self.models.iter().find(|price| price.model == model) else {
103            return Charge::Unpriced;
104        };
105        if !price.currency.eq_ignore_ascii_case(budget_currency) {
106            return Charge::WrongCurrency {
107                priced_in: price.currency.clone(),
108            };
109        }
110
111        // Cached input is charged at its own rate when the operator gave one,
112        // and at the input rate otherwise — the conservative reading, since a
113        // vendor that does not discount cache reads bills them as input.
114        let uncached = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
115        let cache_rate = price.cache_read.as_deref().unwrap_or(&price.input);
116
117        let mut micros: Micros = 0;
118        for (tokens, rate) in [
119            (uncached, price.input.as_str()),
120            (usage.cache_read_tokens, cache_rate),
121            (usage.output_tokens, price.output.as_str()),
122        ] {
123            let Some(per_quote) = parse_micros(rate) else {
124                return Charge::Unpriced;
125            };
126            micros += Micros::from(tokens) * per_quote / TOKENS_PER_QUOTE;
127        }
128
129        Charge::Priced {
130            micros,
131            currency: price.currency.clone(),
132        }
133    }
134}
135
136/// A decimal string as millionths, or `None` when it is not one.
137///
138/// Integer-only: `"3.5"` is 3_500_000, not a float that might be 3.4999999.
139/// More than six fractional digits is a price this format cannot represent
140/// exactly, so it is refused rather than rounded behind the operator's back.
141pub fn parse_micros(amount: &str) -> Option<Micros> {
142    let amount = amount.trim();
143    if amount.is_empty() || amount.starts_with('-') {
144        return None;
145    }
146    let (whole, fraction) = match amount.split_once('.') {
147        Some((whole, fraction)) => (whole, fraction),
148        None => (amount, ""),
149    };
150    if whole.is_empty() && fraction.is_empty() {
151        return None;
152    }
153    if !whole.chars().all(|ch| ch.is_ascii_digit())
154        || !fraction.chars().all(|ch| ch.is_ascii_digit())
155        || fraction.len() > 6
156    {
157        return None;
158    }
159
160    let whole: Micros = if whole.is_empty() {
161        0
162    } else {
163        whole.parse().ok()?
164    };
165    let mut padded = fraction.to_string();
166    while padded.len() < 6 {
167        padded.push('0');
168    }
169    let fraction: Micros = if padded.is_empty() {
170        0
171    } else {
172        padded.parse().ok()?
173    };
174    whole.checked_mul(MICROS)?.checked_add(fraction)
175}
176
177/// Millionths as the decimal string the IR uses.
178///
179/// The inverse of [`parse_micros`], to six digits with trailing zeros trimmed,
180/// so `4_200` renders as `0.0042` and `5_000_000` as `5`.
181pub fn render_micros(micros: Micros) -> String {
182    let whole = micros / MICROS;
183    let fraction = micros % MICROS;
184    if fraction == 0 {
185        return whole.to_string();
186    }
187    let rendered = format!("{whole}.{fraction:06}");
188    rendered.trim_end_matches('0').to_string()
189}
190
191/// What a run spent, and what it could not price.
192///
193/// Serialisable so an interrupted run can carry its ledger across a stop: a
194/// budget bounds a run, and a run that stopped and continued is one run.
195#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase", default)]
197pub struct Spend {
198    micros: Micros,
199    currency: Option<String>,
200    /// Models that answered and had no usable price. Sorted and deduplicated,
201    /// because the same model answering forty times is one thing to configure.
202    unpriced: BTreeMap<String, String>,
203}
204
205impl Spend {
206    /// Add a charge, remembering an unpriced call rather than skipping it.
207    pub fn add(&mut self, model: &str, charge: Charge) {
208        match charge {
209            Charge::Priced { micros, currency } => {
210                self.micros += micros;
211                self.currency = Some(currency);
212            }
213            Charge::Unpriced => {
214                self.unpriced
215                    .insert(model.to_string(), "no price is configured".to_string());
216            }
217            Charge::WrongCurrency { priced_in } => {
218                self.unpriced.insert(
219                    model.to_string(),
220                    format!("priced in {priced_in}, which the budget is not"),
221                );
222            }
223        }
224    }
225
226    pub fn micros(&self) -> Micros {
227        self.micros
228    }
229
230    /// Whether every call that happened could be priced.
231    ///
232    /// The question a `cost` budget's enforcement depends on: a total that
233    /// missed calls is not a total, and reporting it as one would be the
234    /// pretending [Runtime 0.1 §8](../../../specs/runtime/v0.1.md) forbids.
235    pub fn is_complete(&self) -> bool {
236        self.unpriced.is_empty()
237    }
238
239    /// Model name and why it could not be priced.
240    pub fn unpriced(&self) -> impl Iterator<Item = (&str, &str)> {
241        self.unpriced
242            .iter()
243            .map(|(model, reason)| (model.as_str(), reason.as_str()))
244    }
245
246    /// What was spent, when anything could be priced at all.
247    pub fn rendered(&self) -> Option<String> {
248        let currency = self.currency.as_ref()?;
249        Some(format!(
250            "{} {}",
251            render_micros(self.micros),
252            currency.to_ascii_uppercase()
253        ))
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn usage(input: u64, output: u64, cached: u64) -> Usage {
262        Usage {
263            input_tokens: input,
264            output_tokens: output,
265            cache_read_tokens: cached,
266        }
267    }
268
269    fn opus() -> Pricing {
270        Pricing::new(vec![ModelPrice {
271            model: "claude-opus-5".into(),
272            input: "3".into(),
273            output: "15".into(),
274            cache_read: Some("0.3".into()),
275            currency: "usd".into(),
276        }])
277    }
278
279    #[test]
280    fn a_decimal_string_becomes_exact_millionths() {
281        assert_eq!(parse_micros("3"), Some(3_000_000));
282        assert_eq!(parse_micros("0.3"), Some(300_000));
283        assert_eq!(parse_micros("3.5"), Some(3_500_000));
284        assert_eq!(parse_micros("0.000001"), Some(1));
285        assert_eq!(parse_micros(".5"), Some(500_000));
286    }
287
288    #[test]
289    fn an_amount_this_format_cannot_hold_is_refused_rather_than_rounded() {
290        // Rounding behind the operator's back is how a budget quietly stops
291        // meaning what it says.
292        assert_eq!(parse_micros("0.0000001"), None);
293        assert_eq!(parse_micros("-1"), None);
294        assert_eq!(parse_micros("free"), None);
295        assert_eq!(parse_micros(""), None);
296        assert_eq!(parse_micros("1.2.3"), None);
297    }
298
299    #[test]
300    fn rendering_round_trips_through_the_ir_encoding() {
301        for amount in ["0", "5", "0.25", "0.0042", "1234.567891"] {
302            let micros = parse_micros(amount).expect("a valid amount");
303            assert_eq!(render_micros(micros), amount);
304        }
305    }
306
307    #[test]
308    fn a_call_is_charged_at_the_quoted_rate() {
309        // 1000 input at $3/M and 500 output at $15/M is 0.003 + 0.0075.
310        let charge = opus().charge("claude-opus-5", usage(1000, 500, 0), "usd");
311        assert_eq!(
312            charge,
313            Charge::Priced {
314                micros: 3_000 + 7_500,
315                currency: "usd".into()
316            }
317        );
318        assert_eq!(render_micros(10_500), "0.0105");
319    }
320
321    #[test]
322    fn cached_input_is_charged_at_its_own_rate_when_one_is_given() {
323        // 1000 input of which 800 cached: 200 at $3/M, 800 at $0.3/M.
324        let charge = opus().charge("claude-opus-5", usage(1000, 0, 800), "usd");
325        assert_eq!(
326            charge,
327            Charge::Priced {
328                micros: 600 + 240,
329                currency: "usd".into()
330            }
331        );
332    }
333
334    #[test]
335    fn cached_input_falls_back_to_the_input_rate() {
336        // A vendor that does not discount cache reads bills them as input, so
337        // an absent rate must not become a free one.
338        let pricing = Pricing::new(vec![ModelPrice {
339            model: "m".into(),
340            input: "3".into(),
341            output: "15".into(),
342            cache_read: None,
343            currency: "usd".into(),
344        }]);
345        let charge = pricing.charge("m", usage(1000, 0, 1000), "usd");
346        assert_eq!(
347            charge,
348            Charge::Priced {
349                micros: 3_000,
350                currency: "usd".into()
351            }
352        );
353    }
354
355    #[test]
356    fn an_unknown_model_is_unpriced_rather_than_free() {
357        assert_eq!(
358            opus().charge("claude-opus-5-mini", usage(1000, 500, 0), "usd"),
359            Charge::Unpriced,
360            "a prefix rule would price a different model at this one's rate"
361        );
362        assert_eq!(
363            Pricing::default().charge("anything", usage(1, 1, 0), "usd"),
364            Charge::Unpriced
365        );
366    }
367
368    #[test]
369    fn a_price_in_another_currency_does_not_get_converted() {
370        let charge = opus().charge("claude-opus-5", usage(1000, 0, 0), "eur");
371        assert_eq!(
372            charge,
373            Charge::WrongCurrency {
374                priced_in: "usd".into()
375            },
376            "converting needs a rate, which is a second time-dependent input"
377        );
378    }
379
380    #[test]
381    fn a_spend_that_missed_a_call_is_not_a_total() {
382        let mut spend = Spend::default();
383        spend.add(
384            "claude-opus-5",
385            opus().charge("claude-opus-5", usage(1000, 500, 0), "usd"),
386        );
387        assert!(spend.is_complete());
388        assert_eq!(spend.rendered().as_deref(), Some("0.0105 USD"));
389
390        spend.add("mystery", Charge::Unpriced);
391        assert!(
392            !spend.is_complete(),
393            "a total that missed a call is not a total"
394        );
395        let unpriced: Vec<&str> = spend.unpriced().map(|(model, _)| model).collect();
396        assert_eq!(unpriced, vec!["mystery"]);
397
398        // The same model answering many times is one thing to configure.
399        spend.add("mystery", Charge::Unpriced);
400        assert_eq!(spend.unpriced().count(), 1);
401    }
402}