Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

316 linesCodeBlameRaw
1//! What a model's tokens cost at list price, for estimating what a
2//! harness session cost from the token usage it reports.
3//!
4//! The prices are Anthropic's first-party list prices in US dollars per
5//! million tokens, from the pricing page of Anthropic's API documentation,
6//! checked on 2026-10-06. Writing to the prompt cache costs 1.25 times the input price
7//! for the 5-minute lifetime and twice the input price for the 1-hour one,
8//! on every model. Any other model, another provider's included, is priced
9//! as unknown: its cost is not estimated.
10//!
11//! A session reports its usage as a list of [`ModelUsage`], one per model
12//! (and tier) it used, cumulative for the session. [`parse_usage`] reads
13//! and checks one as given; [`estimate`] prices it.
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18/// A model's list prices, in US dollars per million tokens.
19#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct ModelPrice {
21 pub input: f64,
22 pub output: f64,
23 /// Reading from the prompt cache.
24 pub cache_read: f64,
25}
26
27impl ModelPrice {
28 /// Writing to the prompt cache for 5 minutes: 1.25 times input.
29 pub fn cache_write_5m(&self) -> f64 {
30 self.input * 1.25
31 }
32
33 /// Writing to the prompt cache for 1 hour: twice input.
34 pub fn cache_write_1h(&self) -> f64 {
35 self.input * 2.0
36 }
37}
38
39const fn price(input: f64, output: f64, cache_read: f64) -> ModelPrice {
40 ModelPrice { input, output, cache_read }
41}
42
43/// The tier of claude-haiku-5-5 for a request whose prompt (input, cache
44/// read and cache write tokens) is over [`LONG_PROMPT_TOKENS`].
45pub const LONG_TIER: &str = "long";
46
47/// The prompt size above which claude-haiku-5-5 is priced as [`LONG_TIER`].
48pub const LONG_PROMPT_TOKENS: u64 = 100_000;
49
50/// Every model priced, by its id: from Anthropic's pricing documentation,
51/// checked on 2026-10-06. claude-haiku-5-5 has a second row for its
52/// [`LONG_TIER`].
53pub const MODEL_PRICES: &[(&str, Option<&str>, ModelPrice)] = &[
54 ("claude-fable-5-1", None, price(10.0, 50.0, 0.25)),
55 ("claude-mythos-5-1", None, price(10.0, 50.0, 0.25)),
56 ("claude-fable-5", None, price(10.0, 50.0, 1.00)),
57 ("claude-mythos-5", None, price(10.0, 50.0, 1.00)),
58 ("claude-opus-5-5", None, price(4.0, 20.0, 0.20)),
59 ("claude-opus-5", None, price(5.0, 25.0, 0.50)),
60 ("claude-opus-4-8", None, price(5.0, 25.0, 0.50)),
61 ("claude-opus-4-7", None, price(5.0, 25.0, 0.50)),
62 ("claude-opus-4-6", None, price(5.0, 25.0, 0.50)),
63 ("claude-sonnet-5-5", None, price(2.0, 10.0, 0.20)),
64 ("claude-sonnet-5", None, price(2.0, 10.0, 0.20)),
65 ("claude-sonnet-4-6", None, price(3.0, 15.0, 0.30)),
66 ("claude-haiku-5-5", None, price(0.10, 0.50, 0.01)),
67 ("claude-haiku-5-5", Some(LONG_TIER), price(0.50, 2.50, 0.05)),
68 ("claude-haiku-4-5", None, price(1.0, 5.0, 0.10)),
69];
70
71/// A model id as the price table names it: lowercase, without a provider
72/// prefix (`anthropic.`, `us.anthropic.`), a context suffix (`[1m]`), a
73/// version suffix (`-v1:0`) or a date (`-20251001`).
74pub fn model_key(model: &str) -> String {
75 let mut key = model.trim().to_ascii_lowercase();
76 if let Some(at) = key.find("anthropic.") {
77 key = key[at + "anthropic.".len()..].to_owned();
78 }
79 if let Some(at) = key.find('[') {
80 key.truncate(at);
81 }
82 if let Some(at) = key.find(':') {
83 key.truncate(at);
84 }
85 loop {
86 let Some((head, tail)) = key.rsplit_once('-') else { break };
87 let date = tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit());
88 let version = tail.len() >= 2 && tail.starts_with('v') && tail[1..].bytes().all(|b| b.is_ascii_digit());
89 if !(date || version) {
90 break;
91 }
92 key = head.to_owned();
93 }
94 key
95}
96
97/// A model's list prices for `tier`, or none for a model not in the table.
98/// A tier the model has no row for is priced as its base row.
99pub fn model_price(model: &str, tier: Option<&str>) -> Option<ModelPrice> {
100 let key = model_key(model);
101 let rows = || MODEL_PRICES.iter().filter(|(id, _, _)| *id == key);
102 rows()
103 .find(|(_, row_tier, _)| tier.is_some() && *row_tier == tier)
104 .or_else(|| rows().find(|(_, row_tier, _)| row_tier.is_none()))
105 .map(|(_, _, price)| *price)
106}
107
108/// What one model (and tier) of a session used, in tokens, as its harness
109/// reported it.
110#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
111pub struct ModelUsage {
112 pub model: String,
113 /// `long` for a claude-haiku-5-5 prompt over 100,000 tokens; none otherwise.
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub tier: Option<String>,
116 #[serde(default)]
117 pub input_tokens: u64,
118 #[serde(default)]
119 pub output_tokens: u64,
120 #[serde(default)]
121 pub cache_write_5m_tokens: u64,
122 #[serde(default)]
123 pub cache_write_1h_tokens: u64,
124 #[serde(default)]
125 pub cache_read_tokens: u64,
126}
127
128impl ModelUsage {
129 /// What it cost at list price in US dollars, or none for a model not
130 /// in the table.
131 pub fn cost_usd(&self) -> Option<f64> {
132 let price = model_price(&self.model, self.tier.as_deref())?;
133 let tokens = |count: u64, per_million: f64| count as f64 * per_million;
134 Some(
135 (tokens(self.input_tokens, price.input)
136 + tokens(self.output_tokens, price.output)
137 + tokens(self.cache_write_5m_tokens, price.cache_write_5m())
138 + tokens(self.cache_write_1h_tokens, price.cache_write_1h())
139 + tokens(self.cache_read_tokens, price.cache_read))
140 / 1_000_000.0,
141 )
142 }
143
144 fn tokens(&self) -> u64 {
145 self.input_tokens
146 .saturating_add(self.output_tokens)
147 .saturating_add(self.cache_write_5m_tokens)
148 .saturating_add(self.cache_write_1h_tokens)
149 .saturating_add(self.cache_read_tokens)
150 }
151}
152
153/// A session's usage priced: the sum over the models priced (none when no
154/// model is), and whether some of it was for models priced as unknown.
155#[derive(Clone, Copy, Debug, Default, PartialEq)]
156pub struct CostEstimate {
157 pub cost_usd: Option<f64>,
158 pub partial: bool,
159}
160
161/// Prices a session's usage. A model with no tokens counts for nothing.
162pub fn estimate(usage: &[ModelUsage]) -> CostEstimate {
163 let mut out = CostEstimate::default();
164 for one in usage.iter().filter(|one| one.tokens() > 0) {
165 match one.cost_usd() {
166 Some(cost) => out.cost_usd = Some(out.cost_usd.unwrap_or(0.0) + cost),
167 None => out.partial = true,
168 }
169 }
170 out
171}
172
173/// The most models one session's usage names.
174pub const MAX_USAGE_MODELS: usize = 50;
175/// The longest model id and tier kept.
176pub const MAX_USAGE_MODEL_CHARS: usize = 100;
177pub const MAX_USAGE_TIER_CHARS: usize = 20;
178/// The most tokens one count may be: far beyond any session.
179pub const MAX_USAGE_TOKENS: u64 = 1_000_000_000_000;
180
181const COUNTS: [&str; 5] = ["input_tokens", "output_tokens", "cache_write_5m_tokens", "cache_write_1h_tokens", "cache_read_tokens"];
182
183/// A session's usage as given (a list of `{ model, tier?, input_tokens,
184/// output_tokens, cache_write_5m_tokens, cache_write_1h_tokens,
185/// cache_read_tokens }`), checked: at most [`MAX_USAGE_MODELS`] models,
186/// each named, every count a whole number from 0 to [`MAX_USAGE_TOKENS`].
187/// The same model and tier given twice are added together.
188pub fn parse_usage(value: &Value) -> Result<Vec<ModelUsage>, String> {
189 let Value::Array(rows) = value else {
190 return Err("usage is a list of { model, tier, input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens }.".to_owned());
191 };
192 if rows.len() > MAX_USAGE_MODELS {
193 return Err(format!("usage names at most {MAX_USAGE_MODELS} models."));
194 }
195 let mut out: Vec<ModelUsage> = Vec::new();
196 for row in rows {
197 let Value::Object(fields) = row else {
198 return Err("Each usage is an object with model and its token counts.".to_owned());
199 };
200 let model = fields.get("model").and_then(Value::as_str).map(str::trim).unwrap_or_default();
201 if model.is_empty() || model.chars().count() > MAX_USAGE_MODEL_CHARS {
202 return Err(format!("Each usage names its model, in up to {MAX_USAGE_MODEL_CHARS} characters."));
203 }
204 let tier = match fields.get("tier") {
205 None | Some(Value::Null) => None,
206 Some(Value::String(tier)) if tier.trim().is_empty() => None,
207 Some(Value::String(tier)) if tier.trim().chars().count() <= MAX_USAGE_TIER_CHARS => Some(tier.trim().to_owned()),
208 Some(_) => return Err(format!("A usage's tier is text, up to {MAX_USAGE_TIER_CHARS} characters.")),
209 };
210 let mut counts = [0u64; 5];
211 for (count, name) in counts.iter_mut().zip(COUNTS) {
212 *count = match fields.get(name) {
213 None | Some(Value::Null) => 0,
214 Some(value) => match value.as_u64() {
215 Some(n) if n <= MAX_USAGE_TOKENS => n,
216 _ => return Err(format!("{name} is a whole number of tokens from 0 to {MAX_USAGE_TOKENS}, not {value}.")),
217 },
218 };
219 }
220 let [input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens] = counts;
221 let one = ModelUsage { model: model.to_owned(), tier, input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens };
222 match out.iter_mut().find(|known| known.model == one.model && known.tier == one.tier) {
223 Some(known) => {
224 known.input_tokens = known.input_tokens.saturating_add(one.input_tokens);
225 known.output_tokens = known.output_tokens.saturating_add(one.output_tokens);
226 known.cache_write_5m_tokens = known.cache_write_5m_tokens.saturating_add(one.cache_write_5m_tokens);
227 known.cache_write_1h_tokens = known.cache_write_1h_tokens.saturating_add(one.cache_write_1h_tokens);
228 known.cache_read_tokens = known.cache_read_tokens.saturating_add(one.cache_read_tokens);
229 }
230 None => out.push(one),
231 }
232 }
233 Ok(out)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use serde_json::json;
240
241 fn close(a: f64, b: f64) -> bool {
242 (a - b).abs() < 1e-9
243 }
244
245 #[test]
246 fn the_table_pins_list_prices() {
247 assert_eq!(model_price("claude-opus-5-5", None), Some(price(4.0, 20.0, 0.20)));
248 assert_eq!(model_price("claude-fable-5-1", None), Some(price(10.0, 50.0, 0.25)));
249 assert_eq!(model_price("claude-fable-5", None), Some(price(10.0, 50.0, 1.00)));
250 assert_eq!(model_price("claude-sonnet-4-6", None), Some(price(3.0, 15.0, 0.30)));
251 assert_eq!(model_price("claude-haiku-5-5", None), Some(price(0.10, 0.50, 0.01)));
252 assert_eq!(model_price("claude-haiku-5-5", Some("long")), Some(price(0.50, 2.50, 0.05)));
253 assert_eq!(model_price("claude-haiku-4-5", None), Some(price(1.0, 5.0, 0.10)));
254 let opus = model_price("claude-opus-4-7", None).unwrap();
255 assert!(close(opus.cache_write_5m(), 6.25) && close(opus.cache_write_1h(), 10.0));
256 }
257
258 #[test]
259 fn a_model_is_found_without_its_date_context_or_provider() {
260 assert_eq!(model_key("claude-haiku-4-5-20251001"), "claude-haiku-4-5");
261 assert_eq!(model_key("claude-opus-5-5[1m]"), "claude-opus-5-5");
262 assert_eq!(model_key("us.anthropic.claude-sonnet-4-6-20260101-v1:0"), "claude-sonnet-4-6");
263 assert_eq!(model_key("anthropic.claude-opus-4-8"), "claude-opus-4-8");
264 assert!(model_price("Claude-Opus-5-5[1M]", None).is_some());
265 // A tier a model has no row for is its base price.
266 assert_eq!(model_price("claude-opus-5-5", Some("long")), model_price("claude-opus-5-5", None));
267 }
268
269 #[test]
270 fn other_models_are_priced_as_unknown() {
271 for model in ["gpt-5.1-codex", "claude-opus-3", "o3", "<synthetic>", "claude-opus-5-5x"] {
272 assert_eq!(model_price(model, None), None, "{model}");
273 }
274 }
275
276 #[test]
277 fn a_sessions_cost_is_the_sum_of_the_models_priced() {
278 let usage = vec![
279 ModelUsage { model: "claude-opus-5-5".into(), input_tokens: 1_000_000, output_tokens: 100_000, cache_write_5m_tokens: 200_000, cache_write_1h_tokens: 100_000, cache_read_tokens: 10_000_000, ..Default::default() },
280 ModelUsage { model: "claude-haiku-5-5".into(), tier: Some("long".into()), input_tokens: 2_000_000, ..Default::default() },
281 ];
282 // Opus 5.5: 4 + 2 + 1 (5m: 0.2M × 5) + 0.8 (1h: 0.1M × 8) + 2 = 9.8; Haiku 5.5 long: 2M × 0.5 = 1.
283 let priced = estimate(&usage);
284 assert!(close(priced.cost_usd.unwrap(), 10.8), "{priced:?}");
285 assert!(!priced.partial);
286 let mixed = [usage.clone(), vec![ModelUsage { model: "gpt-5".into(), input_tokens: 5, ..Default::default() }]].concat();
287 let priced = estimate(&mixed);
288 assert!(close(priced.cost_usd.unwrap(), 10.8) && priced.partial);
289 let unknown = estimate(&[ModelUsage { model: "gpt-5".into(), output_tokens: 1, ..Default::default() }]);
290 assert_eq!(unknown, CostEstimate { cost_usd: None, partial: true });
291 assert_eq!(estimate(&[ModelUsage { model: "gpt-5".into(), ..Default::default() }]), CostEstimate::default());
292 }
293
294 #[test]
295 fn usage_is_checked_as_given() {
296 let usage = parse_usage(&json!([
297 { "model": "claude-opus-5-5", "input_tokens": 3, "output_tokens": 4 },
298 { "model": "claude-opus-5-5", "input_tokens": 1, "tier": null },
299 { "model": "claude-haiku-5-5", "tier": "long", "cache_read_tokens": 9 },
300 ]))
301 .unwrap();
302 assert_eq!(usage.len(), 2);
303 assert_eq!(usage[0].input_tokens, 4);
304 assert_eq!(usage[1].tier.as_deref(), Some("long"));
305 assert!(parse_usage(&json!({})).is_err());
306 assert!(parse_usage(&json!([{ "input_tokens": 1 }])).is_err());
307 assert!(parse_usage(&json!([{ "model": "m", "input_tokens": -1 }])).is_err());
308 assert!(parse_usage(&json!([{ "model": "m", "input_tokens": 1.5 }])).is_err());
309 assert!(parse_usage(&json!([{ "model": "m", "input_tokens": "7" }])).is_err());
310 assert!(parse_usage(&json!([{ "model": "m", "output_tokens": MAX_USAGE_TOKENS + 1 }])).is_err());
311 assert!(parse_usage(&json!([{ "model": "x".repeat(101) }])).is_err());
312 let many: Vec<Value> = (0..51).map(|i| json!({ "model": format!("m{i}") })).collect();
313 assert!(parse_usage(&Value::Array(many)).is_err());
314 assert_eq!(parse_usage(&json!([])).unwrap(), vec![]);
315 }
316}