1use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::metrics::TokenUsage;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ModelRate {
20 pub input_usd_per_1k: f64,
22 pub output_usd_per_1k: f64,
24}
25
26impl ModelRate {
27 pub fn cost(&self, usage: &TokenUsage) -> f64 {
28 let input_cost = (usage.input_tokens as f64 / 1000.0) * self.input_usd_per_1k;
29 let output_cost = (usage.output_tokens as f64 / 1000.0) * self.output_usd_per_1k;
30 input_cost + output_cost
31 }
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36#[serde(default)]
37pub struct PricingConfig {
38 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
41 pub overrides: HashMap<String, ModelRate>,
42}
43
44impl PricingConfig {
45 pub fn rate_for(&self, model_id: &str) -> Option<ModelRate> {
56 if let Some(r) = self.overrides.get(model_id) {
58 return Some(r.clone());
59 }
60
61 let prefix_match = self
63 .overrides
64 .iter()
65 .filter(|(k, _)| model_id.starts_with(k.as_str()))
66 .max_by_key(|(k, _)| k.len());
67 if let Some((_, r)) = prefix_match {
68 return Some(r.clone());
69 }
70
71 builtin_rate(model_id)
73 }
74
75 pub fn estimate_cost(&self, model_id: &str, usage: Option<&TokenUsage>) -> Option<f64> {
78 let usage = usage?;
79 let rate = self.rate_for(model_id)?;
80 Some(rate.cost(usage))
81 }
82}
83
84fn builtin_rate(model_id: &str) -> Option<ModelRate> {
93 let m = model_id.to_lowercase();
94
95 for prefix in &[
97 "gemma",
98 "llama",
99 "qwen",
100 "mistral",
101 "phi",
102 "orca",
103 "codellama",
104 "deepseek",
105 "starcoder",
106 "falcon",
107 "vicuna",
108 "neural-chat",
109 "openhermes",
110 "nous-hermes",
111 "dolphin",
112 "bakllava",
113 "llava",
114 "yi:",
115 "yi-",
116 "zephyr",
117 "tinyllama",
118 ] {
119 if m.starts_with(prefix) {
120 return Some(ModelRate {
121 input_usd_per_1k: 0.0,
122 output_usd_per_1k: 0.0,
123 });
124 }
125 }
126
127 if m.starts_with("gpt-4o-mini") {
129 return Some(ModelRate {
130 input_usd_per_1k: 0.00015,
131 output_usd_per_1k: 0.0006,
132 });
133 }
134 if m.starts_with("gpt-4o") {
135 return Some(ModelRate {
136 input_usd_per_1k: 0.005,
137 output_usd_per_1k: 0.015,
138 });
139 }
140 if m.starts_with("gpt-4-turbo") || m.starts_with("gpt-4") {
141 return Some(ModelRate {
142 input_usd_per_1k: 0.01,
143 output_usd_per_1k: 0.03,
144 });
145 }
146 if m.starts_with("gpt-3.5-turbo") {
147 return Some(ModelRate {
148 input_usd_per_1k: 0.0005,
149 output_usd_per_1k: 0.0015,
150 });
151 }
152 if m.starts_with("o1-mini") {
153 return Some(ModelRate {
154 input_usd_per_1k: 0.003,
155 output_usd_per_1k: 0.012,
156 });
157 }
158 if m.starts_with("o1") {
159 return Some(ModelRate {
160 input_usd_per_1k: 0.015,
161 output_usd_per_1k: 0.06,
162 });
163 }
164
165 if m.contains("claude-3-5-sonnet") || m.contains("claude-3.5-sonnet") {
167 return Some(ModelRate {
168 input_usd_per_1k: 0.003,
169 output_usd_per_1k: 0.015,
170 });
171 }
172 if m.contains("claude-3-5-haiku") || m.contains("claude-3.5-haiku") {
173 return Some(ModelRate {
174 input_usd_per_1k: 0.0008,
175 output_usd_per_1k: 0.004,
176 });
177 }
178 if m.contains("claude-3-opus") {
179 return Some(ModelRate {
180 input_usd_per_1k: 0.015,
181 output_usd_per_1k: 0.075,
182 });
183 }
184 if m.contains("claude-3-sonnet") {
185 return Some(ModelRate {
186 input_usd_per_1k: 0.003,
187 output_usd_per_1k: 0.015,
188 });
189 }
190 if m.contains("claude-3-haiku") {
191 return Some(ModelRate {
192 input_usd_per_1k: 0.00025,
193 output_usd_per_1k: 0.00125,
194 });
195 }
196 if m.contains("claude-2") {
197 return Some(ModelRate {
198 input_usd_per_1k: 0.008,
199 output_usd_per_1k: 0.024,
200 });
201 }
202
203 if m.contains("gemini-1.5-pro") {
205 return Some(ModelRate {
206 input_usd_per_1k: 0.0035,
207 output_usd_per_1k: 0.0105,
208 });
209 }
210 if m.contains("gemini-1.5-flash") {
211 return Some(ModelRate {
212 input_usd_per_1k: 0.000075,
213 output_usd_per_1k: 0.0003,
214 });
215 }
216
217 None
219}
220
221#[cfg(test)]
226mod tests {
227 use super::*;
228
229 fn usage(inp: u32, out: u32) -> TokenUsage {
230 TokenUsage {
231 input_tokens: inp,
232 output_tokens: out,
233 }
234 }
235
236 #[test]
237 fn local_models_are_free() {
238 let cfg = PricingConfig::default();
239 for model in &[
240 "gemma4:e2b",
241 "llama3.1:8b",
242 "qwen2.5-coder:32b",
243 "mistral:7b",
244 ] {
245 let cost = cfg.estimate_cost(model, Some(&usage(1000, 500)));
246 assert_eq!(cost, Some(0.0), "expected free for {model}");
247 }
248 }
249
250 #[test]
251 fn gpt4o_has_cost() {
252 let cfg = PricingConfig::default();
253 let cost = cfg.estimate_cost("gpt-4o", Some(&usage(1000, 500)));
254 assert!(cost.is_some(), "gpt-4o should have a rate");
255 assert!(cost.unwrap() > 0.0);
256 }
257
258 #[test]
259 fn unknown_model_returns_none() {
260 let cfg = PricingConfig::default();
261 let cost = cfg.estimate_cost("some-private-model-v99", Some(&usage(100, 50)));
262 assert!(cost.is_none(), "unknown model should return None");
263 }
264
265 #[test]
266 fn no_usage_returns_none() {
267 let cfg = PricingConfig::default();
268 assert!(cfg.estimate_cost("gpt-4o", None).is_none());
269 }
270
271 #[test]
272 fn override_takes_precedence() {
273 let mut cfg = PricingConfig::default();
274 cfg.overrides.insert(
275 "gemma4:e2b".into(),
276 ModelRate {
277 input_usd_per_1k: 1.0,
278 output_usd_per_1k: 2.0,
279 },
280 );
281 let cost = cfg.estimate_cost("gemma4:e2b", Some(&usage(1000, 1000)));
282 assert!((cost.unwrap() - 3.0).abs() < 0.0001);
284 }
285
286 #[test]
287 fn prefix_override() {
288 let mut cfg = PricingConfig::default();
289 cfg.overrides.insert(
290 "my-model".into(),
291 ModelRate {
292 input_usd_per_1k: 0.5,
293 output_usd_per_1k: 1.0,
294 },
295 );
296 let cost = cfg.estimate_cost("my-model-v2", Some(&usage(2000, 1000)));
297 assert!((cost.unwrap() - 2.0).abs() < 0.0001);
299 }
300
301 #[test]
302 fn claude_models_have_rates() {
303 let cfg = PricingConfig::default();
304 for m in &[
305 "claude-3-5-sonnet-20241022",
306 "claude-3-haiku-20240307",
307 "claude-3-opus-20240229",
308 ] {
309 assert!(
310 cfg.estimate_cost(m, Some(&usage(100, 50))).unwrap() > 0.0,
311 "{m} should have a positive rate"
312 );
313 }
314 }
315
316 #[test]
317 fn pricing_config_toml_roundtrip() {
318 let mut cfg = PricingConfig::default();
319 cfg.overrides.insert(
320 "test-model".into(),
321 ModelRate {
322 input_usd_per_1k: 0.01,
323 output_usd_per_1k: 0.02,
324 },
325 );
326 let s = toml::to_string(&cfg).unwrap();
327 let back: PricingConfig = toml::from_str(&s).unwrap();
328 assert!(back.overrides.contains_key("test-model"));
329 }
330}