1#[derive(Debug, Clone, Copy)]
24pub struct ModelPricing {
25 pub name: &'static str,
26 pub input_per_mtoken: f64,
27 pub output_per_mtoken: f64,
28 pub cache_read_per_mtoken: Option<f64>,
32 pub cache_create_per_mtoken: Option<f64>,
35 #[allow(dead_code)]
38 pub last_verified: &'static str,
39}
40
41const PRICES: &[ModelPricing] = &[
49 ModelPricing {
51 name: "claude-opus-4-8",
52 input_per_mtoken: 15.0,
53 output_per_mtoken: 75.0,
54 cache_read_per_mtoken: Some(1.50),
55 cache_create_per_mtoken: Some(18.75),
56 last_verified: "2026-06-19 (estimate; unverified)",
57 },
58 ModelPricing {
60 name: "claude-opus-4-6",
61 input_per_mtoken: 15.0,
62 output_per_mtoken: 75.0,
63 cache_read_per_mtoken: Some(1.50),
64 cache_create_per_mtoken: Some(18.75),
65 last_verified: "2026-04-15 (estimate; unverified)",
66 },
67 ModelPricing {
68 name: "claude-sonnet-4-6",
69 input_per_mtoken: 3.0,
70 output_per_mtoken: 15.0,
71 cache_read_per_mtoken: Some(0.30),
72 cache_create_per_mtoken: Some(3.75),
73 last_verified: "2026-04-15 (estimate; unverified)",
74 },
75 ModelPricing {
76 name: "claude-haiku-4-5",
77 input_per_mtoken: 1.0,
78 output_per_mtoken: 5.0,
79 cache_read_per_mtoken: Some(0.10),
80 cache_create_per_mtoken: Some(1.25),
81 last_verified: "2026-04-15 (estimate; unverified)",
82 },
83 ModelPricing {
85 name: "gpt-5",
86 input_per_mtoken: 10.0,
87 output_per_mtoken: 30.0,
88 cache_read_per_mtoken: Some(2.50),
89 cache_create_per_mtoken: None,
90 last_verified: "2026-04-15 (estimate; unverified)",
91 },
92 ModelPricing {
93 name: "gpt-5-mini",
94 input_per_mtoken: 0.5,
95 output_per_mtoken: 2.0,
96 cache_read_per_mtoken: Some(0.10),
97 cache_create_per_mtoken: None,
98 last_verified: "2026-04-15 (estimate; unverified)",
99 },
100 ModelPricing {
102 name: "gemini-2-5-pro",
103 input_per_mtoken: 2.50,
104 output_per_mtoken: 15.0,
105 cache_read_per_mtoken: Some(0.625),
106 cache_create_per_mtoken: None,
107 last_verified: "2026-04-15 (estimate; unverified)",
108 },
109 ModelPricing {
110 name: "gemini-2-5-flash",
111 input_per_mtoken: 0.30,
112 output_per_mtoken: 2.50,
113 cache_read_per_mtoken: Some(0.075),
114 cache_create_per_mtoken: None,
115 last_verified: "2026-04-15 (estimate; unverified)",
116 },
117];
118
119#[must_use]
123pub fn lookup(model: &str) -> Option<&'static ModelPricing> {
124 PRICES.iter().find(|p| p.name.eq_ignore_ascii_case(model))
125}
126
127#[must_use]
131pub fn cost_usd(
132 model: Option<&str>,
133 tokens_in: Option<u64>,
134 tokens_out: Option<u64>,
135 cache_read: Option<u64>,
136 cache_create: Option<u64>,
137) -> Option<f64> {
138 let pricing = lookup(model?)?;
139 let has_any = [tokens_in, tokens_out, cache_read, cache_create]
140 .iter()
141 .any(|v| v.is_some_and(|n| n > 0));
142 if !has_any {
143 return None;
144 }
145 #[allow(clippy::cast_precision_loss)]
146 let t_in = tokens_in.unwrap_or(0) as f64;
147 #[allow(clippy::cast_precision_loss)]
148 let t_out = tokens_out.unwrap_or(0) as f64;
149 #[allow(clippy::cast_precision_loss)]
150 let t_cr = cache_read.unwrap_or(0) as f64;
151 #[allow(clippy::cast_precision_loss)]
152 let t_cc = cache_create.unwrap_or(0) as f64;
153 let cost = t_in * pricing.input_per_mtoken
154 + t_out * pricing.output_per_mtoken
155 + t_cr
156 * pricing
157 .cache_read_per_mtoken
158 .unwrap_or(pricing.input_per_mtoken)
159 + t_cc
160 * pricing
161 .cache_create_per_mtoken
162 .unwrap_or(pricing.input_per_mtoken);
163 Some(cost / 1_000_000.0)
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn lookup_finds_known_model_case_insensitive() {
172 assert!(lookup("claude-opus-4-6").is_some());
173 assert!(lookup("Claude-Opus-4-6").is_some());
174 assert!(lookup("CLAUDE-OPUS-4-6").is_some());
175 }
176
177 #[test]
178 fn lookup_returns_none_for_unknown_model() {
179 assert!(lookup("llama-99-ultra").is_none());
180 assert!(lookup("").is_none());
181 }
182
183 #[test]
184 fn cost_unknown_model_returns_none() {
185 assert_eq!(
186 cost_usd(Some("unknown"), Some(100), Some(50), None, None),
187 None
188 );
189 }
190
191 #[test]
192 fn cost_none_model_returns_none() {
193 assert_eq!(cost_usd(None, Some(100), Some(50), None, None), None);
194 }
195
196 #[test]
197 fn cost_zero_tokens_returns_none() {
198 assert_eq!(
201 cost_usd(Some("claude-opus-4-6"), Some(0), Some(0), Some(0), Some(0)),
202 None
203 );
204 assert_eq!(
205 cost_usd(Some("claude-opus-4-6"), None, None, None, None),
206 None
207 );
208 }
209
210 #[test]
211 fn cost_computes_input_plus_output() {
212 let c = cost_usd(
215 Some("claude-opus-4-6"),
216 Some(1_000_000),
217 Some(1_000_000),
218 None,
219 None,
220 )
221 .unwrap();
222 assert!((c - 90.0).abs() < 1e-6, "expected 90.0, got {c}");
223 }
224
225 #[test]
226 fn cost_cache_read_uses_discounted_rate_when_provider_sets_one() {
227 let c = cost_usd(Some("claude-opus-4-6"), None, None, Some(1_000_000), None).unwrap();
230 assert!((c - 1.50).abs() < 1e-6, "expected 1.50, got {c}");
231 }
232
233 #[test]
234 fn cost_falls_back_to_input_rate_when_cache_rate_missing() {
235 let c = cost_usd(Some("gpt-5"), None, None, None, Some(1_000_000)).unwrap();
238 assert!((c - 10.0).abs() < 1e-6, "expected 10.0, got {c}");
239 }
240
241 #[test]
242 fn every_entry_has_last_verified_date() {
243 for p in PRICES {
244 assert!(
245 !p.last_verified.is_empty(),
246 "{} missing last_verified",
247 p.name
248 );
249 }
250 }
251
252 #[test]
253 fn no_duplicate_model_names() {
254 use std::collections::HashSet;
255 let mut seen = HashSet::new();
256 for p in PRICES {
257 assert!(seen.insert(p.name), "duplicate pricing entry: {}", p.name);
258 }
259 }
260
261 #[test]
262 fn claude_opus_4_8_is_priced() {
263 let c = cost_usd(
266 Some("claude-opus-4-8"),
267 Some(1_000_000),
268 Some(1_000_000),
269 None,
270 None,
271 )
272 .expect("claude-opus-4-8 must be in the pricing table");
273 assert!((c - 90.0).abs() < 1e-6, "expected 90.0, got {c}");
274 }
275}