1use crate::error::{Error, Result};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
14pub struct ModelPrice {
15 pub input_per_mtok: f64,
17 pub output_per_mtok: f64,
19}
20
21impl ModelPrice {
22 #[must_use]
24 pub fn cost(&self, input: u64, output: u64) -> f64 {
25 (input as f64 / 1e6) * self.input_per_mtok + (output as f64 / 1e6) * self.output_per_mtok
26 }
27}
28
29#[derive(Debug, Clone, Default)]
35pub struct PriceTable {
36 prices: HashMap<String, ModelPrice>,
37}
38
39impl PriceTable {
40 #[must_use]
42 pub fn new() -> Self {
43 Self::default()
44 }
45
46 #[must_use]
49 pub fn defaults() -> Self {
50 let mut prices = HashMap::new();
51 let mut put = |k: &str, i: f64, o: f64| {
52 prices.insert(
53 k.to_owned(),
54 ModelPrice {
55 input_per_mtok: i,
56 output_per_mtok: o,
57 },
58 );
59 };
60 put("anthropic/claude-haiku-4-5", 1.0, 5.0);
62 put("anthropic/claude-sonnet-5", 3.0, 15.0);
63 put("anthropic/claude-opus-4-8", 15.0, 75.0);
64 put("openai/gpt-4.1-mini", 0.4, 1.6);
66 put("openai/gpt-5.5", 5.0, 15.0);
67 put("google/gemini-3.1-flash", 0.35, 1.05);
69 put("google/gemini-3.1-pro", 3.5, 10.5);
70 Self { prices }
71 }
72
73 #[must_use]
75 pub fn with_override(mut self, model: impl Into<String>, price: ModelPrice) -> Self {
76 self.prices.insert(model.into(), price);
77 self
78 }
79
80 #[must_use]
82 pub fn get(&self, model: &str) -> Option<ModelPrice> {
83 self.prices.get(model).copied()
84 }
85
86 pub fn cost_usd(&self, model: &str, input: u64, output: u64) -> Result<f64> {
91 self.get(model)
92 .map(|p| p.cost(input, output))
93 .ok_or_else(|| Error::UnknownModel(model.to_owned()))
94 }
95
96 pub fn baseline_usd(&self, top_model: &str, input: u64, output: u64) -> Result<f64> {
105 self.cost_usd(top_model, input, output)
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn cost_math_is_correct() {
115 let p = ModelPrice {
116 input_per_mtok: 3.0,
117 output_per_mtok: 15.0,
118 };
119 assert!((p.cost(1000, 500) - 0.0105).abs() < 1e-12);
121 assert_eq!(p.cost(0, 0), 0.0);
122 }
123
124 #[test]
125 fn table_lookup_and_unknown_model() {
126 let t = PriceTable::defaults();
127 assert!(t.cost_usd("anthropic/claude-haiku-4-5", 1000, 1000).is_ok());
128 match t.cost_usd("acme/nope", 1, 1) {
129 Err(Error::UnknownModel(m)) => assert_eq!(m, "acme/nope"),
130 other => panic!("expected UnknownModel, got {other:?}"),
131 }
132 }
133
134 #[test]
135 fn baseline_exceeds_cheap_rung_for_same_tokens() {
136 let t = PriceTable::defaults();
138 let (i, o) = (2000, 800);
139 let cheap = t.cost_usd("anthropic/claude-haiku-4-5", i, o).unwrap();
140 let baseline = t.baseline_usd("anthropic/claude-opus-4-8", i, o).unwrap();
141 assert!(baseline > cheap);
142 assert!(baseline - cheap > 0.0); }
144
145 #[test]
146 fn overrides_win() {
147 let t = PriceTable::new().with_override(
148 "x/y",
149 ModelPrice {
150 input_per_mtok: 2.0,
151 output_per_mtok: 2.0,
152 },
153 );
154 assert_eq!(t.cost_usd("x/y", 1_000_000, 0).unwrap(), 2.0);
155 }
156}