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        // Azure AI Foundry serverless (Global Standard) — source:
179        // https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/
180        // (July 2026). The cheap-OSS tier the gateway router downgrades to
181        // (enterprise#14); wrong/missing prices here would overstate savings.
182        // Foundry publishes no separate cache price → cache = input rate
183        // (same convention as Gemini above).
184        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        // Conservative blended fallback (used by legacy stats output).
240        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    /// Resolves a pricing model for a client/agent, then quotes it. Resolution
293    /// order: `LEAN_CTX_MODEL`/`LCTX_MODEL` env → `[cost.models]` entry →
294    /// `[cost] default_model` → the client/agent string as a heuristic hint →
295    /// blended fallback (inside [`ModelPricing::quote`]). This is what lets
296    /// MCP-only IDEs (Cursor, Copilot, …) be priced with a declared model.
297    pub fn quote_for_client(&self, client: &str) -> ModelQuote {
298        self.quote(Some(&resolve_model_for_client(client)))
299    }
300
301    /// Back-compat alias for [`ModelPricing::quote_for_client`]; now also honors
302    /// the `[cost]` config, not just the env override.
303    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        // Claude family: accept loose naming (e.g. "claude sonnet", "claude-4.6-sonnet").
350        // 3.x names map to legacy tiers; everything else gets the current
351        // generation's price — defaulting to 3.x would overstate Opus cost 3×.
352        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        // OpenAI family: accept "gpt-5.4" variants and legacy "gpt-4o" as alias to blended fallback.
396        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        // Foundry OSS families (enterprise#14): deployment names carry suffixes
410        // ("Phi-4-reasoning", "DeepSeek-V3-0324", "Llama-3.3-70B-Instruct") —
411        // match the family, keep mini/lite variants on their cheaper tier.
412        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
500/// Pure model resolution: env override → configured model → client hint.
501/// Split out for deterministic testing without touching global config/env.
502fn 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
509/// Resolves the pricing model id for a client/agent: the `LEAN_CTX_MODEL`/
510/// `LCTX_MODEL` env override wins, then the `[cost]` config
511/// (`models[client]` → `default_model`), then the client/agent string itself.
512/// The returned string is fed to [`ModelPricing::quote`] for the actual price.
513pub 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        // enterprise#14: Foundry deployment names carry suffixes; the family
576        // heuristics must land on the right (cheap) tier — mispricing the
577        // downgrade target would corrupt the savings evidence.
578        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        // env override wins over everything.
601        assert_eq!(
602            resolve_model("cursor", Some("gpt-5.4"), Some("claude-opus-4.5")),
603            "gpt-5.4"
604        );
605        // configured model used when no env override.
606        assert_eq!(
607            resolve_model("cursor", None, Some("claude-opus-4.5")),
608            "claude-opus-4.5"
609        );
610        // client/agent string is the final hint.
611        assert_eq!(
612            resolve_model("claude-haiku-4.5", None, None),
613            "claude-haiku-4.5"
614        );
615        // blanks are ignored at each level.
616        assert_eq!(resolve_model("cursor", Some("  "), Some("  ")), "cursor");
617    }
618}