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}