Skip to main content

lean_ctx/core/gain/
model_pricing.rs

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        // Anthropic pricing — source: https://platform.claude.com/docs/en/about-claude/pricing
52        // (June 2026). One entry per price tier; the 4.5 keys cover the whole
53        // 4.5–4.8 generation since Anthropic prices them identically.
54        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        // Legacy Claude 3.x tiers (still seen in older configs/logs).
91        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        // OpenAI API pricing (Flagship) — source: https://openai.com/api/pricing/
120        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        // Google Gemini API pricing — source: https://ai.google.dev/pricing
149        // (No separate cache pricing published → treat cache read/write as input.)
150        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        // Conservative blended fallback (used by legacy stats output).
179        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    /// Resolves a pricing model for a client/agent, then quotes it. Resolution
232    /// order: `LEAN_CTX_MODEL`/`LCTX_MODEL` env → `[cost.models]` entry →
233    /// `[cost] default_model` → the client/agent string as a heuristic hint →
234    /// blended fallback (inside [`ModelPricing::quote`]). This is what lets
235    /// MCP-only IDEs (Cursor, Copilot, …) be priced with a declared model.
236    pub fn quote_for_client(&self, client: &str) -> ModelQuote {
237        self.quote(Some(&resolve_model_for_client(client)))
238    }
239
240    /// Back-compat alias for [`ModelPricing::quote_for_client`]; now also honors
241    /// the `[cost]` config, not just the env override.
242    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        // Claude family: accept loose naming (e.g. "claude sonnet", "claude-4.6-sonnet").
283        // 3.x names map to legacy tiers; everything else gets the current
284        // generation's price — defaulting to 3.x would overstate Opus cost 3×.
285        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        // OpenAI family: accept "gpt-5.4" variants and legacy "gpt-4o" as alias to blended fallback.
329        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
408/// Pure model resolution: env override → configured model → client hint.
409/// Split out for deterministic testing without touching global config/env.
410fn 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
417/// Resolves the pricing model id for a client/agent: the `LEAN_CTX_MODEL`/
418/// `LCTX_MODEL` env override wins, then the `[cost]` config
419/// (`models[client]` → `default_model`), then the client/agent string itself.
420/// The returned string is fed to [`ModelPricing::quote`] for the actual price.
421pub 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        // env override wins over everything.
484        assert_eq!(
485            resolve_model("cursor", Some("gpt-5.4"), Some("claude-opus-4.5")),
486            "gpt-5.4"
487        );
488        // configured model used when no env override.
489        assert_eq!(
490            resolve_model("cursor", None, Some("claude-opus-4.5")),
491            "claude-opus-4.5"
492        );
493        // client/agent string is the final hint.
494        assert_eq!(
495            resolve_model("claude-haiku-4.5", None, None),
496            "claude-haiku-4.5"
497        );
498        // blanks are ignored at each level.
499        assert_eq!(resolve_model("cursor", Some("  "), Some("  ")), "cursor");
500    }
501}