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 Live,
27 Alias,
28 Heuristic,
29 Fallback,
30}
31
32impl PricingMatchKind {
33 #[must_use]
36 pub fn is_estimated(self) -> bool {
37 !matches!(self, Self::Exact | Self::Live)
38 }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ModelQuote {
43 pub model_key: String,
44 pub cost: ModelCost,
45 pub match_kind: PricingMatchKind,
46}
47
48#[derive(Debug, Clone)]
49pub struct ModelPricing {
50 models: HashMap<String, ModelCost>,
51}
52
53impl ModelPricing {
54 pub fn load() -> Self {
55 let mut p = Self::embedded();
56 p.apply_config_overrides(&crate::core::config::Config::load().cost.prices);
57 p.apply_env_override();
58 p
59 }
60
61 pub fn embedded() -> Self {
62 let mut models: HashMap<String, ModelCost> = HashMap::new();
63
64 models.insert(
68 "claude-fable-5".to_string(),
69 ModelCost {
70 input_per_m: 10.00,
71 output_per_m: 50.00,
72 cache_write_per_m: 12.50,
73 cache_read_per_m: 1.00,
74 },
75 );
76 models.insert(
77 "claude-opus-4.5".to_string(),
78 ModelCost {
79 input_per_m: 5.00,
80 output_per_m: 25.00,
81 cache_write_per_m: 6.25,
82 cache_read_per_m: 0.50,
83 },
84 );
85 models.insert(
86 "claude-sonnet-4.5".to_string(),
87 ModelCost {
88 input_per_m: 3.00,
89 output_per_m: 15.00,
90 cache_write_per_m: 3.75,
91 cache_read_per_m: 0.30,
92 },
93 );
94 models.insert(
95 "claude-haiku-4.5".to_string(),
96 ModelCost {
97 input_per_m: 1.00,
98 output_per_m: 5.00,
99 cache_write_per_m: 1.25,
100 cache_read_per_m: 0.10,
101 },
102 );
103 models.insert(
105 "claude-3.5-sonnet".to_string(),
106 ModelCost {
107 input_per_m: 3.00,
108 output_per_m: 15.00,
109 cache_write_per_m: 3.75,
110 cache_read_per_m: 0.30,
111 },
112 );
113 models.insert(
114 "claude-3-opus".to_string(),
115 ModelCost {
116 input_per_m: 15.00,
117 output_per_m: 75.00,
118 cache_write_per_m: 18.75,
119 cache_read_per_m: 1.50,
120 },
121 );
122 models.insert(
123 "claude-3-haiku".to_string(),
124 ModelCost {
125 input_per_m: 0.25,
126 output_per_m: 1.25,
127 cache_write_per_m: 0.30,
128 cache_read_per_m: 0.03,
129 },
130 );
131
132 models.insert(
134 "gpt-5.4".to_string(),
135 ModelCost {
136 input_per_m: 2.50,
137 output_per_m: 15.00,
138 cache_write_per_m: 2.50,
139 cache_read_per_m: 0.25,
140 },
141 );
142 models.insert(
143 "gpt-5.4-mini".to_string(),
144 ModelCost {
145 input_per_m: 0.75,
146 output_per_m: 4.50,
147 cache_write_per_m: 0.75,
148 cache_read_per_m: 0.075,
149 },
150 );
151 models.insert(
152 "gpt-5.4-nano".to_string(),
153 ModelCost {
154 input_per_m: 0.20,
155 output_per_m: 1.25,
156 cache_write_per_m: 0.20,
157 cache_read_per_m: 0.02,
158 },
159 );
160
161 models.insert(
164 "gemini-2.5-pro".to_string(),
165 ModelCost {
166 input_per_m: 1.25,
167 output_per_m: 10.00,
168 cache_write_per_m: 1.25,
169 cache_read_per_m: 1.25,
170 },
171 );
172 models.insert(
173 "gemini-2.5-flash".to_string(),
174 ModelCost {
175 input_per_m: 0.30,
176 output_per_m: 2.50,
177 cache_write_per_m: 0.30,
178 cache_read_per_m: 0.30,
179 },
180 );
181 models.insert(
182 "gemini-2.5-flash-lite".to_string(),
183 ModelCost {
184 input_per_m: 0.10,
185 output_per_m: 0.40,
186 cache_write_per_m: 0.10,
187 cache_read_per_m: 0.10,
188 },
189 );
190
191 models.insert(
198 "phi-4".to_string(),
199 ModelCost {
200 input_per_m: 0.125,
201 output_per_m: 0.50,
202 cache_write_per_m: 0.125,
203 cache_read_per_m: 0.125,
204 },
205 );
206 models.insert(
207 "phi-4-mini".to_string(),
208 ModelCost {
209 input_per_m: 0.075,
210 output_per_m: 0.30,
211 cache_write_per_m: 0.075,
212 cache_read_per_m: 0.075,
213 },
214 );
215 models.insert(
216 "deepseek-v3.2".to_string(),
217 ModelCost {
218 input_per_m: 0.58,
219 output_per_m: 1.68,
220 cache_write_per_m: 0.58,
221 cache_read_per_m: 0.58,
222 },
223 );
224 models.insert(
225 "deepseek-v3".to_string(),
226 ModelCost {
227 input_per_m: 1.14,
228 output_per_m: 4.56,
229 cache_write_per_m: 1.14,
230 cache_read_per_m: 1.14,
231 },
232 );
233 models.insert(
234 "llama-3.3-70b".to_string(),
235 ModelCost {
236 input_per_m: 0.71,
237 output_per_m: 0.71,
238 cache_write_per_m: 0.71,
239 cache_read_per_m: 0.71,
240 },
241 );
242 models.insert(
243 "llama-4-maverick".to_string(),
244 ModelCost {
245 input_per_m: 0.25,
246 output_per_m: 1.00,
247 cache_write_per_m: 0.25,
248 cache_read_per_m: 0.25,
249 },
250 );
251
252 models.insert(
254 "fallback-blended".to_string(),
255 ModelCost {
256 input_per_m: 2.50,
257 output_per_m: 10.00,
258 cache_write_per_m: 2.50,
259 cache_read_per_m: 2.50,
260 },
261 );
262
263 Self { models }
264 }
265
266 pub fn rows(&self) -> Vec<(&str, ModelCost)> {
269 self.models.iter().map(|(k, v)| (k.as_str(), *v)).collect()
270 }
271
272 pub fn quote(&self, model: Option<&str>) -> ModelQuote {
273 let raw = model.unwrap_or_default();
274 let m = normalize(raw);
279 if !m.is_empty() {
280 for k in [m.clone(), dot_versions(&m)] {
281 if let Some(cost) = self.models.get(&k).copied() {
282 return ModelQuote {
283 model_key: k,
284 cost,
285 match_kind: PricingMatchKind::Exact,
286 };
287 }
288 }
289 }
290
291 if let Some((k, cost)) = super::live_pricing::lookup(raw) {
296 return ModelQuote {
297 model_key: k,
298 cost,
299 match_kind: PricingMatchKind::Live,
300 };
301 }
302
303 if let Some((k, kind)) = Self::heuristic_key(raw)
304 && let Some(cost) = self.models.get(&k).copied()
305 {
306 return ModelQuote {
307 model_key: k,
308 cost,
309 match_kind: kind,
310 };
311 }
312
313 let cost = self
314 .models
315 .get("fallback-blended")
316 .copied()
317 .unwrap_or(ModelCost {
318 input_per_m: 2.50,
319 output_per_m: 10.00,
320 cache_write_per_m: 2.50,
321 cache_read_per_m: 2.50,
322 });
323 ModelQuote {
324 model_key: "fallback-blended".to_string(),
325 cost,
326 match_kind: PricingMatchKind::Fallback,
327 }
328 }
329
330 pub fn quote_for_client(&self, client: &str) -> ModelQuote {
336 self.quote(Some(&resolve_model_for_client(client)))
337 }
338
339 pub fn quote_from_env_or_agent_type(&self, agent_type: &str) -> ModelQuote {
342 self.quote_for_client(agent_type)
343 }
344
345 fn heuristic_key(model: &str) -> Option<(String, PricingMatchKind)> {
346 let m = normalize(model);
347 if m.is_empty() {
348 return None;
349 }
350
351 if m.contains("claude") || m.contains("fable") || m.contains("mythos") {
355 let legacy = m.contains("claude-3");
356 if m.contains("fable") || m.contains("mythos") {
357 return Some(("claude-fable-5".to_string(), PricingMatchKind::Heuristic));
358 }
359 if m.contains("sonnet") {
360 return Some(if legacy {
361 ("claude-3.5-sonnet".to_string(), PricingMatchKind::Heuristic)
362 } else {
363 ("claude-sonnet-4.5".to_string(), PricingMatchKind::Heuristic)
364 });
365 }
366 if m.contains("opus") {
367 return Some(if legacy {
368 ("claude-3-opus".to_string(), PricingMatchKind::Heuristic)
369 } else {
370 ("claude-opus-4.5".to_string(), PricingMatchKind::Heuristic)
371 });
372 }
373 if m.contains("haiku") {
374 return Some(if legacy {
375 ("claude-3-haiku".to_string(), PricingMatchKind::Heuristic)
376 } else {
377 ("claude-haiku-4.5".to_string(), PricingMatchKind::Heuristic)
378 });
379 }
380 }
381
382 if m.contains("gemini") {
383 if m.contains("2.5") && m.contains("pro") {
384 return Some(("gemini-2.5-pro".to_string(), PricingMatchKind::Heuristic));
385 }
386 if m.contains("2.5") && m.contains("flash-lite") {
387 return Some((
388 "gemini-2.5-flash-lite".to_string(),
389 PricingMatchKind::Heuristic,
390 ));
391 }
392 if m.contains("2.5") && m.contains("flash") {
393 return Some(("gemini-2.5-flash".to_string(), PricingMatchKind::Heuristic));
394 }
395 }
396
397 if m.contains("gpt-5.4") && m.contains("mini") {
399 return Some(("gpt-5.4-mini".to_string(), PricingMatchKind::Alias));
400 }
401 if m.contains("gpt-5.4") && m.contains("nano") {
402 return Some(("gpt-5.4-nano".to_string(), PricingMatchKind::Alias));
403 }
404 if m.contains("gpt-5.4") {
405 return Some(("gpt-5.4".to_string(), PricingMatchKind::Alias));
406 }
407 if m.contains("gpt-4o") {
408 return Some(("fallback-blended".to_string(), PricingMatchKind::Heuristic));
409 }
410
411 if m.contains("phi-4") {
415 return Some(if m.contains("mini") {
416 ("phi-4-mini".to_string(), PricingMatchKind::Heuristic)
417 } else {
418 ("phi-4".to_string(), PricingMatchKind::Heuristic)
419 });
420 }
421 if m.contains("deepseek") {
422 return Some(if m.contains("v3.2") {
423 ("deepseek-v3.2".to_string(), PricingMatchKind::Heuristic)
424 } else {
425 ("deepseek-v3".to_string(), PricingMatchKind::Heuristic)
426 });
427 }
428 if m.contains("llama") {
429 return Some(if m.contains("maverick") || m.contains("llama-4") {
430 ("llama-4-maverick".to_string(), PricingMatchKind::Heuristic)
431 } else {
432 ("llama-3.3-70b".to_string(), PricingMatchKind::Heuristic)
433 });
434 }
435
436 None
437 }
438
439 fn apply_config_overrides(
446 &mut self,
447 prices: &std::collections::HashMap<String, crate::core::config::PriceOverride>,
448 ) {
449 for (model, o) in prices {
450 if o.input_per_m.is_none()
451 && o.output_per_m.is_none()
452 && o.cache_write_per_m.is_none()
453 && o.cache_read_per_m.is_none()
454 {
455 continue;
456 }
457 let key = normalize(model);
458 if key.is_empty() {
459 continue;
460 }
461 let base = self.models.get(&key).copied();
462 let input = o
463 .input_per_m
464 .or(base.map(|b| b.input_per_m))
465 .unwrap_or(2.50);
466 let merged = ModelCost {
467 input_per_m: input,
468 output_per_m: o
469 .output_per_m
470 .or(base.map(|b| b.output_per_m))
471 .unwrap_or(10.00),
472 cache_write_per_m: o
473 .cache_write_per_m
474 .or(base.map(|b| b.cache_write_per_m))
475 .unwrap_or(input),
476 cache_read_per_m: o
477 .cache_read_per_m
478 .or(base.map(|b| b.cache_read_per_m))
479 .unwrap_or(input),
480 };
481 self.models.insert(key, merged);
482 }
483 }
484
485 fn apply_env_override(&mut self) {
486 let raw = std::env::var("LEAN_CTX_MODEL_PRICING_JSON")
487 .or_else(|_| std::env::var("LCTX_MODEL_PRICING_JSON"))
488 .ok();
489 let Some(raw) = raw else { return };
490
491 let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
492 return;
493 };
494 let Some(models) = v.get("models").and_then(|m| m.as_object()) else {
495 return;
496 };
497 for (k, vv) in models {
498 let Some(obj) = vv.as_object() else { continue };
499 let input_per_m = obj.get("input_per_m").and_then(serde_json::Value::as_f64);
500 let output_per_m = obj.get("output_per_m").and_then(serde_json::Value::as_f64);
501 if input_per_m.is_none() && output_per_m.is_none() {
502 continue;
503 }
504
505 let key_norm = normalize(k);
506 let base = self.models.get(&key_norm).copied().unwrap_or_else(|| {
507 self.models
508 .get("fallback-blended")
509 .copied()
510 .unwrap_or(ModelCost {
511 input_per_m: 2.50,
512 output_per_m: 10.00,
513 cache_write_per_m: 2.50,
514 cache_read_per_m: 2.50,
515 })
516 });
517
518 let merged = ModelCost {
519 input_per_m: input_per_m.unwrap_or(base.input_per_m),
520 output_per_m: output_per_m.unwrap_or(base.output_per_m),
521 cache_write_per_m: obj
522 .get("cache_write_per_m")
523 .and_then(serde_json::Value::as_f64)
524 .unwrap_or(base.cache_write_per_m),
525 cache_read_per_m: obj
526 .get("cache_read_per_m")
527 .and_then(serde_json::Value::as_f64)
528 .unwrap_or(base.cache_read_per_m),
529 };
530 self.models.insert(key_norm, merged);
531 }
532 }
533}
534
535fn normalize(s: &str) -> String {
536 s.trim().to_lowercase().replace(' ', "-")
537}
538
539fn dot_versions(s: &str) -> String {
543 let b = s.as_bytes();
544 let mut out = String::with_capacity(s.len());
545 for (i, &c) in b.iter().enumerate() {
546 if c == b'-'
547 && i > 0
548 && b[i - 1].is_ascii_digit()
549 && b.get(i + 1).is_some_and(u8::is_ascii_digit)
550 {
551 out.push('.');
552 } else {
553 out.push(c as char);
554 }
555 }
556 out
557}
558
559fn non_blank(s: &str) -> Option<String> {
560 let t = s.trim();
561 if t.is_empty() {
562 None
563 } else {
564 Some(t.to_string())
565 }
566}
567
568fn resolve_model(client: &str, env_model: Option<&str>, configured: Option<&str>) -> String {
571 env_model
572 .and_then(non_blank)
573 .or_else(|| configured.and_then(non_blank))
574 .unwrap_or_else(|| client.to_string())
575}
576
577pub fn resolve_model_for_client(client: &str) -> String {
582 let env_model = std::env::var("LEAN_CTX_MODEL")
583 .or_else(|_| std::env::var("LCTX_MODEL"))
584 .ok();
585 let configured = crate::core::config::Config::load()
586 .cost
587 .model_for_client(client);
588 resolve_model(client, env_model.as_deref(), configured.as_deref())
589}
590
591#[cfg(test)]
592pub mod tests {
593 use super::*;
594
595 #[test]
596 fn quote_falls_back() {
597 let p = ModelPricing::embedded();
598 let q = p.quote(Some("unknown-model"));
599 assert_eq!(q.match_kind, PricingMatchKind::Fallback);
600 }
601
602 #[test]
603 fn live_price_beats_heuristic_but_not_embedded_exact() {
604 let _lock = crate::core::data_dir::test_env_lock();
607 crate::core::gain::live_pricing::install(crate::core::gain::live_pricing::LivePriceTable {
608 fetched_at: 1,
609 models: [
610 (
611 "zzz-test/live-only-model".to_string(),
612 ModelCost {
613 input_per_m: 0.07,
614 output_per_m: 0.28,
615 cache_write_per_m: 0.07,
616 cache_read_per_m: 0.007,
617 },
618 ),
619 (
620 "claude-sonnet-4-5".to_string(),
621 ModelCost {
622 input_per_m: 999.0,
623 output_per_m: 999.0,
624 cache_write_per_m: 999.0,
625 cache_read_per_m: 999.0,
626 },
627 ),
628 ]
629 .into_iter()
630 .collect(),
631 });
632
633 let p = ModelPricing::embedded();
634 let live = p.quote(Some("zzz-test/live-only-model"));
635 assert_eq!(live.match_kind, PricingMatchKind::Live);
636 assert!(
637 !live.match_kind.is_estimated(),
638 "live is market data, not a guess"
639 );
640 assert!((live.cost.input_per_m - 0.07).abs() < 1e-9);
641
642 let exact = p.quote(Some("claude-sonnet-4.5"));
644 assert_eq!(exact.match_kind, PricingMatchKind::Exact);
645 assert!((exact.cost.input_per_m - 3.00).abs() < f64::EPSILON);
646
647 crate::core::gain::live_pricing::clear_for_tests();
648 let after = p.quote(Some("zzz-test/live-only-model"));
649 assert_eq!(
650 after.match_kind,
651 PricingMatchKind::Fallback,
652 "no snapshot → fallback"
653 );
654 }
655
656 #[test]
657 fn config_price_overrides_are_exact_and_beat_embedded_rows() {
658 let mut p = ModelPricing::embedded();
661 let overrides: std::collections::HashMap<String, crate::core::config::PriceOverride> = [
662 (
663 "internal-llm".to_string(),
664 crate::core::config::PriceOverride {
665 input_per_m: Some(0.10),
666 output_per_m: Some(0.40),
667 ..Default::default()
668 },
669 ),
670 (
671 "claude-opus-4.5".to_string(),
672 crate::core::config::PriceOverride {
673 input_per_m: Some(4.00), ..Default::default()
675 },
676 ),
677 (
678 "empty-row".to_string(),
679 crate::core::config::PriceOverride::default(),
680 ),
681 ]
682 .into();
683 p.apply_config_overrides(&overrides);
684
685 let custom = p.quote(Some("internal-llm"));
686 assert_eq!(custom.match_kind, PricingMatchKind::Exact);
687 assert!((custom.cost.input_per_m - 0.10).abs() < 1e-9);
688 assert!(
689 (custom.cost.cache_read_per_m - 0.10).abs() < 1e-9,
690 "omitted cache rates default to the input rate"
691 );
692
693 let discounted = p.quote(Some("claude-opus-4.5"));
694 assert!((discounted.cost.input_per_m - 4.00).abs() < 1e-9);
695 assert!(
696 (discounted.cost.output_per_m - 25.00).abs() < 1e-9,
697 "omitted fields inherit the embedded row"
698 );
699
700 assert_eq!(
701 p.quote(Some("empty-row")).match_kind,
702 PricingMatchKind::Fallback,
703 "a row without any rate is ignored"
704 );
705 }
706
707 #[test]
708 fn dashed_api_ids_hit_their_exact_table_entry() {
709 let p = ModelPricing::embedded();
712 for (api_id, key) in [
713 ("claude-sonnet-4-5", "claude-sonnet-4.5"),
714 ("claude-opus-4-5", "claude-opus-4.5"),
715 ("claude-3-5-sonnet", "claude-3.5-sonnet"),
716 ("gemini-2-5-pro", "gemini-2.5-pro"),
717 ] {
718 let q = p.quote(Some(api_id));
719 assert_eq!(q.model_key, key, "{api_id} must map to {key}");
720 assert_eq!(
721 q.match_kind,
722 PricingMatchKind::Exact,
723 "{api_id} is the same model as {key} — exact, not heuristic"
724 );
725 }
726 let q = p.quote(Some("phi-4-mini"));
728 assert_eq!(q.model_key, "phi-4-mini");
729 assert_eq!(q.match_kind, PricingMatchKind::Exact);
730 }
731
732 #[test]
733 fn claude_sonnet_heuristic_maps_to_current_generation() {
734 let p = ModelPricing::embedded();
735 let q = p.quote(Some("claude-4.6-sonnet"));
736 assert!(matches!(
737 q.match_kind,
738 PricingMatchKind::Heuristic | PricingMatchKind::Alias
739 ));
740 assert_eq!(q.model_key, "claude-sonnet-4.5");
741 assert!((q.cost.input_per_m - 3.00).abs() < f64::EPSILON);
742 }
743
744 #[test]
745 fn claude_legacy_names_keep_legacy_pricing() {
746 let p = ModelPricing::embedded();
747 let q = p.quote(Some("claude-3-opus"));
748 assert_eq!(q.model_key, "claude-3-opus");
749 assert!((q.cost.input_per_m - 15.00).abs() < f64::EPSILON);
750 }
751
752 #[test]
753 fn claude_opus_current_generation_is_5_per_m() {
754 let p = ModelPricing::embedded();
755 for name in ["claude-opus-4.8", "claude-4.7-opus", "claude opus"] {
756 let q = p.quote(Some(name));
757 assert_eq!(q.model_key, "claude-opus-4.5", "for {name}");
758 assert!((q.cost.input_per_m - 5.00).abs() < f64::EPSILON);
759 assert!((q.cost.output_per_m - 25.00).abs() < f64::EPSILON);
760 }
761 }
762
763 #[test]
764 fn claude_fable_matches_frontier_tier() {
765 let p = ModelPricing::embedded();
766 let q = p.quote(Some("claude-fable-5-thinking-high"));
767 assert_eq!(q.model_key, "claude-fable-5");
768 assert!((q.cost.input_per_m - 10.00).abs() < f64::EPSILON);
769 }
770
771 #[test]
772 fn foundry_families_map_deployment_names_to_price_keys() {
773 let p = ModelPricing::embedded();
777 for (name, key, input) in [
778 ("Phi-4", "phi-4", 0.125),
779 ("Phi-4-reasoning", "phi-4", 0.125),
780 ("Phi-4-mini-instruct", "phi-4-mini", 0.075),
781 ("DeepSeek-V3-0324", "deepseek-v3", 1.14),
782 ("DeepSeek-V3.2", "deepseek-v3.2", 0.58),
783 ("Llama-3.3-70B-Instruct", "llama-3.3-70b", 0.71),
784 ("Llama-4-Maverick-17B-128E", "llama-4-maverick", 0.25),
785 ] {
786 let q = p.quote(Some(name));
787 assert_eq!(q.model_key, key, "for {name}");
788 assert!(
789 (q.cost.input_per_m - input).abs() < f64::EPSILON,
790 "for {name}"
791 );
792 assert_ne!(q.match_kind, PricingMatchKind::Fallback, "for {name}");
793 }
794 }
795
796 #[test]
797 fn resolve_model_precedence() {
798 assert_eq!(
800 resolve_model("cursor", Some("gpt-5.4"), Some("claude-opus-4.5")),
801 "gpt-5.4"
802 );
803 assert_eq!(
805 resolve_model("cursor", None, Some("claude-opus-4.5")),
806 "claude-opus-4.5"
807 );
808 assert_eq!(
810 resolve_model("claude-haiku-4.5", None, None),
811 "claude-haiku-4.5"
812 );
813 assert_eq!(resolve_model("cursor", Some(" "), Some(" ")), "cursor");
815 }
816}