1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
5pub struct ModelCost {
6 pub input_per_m: f64,
7 pub output_per_m: f64,
8 pub cache_write_per_m: f64,
9 pub cache_read_per_m: f64,
10}
11
12impl ModelCost {
13 pub fn estimate_usd(&self, input: u64, output: u64, cache_write: u64, cache_read: u64) -> f64 {
14 (input as f64 / 1_000_000.0 * self.input_per_m)
15 + (output as f64 / 1_000_000.0 * self.output_per_m)
16 + (cache_write as f64 / 1_000_000.0 * self.cache_write_per_m)
17 + (cache_read as f64 / 1_000_000.0 * self.cache_read_per_m)
18 }
19}
20
21#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
22pub enum PricingMatchKind {
23 Exact,
24 Alias,
25 Heuristic,
26 Fallback,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ModelQuote {
31 pub model_key: String,
32 pub cost: ModelCost,
33 pub match_kind: PricingMatchKind,
34}
35
36#[derive(Debug, Clone)]
37pub struct ModelPricing {
38 models: HashMap<String, ModelCost>,
39}
40
41impl ModelPricing {
42 pub fn load() -> Self {
43 let mut p = Self::embedded();
44 p.apply_env_override();
45 p
46 }
47
48 pub fn embedded() -> Self {
49 let mut models: HashMap<String, ModelCost> = HashMap::new();
50
51 models.insert(
55 "claude-fable-5".to_string(),
56 ModelCost {
57 input_per_m: 10.00,
58 output_per_m: 50.00,
59 cache_write_per_m: 12.50,
60 cache_read_per_m: 1.00,
61 },
62 );
63 models.insert(
64 "claude-opus-4.5".to_string(),
65 ModelCost {
66 input_per_m: 5.00,
67 output_per_m: 25.00,
68 cache_write_per_m: 6.25,
69 cache_read_per_m: 0.50,
70 },
71 );
72 models.insert(
73 "claude-sonnet-4.5".to_string(),
74 ModelCost {
75 input_per_m: 3.00,
76 output_per_m: 15.00,
77 cache_write_per_m: 3.75,
78 cache_read_per_m: 0.30,
79 },
80 );
81 models.insert(
82 "claude-haiku-4.5".to_string(),
83 ModelCost {
84 input_per_m: 1.00,
85 output_per_m: 5.00,
86 cache_write_per_m: 1.25,
87 cache_read_per_m: 0.10,
88 },
89 );
90 models.insert(
92 "claude-3.5-sonnet".to_string(),
93 ModelCost {
94 input_per_m: 3.00,
95 output_per_m: 15.00,
96 cache_write_per_m: 3.75,
97 cache_read_per_m: 0.30,
98 },
99 );
100 models.insert(
101 "claude-3-opus".to_string(),
102 ModelCost {
103 input_per_m: 15.00,
104 output_per_m: 75.00,
105 cache_write_per_m: 18.75,
106 cache_read_per_m: 1.50,
107 },
108 );
109 models.insert(
110 "claude-3-haiku".to_string(),
111 ModelCost {
112 input_per_m: 0.25,
113 output_per_m: 1.25,
114 cache_write_per_m: 0.30,
115 cache_read_per_m: 0.03,
116 },
117 );
118
119 models.insert(
121 "gpt-5.4".to_string(),
122 ModelCost {
123 input_per_m: 2.50,
124 output_per_m: 15.00,
125 cache_write_per_m: 2.50,
126 cache_read_per_m: 0.25,
127 },
128 );
129 models.insert(
130 "gpt-5.4-mini".to_string(),
131 ModelCost {
132 input_per_m: 0.75,
133 output_per_m: 4.50,
134 cache_write_per_m: 0.75,
135 cache_read_per_m: 0.075,
136 },
137 );
138 models.insert(
139 "gpt-5.4-nano".to_string(),
140 ModelCost {
141 input_per_m: 0.20,
142 output_per_m: 1.25,
143 cache_write_per_m: 0.20,
144 cache_read_per_m: 0.02,
145 },
146 );
147
148 models.insert(
151 "gemini-2.5-pro".to_string(),
152 ModelCost {
153 input_per_m: 1.25,
154 output_per_m: 10.00,
155 cache_write_per_m: 1.25,
156 cache_read_per_m: 1.25,
157 },
158 );
159 models.insert(
160 "gemini-2.5-flash".to_string(),
161 ModelCost {
162 input_per_m: 0.30,
163 output_per_m: 2.50,
164 cache_write_per_m: 0.30,
165 cache_read_per_m: 0.30,
166 },
167 );
168 models.insert(
169 "gemini-2.5-flash-lite".to_string(),
170 ModelCost {
171 input_per_m: 0.10,
172 output_per_m: 0.40,
173 cache_write_per_m: 0.10,
174 cache_read_per_m: 0.10,
175 },
176 );
177
178 models.insert(
185 "phi-4".to_string(),
186 ModelCost {
187 input_per_m: 0.125,
188 output_per_m: 0.50,
189 cache_write_per_m: 0.125,
190 cache_read_per_m: 0.125,
191 },
192 );
193 models.insert(
194 "phi-4-mini".to_string(),
195 ModelCost {
196 input_per_m: 0.075,
197 output_per_m: 0.30,
198 cache_write_per_m: 0.075,
199 cache_read_per_m: 0.075,
200 },
201 );
202 models.insert(
203 "deepseek-v3.2".to_string(),
204 ModelCost {
205 input_per_m: 0.58,
206 output_per_m: 1.68,
207 cache_write_per_m: 0.58,
208 cache_read_per_m: 0.58,
209 },
210 );
211 models.insert(
212 "deepseek-v3".to_string(),
213 ModelCost {
214 input_per_m: 1.14,
215 output_per_m: 4.56,
216 cache_write_per_m: 1.14,
217 cache_read_per_m: 1.14,
218 },
219 );
220 models.insert(
221 "llama-3.3-70b".to_string(),
222 ModelCost {
223 input_per_m: 0.71,
224 output_per_m: 0.71,
225 cache_write_per_m: 0.71,
226 cache_read_per_m: 0.71,
227 },
228 );
229 models.insert(
230 "llama-4-maverick".to_string(),
231 ModelCost {
232 input_per_m: 0.25,
233 output_per_m: 1.00,
234 cache_write_per_m: 0.25,
235 cache_read_per_m: 0.25,
236 },
237 );
238
239 models.insert(
241 "fallback-blended".to_string(),
242 ModelCost {
243 input_per_m: 2.50,
244 output_per_m: 10.00,
245 cache_write_per_m: 2.50,
246 cache_read_per_m: 2.50,
247 },
248 );
249
250 Self { models }
251 }
252
253 pub fn quote(&self, model: Option<&str>) -> ModelQuote {
254 let raw = model.unwrap_or_default();
255 if let Some(k) = Self::infer_model_key(raw)
256 && let Some(cost) = self.models.get(&k).copied()
257 {
258 return ModelQuote {
259 model_key: k,
260 cost,
261 match_kind: PricingMatchKind::Exact,
262 };
263 }
264
265 if let Some((k, kind)) = Self::heuristic_key(raw)
266 && let Some(cost) = self.models.get(&k).copied()
267 {
268 return ModelQuote {
269 model_key: k,
270 cost,
271 match_kind: kind,
272 };
273 }
274
275 let cost = self
276 .models
277 .get("fallback-blended")
278 .copied()
279 .unwrap_or(ModelCost {
280 input_per_m: 2.50,
281 output_per_m: 10.00,
282 cache_write_per_m: 2.50,
283 cache_read_per_m: 2.50,
284 });
285 ModelQuote {
286 model_key: "fallback-blended".to_string(),
287 cost,
288 match_kind: PricingMatchKind::Fallback,
289 }
290 }
291
292 pub fn quote_for_client(&self, client: &str) -> ModelQuote {
298 self.quote(Some(&resolve_model_for_client(client)))
299 }
300
301 pub fn quote_from_env_or_agent_type(&self, agent_type: &str) -> ModelQuote {
304 self.quote_for_client(agent_type)
305 }
306
307 pub fn infer_model_key(model: &str) -> Option<String> {
308 let m = normalize(model);
309 if m.is_empty() {
310 return None;
311 }
312
313 let exact_keys = [
314 "claude-fable-5",
315 "claude-opus-4.5",
316 "claude-sonnet-4.5",
317 "claude-haiku-4.5",
318 "claude-3.5-sonnet",
319 "claude-3-opus",
320 "claude-3-haiku",
321 "gpt-5.4",
322 "gpt-5.4-mini",
323 "gpt-5.4-nano",
324 "gemini-2.5-pro",
325 "gemini-2.5-flash",
326 "gemini-2.5-flash-lite",
327 "phi-4",
328 "phi-4-mini",
329 "deepseek-v3.2",
330 "deepseek-v3",
331 "llama-3.3-70b",
332 "llama-4-maverick",
333 "fallback-blended",
334 ];
335 for k in exact_keys {
336 if m == k {
337 return Some(k.to_string());
338 }
339 }
340 None
341 }
342
343 fn heuristic_key(model: &str) -> Option<(String, PricingMatchKind)> {
344 let m = normalize(model);
345 if m.is_empty() {
346 return None;
347 }
348
349 if m.contains("claude") || m.contains("fable") || m.contains("mythos") {
353 let legacy = m.contains("claude-3");
354 if m.contains("fable") || m.contains("mythos") {
355 return Some(("claude-fable-5".to_string(), PricingMatchKind::Heuristic));
356 }
357 if m.contains("sonnet") {
358 return Some(if legacy {
359 ("claude-3.5-sonnet".to_string(), PricingMatchKind::Heuristic)
360 } else {
361 ("claude-sonnet-4.5".to_string(), PricingMatchKind::Heuristic)
362 });
363 }
364 if m.contains("opus") {
365 return Some(if legacy {
366 ("claude-3-opus".to_string(), PricingMatchKind::Heuristic)
367 } else {
368 ("claude-opus-4.5".to_string(), PricingMatchKind::Heuristic)
369 });
370 }
371 if m.contains("haiku") {
372 return Some(if legacy {
373 ("claude-3-haiku".to_string(), PricingMatchKind::Heuristic)
374 } else {
375 ("claude-haiku-4.5".to_string(), PricingMatchKind::Heuristic)
376 });
377 }
378 }
379
380 if m.contains("gemini") {
381 if m.contains("2.5") && m.contains("pro") {
382 return Some(("gemini-2.5-pro".to_string(), PricingMatchKind::Heuristic));
383 }
384 if m.contains("2.5") && m.contains("flash-lite") {
385 return Some((
386 "gemini-2.5-flash-lite".to_string(),
387 PricingMatchKind::Heuristic,
388 ));
389 }
390 if m.contains("2.5") && m.contains("flash") {
391 return Some(("gemini-2.5-flash".to_string(), PricingMatchKind::Heuristic));
392 }
393 }
394
395 if m.contains("gpt-5.4") && m.contains("mini") {
397 return Some(("gpt-5.4-mini".to_string(), PricingMatchKind::Alias));
398 }
399 if m.contains("gpt-5.4") && m.contains("nano") {
400 return Some(("gpt-5.4-nano".to_string(), PricingMatchKind::Alias));
401 }
402 if m.contains("gpt-5.4") {
403 return Some(("gpt-5.4".to_string(), PricingMatchKind::Alias));
404 }
405 if m.contains("gpt-4o") {
406 return Some(("fallback-blended".to_string(), PricingMatchKind::Heuristic));
407 }
408
409 if m.contains("phi-4") {
413 return Some(if m.contains("mini") {
414 ("phi-4-mini".to_string(), PricingMatchKind::Heuristic)
415 } else {
416 ("phi-4".to_string(), PricingMatchKind::Heuristic)
417 });
418 }
419 if m.contains("deepseek") {
420 return Some(if m.contains("v3.2") {
421 ("deepseek-v3.2".to_string(), PricingMatchKind::Heuristic)
422 } else {
423 ("deepseek-v3".to_string(), PricingMatchKind::Heuristic)
424 });
425 }
426 if m.contains("llama") {
427 return Some(if m.contains("maverick") || m.contains("llama-4") {
428 ("llama-4-maverick".to_string(), PricingMatchKind::Heuristic)
429 } else {
430 ("llama-3.3-70b".to_string(), PricingMatchKind::Heuristic)
431 });
432 }
433
434 None
435 }
436
437 fn apply_env_override(&mut self) {
438 let raw = std::env::var("LEAN_CTX_MODEL_PRICING_JSON")
439 .or_else(|_| std::env::var("LCTX_MODEL_PRICING_JSON"))
440 .ok();
441 let Some(raw) = raw else { return };
442
443 let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
444 return;
445 };
446 let Some(models) = v.get("models").and_then(|m| m.as_object()) else {
447 return;
448 };
449 for (k, vv) in models {
450 let Some(obj) = vv.as_object() else { continue };
451 let input_per_m = obj.get("input_per_m").and_then(serde_json::Value::as_f64);
452 let output_per_m = obj.get("output_per_m").and_then(serde_json::Value::as_f64);
453 if input_per_m.is_none() && output_per_m.is_none() {
454 continue;
455 }
456
457 let key_norm = normalize(k);
458 let base = self.models.get(&key_norm).copied().unwrap_or_else(|| {
459 self.models
460 .get("fallback-blended")
461 .copied()
462 .unwrap_or(ModelCost {
463 input_per_m: 2.50,
464 output_per_m: 10.00,
465 cache_write_per_m: 2.50,
466 cache_read_per_m: 2.50,
467 })
468 });
469
470 let merged = ModelCost {
471 input_per_m: input_per_m.unwrap_or(base.input_per_m),
472 output_per_m: output_per_m.unwrap_or(base.output_per_m),
473 cache_write_per_m: obj
474 .get("cache_write_per_m")
475 .and_then(serde_json::Value::as_f64)
476 .unwrap_or(base.cache_write_per_m),
477 cache_read_per_m: obj
478 .get("cache_read_per_m")
479 .and_then(serde_json::Value::as_f64)
480 .unwrap_or(base.cache_read_per_m),
481 };
482 self.models.insert(key_norm, merged);
483 }
484 }
485}
486
487fn normalize(s: &str) -> String {
488 s.trim().to_lowercase().replace(' ', "-")
489}
490
491fn non_blank(s: &str) -> Option<String> {
492 let t = s.trim();
493 if t.is_empty() {
494 None
495 } else {
496 Some(t.to_string())
497 }
498}
499
500fn resolve_model(client: &str, env_model: Option<&str>, configured: Option<&str>) -> String {
503 env_model
504 .and_then(non_blank)
505 .or_else(|| configured.and_then(non_blank))
506 .unwrap_or_else(|| client.to_string())
507}
508
509pub fn resolve_model_for_client(client: &str) -> String {
514 let env_model = std::env::var("LEAN_CTX_MODEL")
515 .or_else(|_| std::env::var("LCTX_MODEL"))
516 .ok();
517 let configured = crate::core::config::Config::load()
518 .cost
519 .model_for_client(client);
520 resolve_model(client, env_model.as_deref(), configured.as_deref())
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 #[test]
528 fn quote_falls_back() {
529 let p = ModelPricing::embedded();
530 let q = p.quote(Some("unknown-model"));
531 assert_eq!(q.match_kind, PricingMatchKind::Fallback);
532 }
533
534 #[test]
535 fn claude_sonnet_heuristic_maps_to_current_generation() {
536 let p = ModelPricing::embedded();
537 let q = p.quote(Some("claude-4.6-sonnet"));
538 assert!(matches!(
539 q.match_kind,
540 PricingMatchKind::Heuristic | PricingMatchKind::Alias
541 ));
542 assert_eq!(q.model_key, "claude-sonnet-4.5");
543 assert!((q.cost.input_per_m - 3.00).abs() < f64::EPSILON);
544 }
545
546 #[test]
547 fn claude_legacy_names_keep_legacy_pricing() {
548 let p = ModelPricing::embedded();
549 let q = p.quote(Some("claude-3-opus"));
550 assert_eq!(q.model_key, "claude-3-opus");
551 assert!((q.cost.input_per_m - 15.00).abs() < f64::EPSILON);
552 }
553
554 #[test]
555 fn claude_opus_current_generation_is_5_per_m() {
556 let p = ModelPricing::embedded();
557 for name in ["claude-opus-4.8", "claude-4.7-opus", "claude opus"] {
558 let q = p.quote(Some(name));
559 assert_eq!(q.model_key, "claude-opus-4.5", "for {name}");
560 assert!((q.cost.input_per_m - 5.00).abs() < f64::EPSILON);
561 assert!((q.cost.output_per_m - 25.00).abs() < f64::EPSILON);
562 }
563 }
564
565 #[test]
566 fn claude_fable_matches_frontier_tier() {
567 let p = ModelPricing::embedded();
568 let q = p.quote(Some("claude-fable-5-thinking-high"));
569 assert_eq!(q.model_key, "claude-fable-5");
570 assert!((q.cost.input_per_m - 10.00).abs() < f64::EPSILON);
571 }
572
573 #[test]
574 fn foundry_families_map_deployment_names_to_price_keys() {
575 let p = ModelPricing::embedded();
579 for (name, key, input) in [
580 ("Phi-4", "phi-4", 0.125),
581 ("Phi-4-reasoning", "phi-4", 0.125),
582 ("Phi-4-mini-instruct", "phi-4-mini", 0.075),
583 ("DeepSeek-V3-0324", "deepseek-v3", 1.14),
584 ("DeepSeek-V3.2", "deepseek-v3.2", 0.58),
585 ("Llama-3.3-70B-Instruct", "llama-3.3-70b", 0.71),
586 ("Llama-4-Maverick-17B-128E", "llama-4-maverick", 0.25),
587 ] {
588 let q = p.quote(Some(name));
589 assert_eq!(q.model_key, key, "for {name}");
590 assert!(
591 (q.cost.input_per_m - input).abs() < f64::EPSILON,
592 "for {name}"
593 );
594 assert_ne!(q.match_kind, PricingMatchKind::Fallback, "for {name}");
595 }
596 }
597
598 #[test]
599 fn resolve_model_precedence() {
600 assert_eq!(
602 resolve_model("cursor", Some("gpt-5.4"), Some("claude-opus-4.5")),
603 "gpt-5.4"
604 );
605 assert_eq!(
607 resolve_model("cursor", None, Some("claude-opus-4.5")),
608 "claude-opus-4.5"
609 );
610 assert_eq!(
612 resolve_model("claude-haiku-4.5", None, None),
613 "claude-haiku-4.5"
614 );
615 assert_eq!(resolve_model("cursor", Some(" "), Some(" ")), "cursor");
617 }
618}