Skip to main content

supercode_harness/
pricing.rs

1//! BP-7 (catalog §4a "Turn/budget caps" — the *spend* cap; "Per-turn
2//! cost/usage accounting" — the *cost* half): token→dollars on the request
3//! path.
4//!
5//! Until BP-7 the only pricing in the workspace was
6//! [`crate::pricing_ref`], whose own module doc says it is "not used
7//! anywhere on the request path" — which is exactly why the ledger's
8//! `turn-budget-caps` row read "no spend/budget cap at all — no pricing
9//! exists on the request path". This module is that missing piece, and
10//! nothing more: a per-million-token price pair for a model id, resolved
11//! from (1) the config's explicit override
12//! ([`crate::Config::price_input_per_mtok`] /
13//! [`crate::Config::price_output_per_mtok`]) or (2) a small built-in table
14//! of published list prices for the model families the parity presets pin.
15//!
16//! **Deliberately fails closed.** [`resolve`] returns `None` for a model it
17//! cannot price rather than guessing, and [`crate::Agent::new`] refuses to
18//! build an agent that arms `core.max_budget_usd` against an unpriceable
19//! model. A spend cap that silently never bites is worse than no cap: the
20//! caller believes they are protected.
21
22/// Per-million-token prices for one model, in US dollars.
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct ModelPrice {
25    /// Input (prompt) tokens, dollars per million.
26    pub input_per_mtok: f64,
27    /// Output (completion) tokens, dollars per million.
28    pub output_per_mtok: f64,
29}
30
31impl ModelPrice {
32    /// Dollar cost of one round-trip's token counts.
33    ///
34    /// Cached prompt tokens are billed at the full input rate here: the
35    /// provider-reported discount varies per provider and per cache tier,
36    /// and over-reporting cost is the safe direction for a *cap* (a budget
37    /// that stops slightly early never overspends). Named rather than
38    /// silently assumed — see the `per-turn-cost-usage-accounting` ledger
39    /// row's note.
40    pub fn cost_usd(&self, prompt_tokens: u64, completion_tokens: u64) -> f64 {
41        (prompt_tokens as f64 / 1_000_000.0) * self.input_per_mtok
42            + (completion_tokens as f64 / 1_000_000.0) * self.output_per_mtok
43    }
44}
45
46/// Published list prices for the model families the parity presets pin,
47/// matched by SUBSTRING on the model id so the provider-prefixed spellings
48/// (`anthropic/claude-opus-4-8`, `claude-opus-4-8`,
49/// `us.anthropic.claude-opus-4-8-v1:0`) all resolve to the same row.
50///
51/// Longest pattern first — `claude-haiku` must win over a shorter prefix
52/// that would also match.
53const BUILT_IN_PRICES: &[(&str, ModelPrice)] = &[
54    (
55        "claude-opus",
56        ModelPrice {
57            input_per_mtok: crate::pricing_ref::REF_INPUT_PER_MTOK,
58            output_per_mtok: crate::pricing_ref::REF_OUTPUT_PER_MTOK,
59        },
60    ),
61    (
62        "claude-sonnet",
63        ModelPrice {
64            input_per_mtok: 3.00,
65            output_per_mtok: 15.00,
66        },
67    ),
68    (
69        "claude-haiku",
70        ModelPrice {
71            input_per_mtok: 1.00,
72            output_per_mtok: 5.00,
73        },
74    ),
75];
76
77/// The built-in list price for `model`, if this build knows one.
78pub fn built_in(model: &str) -> Option<ModelPrice> {
79    BUILT_IN_PRICES
80        .iter()
81        .find(|(pattern, _)| model.contains(pattern))
82        .map(|(_, price)| *price)
83}
84
85/// Resolve the price to bill `model` at: the explicit config override when
86/// BOTH halves are set, else the built-in table, else `None`.
87///
88/// Both override halves are required together on purpose — an input price
89/// with no output price would silently bill completions at zero.
90pub fn resolve(
91    model: &str,
92    price_input_per_mtok: Option<f64>,
93    price_output_per_mtok: Option<f64>,
94) -> Option<ModelPrice> {
95    match (price_input_per_mtok, price_output_per_mtok) {
96        (Some(input_per_mtok), Some(output_per_mtok)) => Some(ModelPrice {
97            input_per_mtok,
98            output_per_mtok,
99        }),
100        _ => built_in(model),
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn built_in_matches_every_provider_spelling_of_the_same_family() {
110        for spelling in [
111            "anthropic/claude-opus-4-8",
112            "claude-opus-4-8",
113            "us.anthropic.claude-opus-4-8-v1:0",
114        ] {
115            assert_eq!(
116                built_in(spelling).map(|p| p.input_per_mtok),
117                Some(crate::pricing_ref::REF_INPUT_PER_MTOK),
118                "{spelling}"
119            );
120        }
121    }
122
123    #[test]
124    fn haiku_is_not_swallowed_by_a_broader_family_row() {
125        let haiku = built_in("anthropic/claude-haiku-4-5").expect("haiku is priced");
126        let opus = built_in("anthropic/claude-opus-4-8").expect("opus is priced");
127        assert!(haiku.input_per_mtok < opus.input_per_mtok);
128    }
129
130    #[test]
131    fn an_unknown_model_has_no_built_in_price() {
132        assert!(built_in("someone-elses/model-1").is_none());
133    }
134
135    #[test]
136    fn an_override_needs_both_halves_and_wins_over_the_table() {
137        assert_eq!(
138            resolve("anthropic/claude-opus-4-8", Some(1.0), Some(2.0)),
139            Some(ModelPrice {
140                input_per_mtok: 1.0,
141                output_per_mtok: 2.0
142            })
143        );
144        // Half an override falls back to the table rather than billing
145        // completions at zero.
146        assert_eq!(
147            resolve("anthropic/claude-opus-4-8", Some(1.0), None).map(|p| p.output_per_mtok),
148            Some(crate::pricing_ref::REF_OUTPUT_PER_MTOK)
149        );
150        assert!(resolve("someone-elses/model-1", None, Some(2.0)).is_none());
151    }
152
153    #[test]
154    fn cost_is_the_two_rates_summed_over_a_million() {
155        let price = ModelPrice {
156            input_per_mtok: 10.0,
157            output_per_mtok: 100.0,
158        };
159        // 1M input + 0.1M output = $10 + $10.
160        assert!((price.cost_usd(1_000_000, 100_000) - 20.0).abs() < 1e-9);
161        assert_eq!(price.cost_usd(0, 0), 0.0);
162    }
163}