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(
180 "fallback-blended".to_string(),
181 ModelCost {
182 input_per_m: 2.50,
183 output_per_m: 10.00,
184 cache_write_per_m: 2.50,
185 cache_read_per_m: 2.50,
186 },
187 );
188
189 Self { models }
190 }
191
192 pub fn quote(&self, model: Option<&str>) -> ModelQuote {
193 let raw = model.unwrap_or_default();
194 if let Some(k) = Self::infer_model_key(raw)
195 && let Some(cost) = self.models.get(&k).copied()
196 {
197 return ModelQuote {
198 model_key: k,
199 cost,
200 match_kind: PricingMatchKind::Exact,
201 };
202 }
203
204 if let Some((k, kind)) = Self::heuristic_key(raw)
205 && let Some(cost) = self.models.get(&k).copied()
206 {
207 return ModelQuote {
208 model_key: k,
209 cost,
210 match_kind: kind,
211 };
212 }
213
214 let cost = self
215 .models
216 .get("fallback-blended")
217 .copied()
218 .unwrap_or(ModelCost {
219 input_per_m: 2.50,
220 output_per_m: 10.00,
221 cache_write_per_m: 2.50,
222 cache_read_per_m: 2.50,
223 });
224 ModelQuote {
225 model_key: "fallback-blended".to_string(),
226 cost,
227 match_kind: PricingMatchKind::Fallback,
228 }
229 }
230
231 pub fn quote_for_client(&self, client: &str) -> ModelQuote {
237 self.quote(Some(&resolve_model_for_client(client)))
238 }
239
240 pub fn quote_from_env_or_agent_type(&self, agent_type: &str) -> ModelQuote {
243 self.quote_for_client(agent_type)
244 }
245
246 pub fn infer_model_key(model: &str) -> Option<String> {
247 let m = normalize(model);
248 if m.is_empty() {
249 return None;
250 }
251
252 let exact_keys = [
253 "claude-fable-5",
254 "claude-opus-4.5",
255 "claude-sonnet-4.5",
256 "claude-haiku-4.5",
257 "claude-3.5-sonnet",
258 "claude-3-opus",
259 "claude-3-haiku",
260 "gpt-5.4",
261 "gpt-5.4-mini",
262 "gpt-5.4-nano",
263 "gemini-2.5-pro",
264 "gemini-2.5-flash",
265 "gemini-2.5-flash-lite",
266 "fallback-blended",
267 ];
268 for k in exact_keys {
269 if m == k {
270 return Some(k.to_string());
271 }
272 }
273 None
274 }
275
276 fn heuristic_key(model: &str) -> Option<(String, PricingMatchKind)> {
277 let m = normalize(model);
278 if m.is_empty() {
279 return None;
280 }
281
282 if m.contains("claude") || m.contains("fable") || m.contains("mythos") {
286 let legacy = m.contains("claude-3");
287 if m.contains("fable") || m.contains("mythos") {
288 return Some(("claude-fable-5".to_string(), PricingMatchKind::Heuristic));
289 }
290 if m.contains("sonnet") {
291 return Some(if legacy {
292 ("claude-3.5-sonnet".to_string(), PricingMatchKind::Heuristic)
293 } else {
294 ("claude-sonnet-4.5".to_string(), PricingMatchKind::Heuristic)
295 });
296 }
297 if m.contains("opus") {
298 return Some(if legacy {
299 ("claude-3-opus".to_string(), PricingMatchKind::Heuristic)
300 } else {
301 ("claude-opus-4.5".to_string(), PricingMatchKind::Heuristic)
302 });
303 }
304 if m.contains("haiku") {
305 return Some(if legacy {
306 ("claude-3-haiku".to_string(), PricingMatchKind::Heuristic)
307 } else {
308 ("claude-haiku-4.5".to_string(), PricingMatchKind::Heuristic)
309 });
310 }
311 }
312
313 if m.contains("gemini") {
314 if m.contains("2.5") && m.contains("pro") {
315 return Some(("gemini-2.5-pro".to_string(), PricingMatchKind::Heuristic));
316 }
317 if m.contains("2.5") && m.contains("flash-lite") {
318 return Some((
319 "gemini-2.5-flash-lite".to_string(),
320 PricingMatchKind::Heuristic,
321 ));
322 }
323 if m.contains("2.5") && m.contains("flash") {
324 return Some(("gemini-2.5-flash".to_string(), PricingMatchKind::Heuristic));
325 }
326 }
327
328 if m.contains("gpt-5.4") && m.contains("mini") {
330 return Some(("gpt-5.4-mini".to_string(), PricingMatchKind::Alias));
331 }
332 if m.contains("gpt-5.4") && m.contains("nano") {
333 return Some(("gpt-5.4-nano".to_string(), PricingMatchKind::Alias));
334 }
335 if m.contains("gpt-5.4") {
336 return Some(("gpt-5.4".to_string(), PricingMatchKind::Alias));
337 }
338 if m.contains("gpt-4o") {
339 return Some(("fallback-blended".to_string(), PricingMatchKind::Heuristic));
340 }
341
342 None
343 }
344
345 fn apply_env_override(&mut self) {
346 let raw = std::env::var("LEAN_CTX_MODEL_PRICING_JSON")
347 .or_else(|_| std::env::var("LCTX_MODEL_PRICING_JSON"))
348 .ok();
349 let Some(raw) = raw else { return };
350
351 let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
352 return;
353 };
354 let Some(models) = v.get("models").and_then(|m| m.as_object()) else {
355 return;
356 };
357 for (k, vv) in models {
358 let Some(obj) = vv.as_object() else { continue };
359 let input_per_m = obj.get("input_per_m").and_then(serde_json::Value::as_f64);
360 let output_per_m = obj.get("output_per_m").and_then(serde_json::Value::as_f64);
361 if input_per_m.is_none() && output_per_m.is_none() {
362 continue;
363 }
364
365 let key_norm = normalize(k);
366 let base = self.models.get(&key_norm).copied().unwrap_or_else(|| {
367 self.models
368 .get("fallback-blended")
369 .copied()
370 .unwrap_or(ModelCost {
371 input_per_m: 2.50,
372 output_per_m: 10.00,
373 cache_write_per_m: 2.50,
374 cache_read_per_m: 2.50,
375 })
376 });
377
378 let merged = ModelCost {
379 input_per_m: input_per_m.unwrap_or(base.input_per_m),
380 output_per_m: output_per_m.unwrap_or(base.output_per_m),
381 cache_write_per_m: obj
382 .get("cache_write_per_m")
383 .and_then(serde_json::Value::as_f64)
384 .unwrap_or(base.cache_write_per_m),
385 cache_read_per_m: obj
386 .get("cache_read_per_m")
387 .and_then(serde_json::Value::as_f64)
388 .unwrap_or(base.cache_read_per_m),
389 };
390 self.models.insert(key_norm, merged);
391 }
392 }
393}
394
395fn normalize(s: &str) -> String {
396 s.trim().to_lowercase().replace(' ', "-")
397}
398
399fn non_blank(s: &str) -> Option<String> {
400 let t = s.trim();
401 if t.is_empty() {
402 None
403 } else {
404 Some(t.to_string())
405 }
406}
407
408fn resolve_model(client: &str, env_model: Option<&str>, configured: Option<&str>) -> String {
411 env_model
412 .and_then(non_blank)
413 .or_else(|| configured.and_then(non_blank))
414 .unwrap_or_else(|| client.to_string())
415}
416
417pub fn resolve_model_for_client(client: &str) -> String {
422 let env_model = std::env::var("LEAN_CTX_MODEL")
423 .or_else(|_| std::env::var("LCTX_MODEL"))
424 .ok();
425 let configured = crate::core::config::Config::load()
426 .cost
427 .model_for_client(client);
428 resolve_model(client, env_model.as_deref(), configured.as_deref())
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn quote_falls_back() {
437 let p = ModelPricing::embedded();
438 let q = p.quote(Some("unknown-model"));
439 assert_eq!(q.match_kind, PricingMatchKind::Fallback);
440 }
441
442 #[test]
443 fn claude_sonnet_heuristic_maps_to_current_generation() {
444 let p = ModelPricing::embedded();
445 let q = p.quote(Some("claude-4.6-sonnet"));
446 assert!(matches!(
447 q.match_kind,
448 PricingMatchKind::Heuristic | PricingMatchKind::Alias
449 ));
450 assert_eq!(q.model_key, "claude-sonnet-4.5");
451 assert!((q.cost.input_per_m - 3.00).abs() < f64::EPSILON);
452 }
453
454 #[test]
455 fn claude_legacy_names_keep_legacy_pricing() {
456 let p = ModelPricing::embedded();
457 let q = p.quote(Some("claude-3-opus"));
458 assert_eq!(q.model_key, "claude-3-opus");
459 assert!((q.cost.input_per_m - 15.00).abs() < f64::EPSILON);
460 }
461
462 #[test]
463 fn claude_opus_current_generation_is_5_per_m() {
464 let p = ModelPricing::embedded();
465 for name in ["claude-opus-4.8", "claude-4.7-opus", "claude opus"] {
466 let q = p.quote(Some(name));
467 assert_eq!(q.model_key, "claude-opus-4.5", "for {name}");
468 assert!((q.cost.input_per_m - 5.00).abs() < f64::EPSILON);
469 assert!((q.cost.output_per_m - 25.00).abs() < f64::EPSILON);
470 }
471 }
472
473 #[test]
474 fn claude_fable_matches_frontier_tier() {
475 let p = ModelPricing::embedded();
476 let q = p.quote(Some("claude-fable-5-thinking-high"));
477 assert_eq!(q.model_key, "claude-fable-5");
478 assert!((q.cost.input_per_m - 10.00).abs() < f64::EPSILON);
479 }
480
481 #[test]
482 fn resolve_model_precedence() {
483 assert_eq!(
485 resolve_model("cursor", Some("gpt-5.4"), Some("claude-opus-4.5")),
486 "gpt-5.4"
487 );
488 assert_eq!(
490 resolve_model("cursor", None, Some("claude-opus-4.5")),
491 "claude-opus-4.5"
492 );
493 assert_eq!(
495 resolve_model("claude-haiku-4.5", None, None),
496 "claude-haiku-4.5"
497 );
498 assert_eq!(resolve_model("cursor", Some(" "), Some(" ")), "cursor");
500 }
501}