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    /// Exact hit in the live provider price list (OpenRouter models API,
25    /// refreshed in the background). Current market data — NOT an estimate.
26    Live,
27    Alias,
28    Heuristic,
29    Fallback,
30}
31
32impl PricingMatchKind {
33    /// True when the priced figure is an estimate (no exact or live price for
34    /// the model) and must be surfaced as such, never as a precise number.
35    #[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        // Anthropic pricing — source: https://platform.claude.com/docs/en/about-claude/pricing
65        // (June 2026). One entry per price tier; the 4.5 keys cover the whole
66        // 4.5–4.8 generation since Anthropic prices them identically.
67        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        // Legacy Claude 3.x tiers (still seen in older configs/logs).
104        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        // OpenAI API pricing (Flagship) — source: https://openai.com/api/pricing/
133        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        // Google Gemini API pricing — source: https://ai.google.dev/pricing
162        // (No separate cache pricing published → treat cache read/write as input.)
163        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        // Azure AI Foundry serverless (Global Standard) — source:
192        // https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/
193        // (July 2026). The cheap-OSS tier the gateway router downgrades to
194        // (enterprise#14); wrong/missing prices here would overstate savings.
195        // Foundry publishes no separate cache price → cache = input rate
196        // (same convention as Gemini above).
197        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        // Conservative blended fallback (used by legacy stats output).
253        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 quote(&self, model: Option<&str>) -> ModelQuote {
267        let raw = model.unwrap_or_default();
268        // Exact = a direct hit in the loaded table (embedded + operator
269        // overrides, #1189) — not a fixed key list, so `[cost.prices]` rows
270        // with custom names ("internal-llm") price exactly. Dashed API ids
271        // (`claude-sonnet-4-5`) retry with dotted versions (`…-4.5`).
272        let m = normalize(raw);
273        if !m.is_empty() {
274            for k in [m.clone(), dot_versions(&m)] {
275                if let Some(cost) = self.models.get(&k).copied() {
276                    return ModelQuote {
277                        model_key: k,
278                        cost,
279                        match_kind: PricingMatchKind::Exact,
280                    };
281                }
282            }
283        }
284
285        // Live provider price list (#1179): exact market prices for models the
286        // embedded table doesn't know — checked BEFORE any family heuristic so
287        // a new model is never priced by its older, differently-priced kin.
288        // No-op unless a run-mode loaded the snapshot (proxy/gateway/spend).
289        if let Some((k, cost)) = super::live_pricing::lookup(raw) {
290            return ModelQuote {
291                model_key: k,
292                cost,
293                match_kind: PricingMatchKind::Live,
294            };
295        }
296
297        if let Some((k, kind)) = Self::heuristic_key(raw)
298            && let Some(cost) = self.models.get(&k).copied()
299        {
300            return ModelQuote {
301                model_key: k,
302                cost,
303                match_kind: kind,
304            };
305        }
306
307        let cost = self
308            .models
309            .get("fallback-blended")
310            .copied()
311            .unwrap_or(ModelCost {
312                input_per_m: 2.50,
313                output_per_m: 10.00,
314                cache_write_per_m: 2.50,
315                cache_read_per_m: 2.50,
316            });
317        ModelQuote {
318            model_key: "fallback-blended".to_string(),
319            cost,
320            match_kind: PricingMatchKind::Fallback,
321        }
322    }
323
324    /// Resolves a pricing model for a client/agent, then quotes it. Resolution
325    /// order: `LEAN_CTX_MODEL`/`LCTX_MODEL` env → `[cost.models]` entry →
326    /// `[cost] default_model` → the client/agent string as a heuristic hint →
327    /// blended fallback (inside [`ModelPricing::quote`]). This is what lets
328    /// MCP-only IDEs (Cursor, Copilot, …) be priced with a declared model.
329    pub fn quote_for_client(&self, client: &str) -> ModelQuote {
330        self.quote(Some(&resolve_model_for_client(client)))
331    }
332
333    /// Back-compat alias for [`ModelPricing::quote_for_client`]; now also honors
334    /// the `[cost]` config, not just the env override.
335    pub fn quote_from_env_or_agent_type(&self, agent_type: &str) -> ModelQuote {
336        self.quote_for_client(agent_type)
337    }
338
339    fn heuristic_key(model: &str) -> Option<(String, PricingMatchKind)> {
340        let m = normalize(model);
341        if m.is_empty() {
342            return None;
343        }
344
345        // Claude family: accept loose naming (e.g. "claude sonnet", "claude-4.6-sonnet").
346        // 3.x names map to legacy tiers; everything else gets the current
347        // generation's price — defaulting to 3.x would overstate Opus cost 3×.
348        if m.contains("claude") || m.contains("fable") || m.contains("mythos") {
349            let legacy = m.contains("claude-3");
350            if m.contains("fable") || m.contains("mythos") {
351                return Some(("claude-fable-5".to_string(), PricingMatchKind::Heuristic));
352            }
353            if m.contains("sonnet") {
354                return Some(if legacy {
355                    ("claude-3.5-sonnet".to_string(), PricingMatchKind::Heuristic)
356                } else {
357                    ("claude-sonnet-4.5".to_string(), PricingMatchKind::Heuristic)
358                });
359            }
360            if m.contains("opus") {
361                return Some(if legacy {
362                    ("claude-3-opus".to_string(), PricingMatchKind::Heuristic)
363                } else {
364                    ("claude-opus-4.5".to_string(), PricingMatchKind::Heuristic)
365                });
366            }
367            if m.contains("haiku") {
368                return Some(if legacy {
369                    ("claude-3-haiku".to_string(), PricingMatchKind::Heuristic)
370                } else {
371                    ("claude-haiku-4.5".to_string(), PricingMatchKind::Heuristic)
372                });
373            }
374        }
375
376        if m.contains("gemini") {
377            if m.contains("2.5") && m.contains("pro") {
378                return Some(("gemini-2.5-pro".to_string(), PricingMatchKind::Heuristic));
379            }
380            if m.contains("2.5") && m.contains("flash-lite") {
381                return Some((
382                    "gemini-2.5-flash-lite".to_string(),
383                    PricingMatchKind::Heuristic,
384                ));
385            }
386            if m.contains("2.5") && m.contains("flash") {
387                return Some(("gemini-2.5-flash".to_string(), PricingMatchKind::Heuristic));
388            }
389        }
390
391        // OpenAI family: accept "gpt-5.4" variants and legacy "gpt-4o" as alias to blended fallback.
392        if m.contains("gpt-5.4") && m.contains("mini") {
393            return Some(("gpt-5.4-mini".to_string(), PricingMatchKind::Alias));
394        }
395        if m.contains("gpt-5.4") && m.contains("nano") {
396            return Some(("gpt-5.4-nano".to_string(), PricingMatchKind::Alias));
397        }
398        if m.contains("gpt-5.4") {
399            return Some(("gpt-5.4".to_string(), PricingMatchKind::Alias));
400        }
401        if m.contains("gpt-4o") {
402            return Some(("fallback-blended".to_string(), PricingMatchKind::Heuristic));
403        }
404
405        // Foundry OSS families (enterprise#14): deployment names carry suffixes
406        // ("Phi-4-reasoning", "DeepSeek-V3-0324", "Llama-3.3-70B-Instruct") —
407        // match the family, keep mini/lite variants on their cheaper tier.
408        if m.contains("phi-4") {
409            return Some(if m.contains("mini") {
410                ("phi-4-mini".to_string(), PricingMatchKind::Heuristic)
411            } else {
412                ("phi-4".to_string(), PricingMatchKind::Heuristic)
413            });
414        }
415        if m.contains("deepseek") {
416            return Some(if m.contains("v3.2") {
417                ("deepseek-v3.2".to_string(), PricingMatchKind::Heuristic)
418            } else {
419                ("deepseek-v3".to_string(), PricingMatchKind::Heuristic)
420            });
421        }
422        if m.contains("llama") {
423            return Some(if m.contains("maverick") || m.contains("llama-4") {
424                ("llama-4-maverick".to_string(), PricingMatchKind::Heuristic)
425            } else {
426                ("llama-3.3-70b".to_string(), PricingMatchKind::Heuristic)
427            });
428        }
429
430        None
431    }
432
433    /// Merges `[cost.prices]` operator overrides (#1189) into the table as
434    /// exact entries. Negotiated enterprise rates override embedded rows and
435    /// (via exact-match precedence) every live/heuristic price; only a
436    /// provider-measured bill beats them. Rows without any rate are ignored;
437    /// omitted fields inherit from the existing row for that key, falling back
438    /// to the blended profile — cache rates default to the input rate.
439    fn apply_config_overrides(
440        &mut self,
441        prices: &std::collections::HashMap<String, crate::core::config::PriceOverride>,
442    ) {
443        for (model, o) in prices {
444            if o.input_per_m.is_none()
445                && o.output_per_m.is_none()
446                && o.cache_write_per_m.is_none()
447                && o.cache_read_per_m.is_none()
448            {
449                continue;
450            }
451            let key = normalize(model);
452            if key.is_empty() {
453                continue;
454            }
455            let base = self.models.get(&key).copied();
456            let input = o
457                .input_per_m
458                .or(base.map(|b| b.input_per_m))
459                .unwrap_or(2.50);
460            let merged = ModelCost {
461                input_per_m: input,
462                output_per_m: o
463                    .output_per_m
464                    .or(base.map(|b| b.output_per_m))
465                    .unwrap_or(10.00),
466                cache_write_per_m: o
467                    .cache_write_per_m
468                    .or(base.map(|b| b.cache_write_per_m))
469                    .unwrap_or(input),
470                cache_read_per_m: o
471                    .cache_read_per_m
472                    .or(base.map(|b| b.cache_read_per_m))
473                    .unwrap_or(input),
474            };
475            self.models.insert(key, merged);
476        }
477    }
478
479    fn apply_env_override(&mut self) {
480        let raw = std::env::var("LEAN_CTX_MODEL_PRICING_JSON")
481            .or_else(|_| std::env::var("LCTX_MODEL_PRICING_JSON"))
482            .ok();
483        let Some(raw) = raw else { return };
484
485        let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
486            return;
487        };
488        let Some(models) = v.get("models").and_then(|m| m.as_object()) else {
489            return;
490        };
491        for (k, vv) in models {
492            let Some(obj) = vv.as_object() else { continue };
493            let input_per_m = obj.get("input_per_m").and_then(serde_json::Value::as_f64);
494            let output_per_m = obj.get("output_per_m").and_then(serde_json::Value::as_f64);
495            if input_per_m.is_none() && output_per_m.is_none() {
496                continue;
497            }
498
499            let key_norm = normalize(k);
500            let base = self.models.get(&key_norm).copied().unwrap_or_else(|| {
501                self.models
502                    .get("fallback-blended")
503                    .copied()
504                    .unwrap_or(ModelCost {
505                        input_per_m: 2.50,
506                        output_per_m: 10.00,
507                        cache_write_per_m: 2.50,
508                        cache_read_per_m: 2.50,
509                    })
510            });
511
512            let merged = ModelCost {
513                input_per_m: input_per_m.unwrap_or(base.input_per_m),
514                output_per_m: output_per_m.unwrap_or(base.output_per_m),
515                cache_write_per_m: obj
516                    .get("cache_write_per_m")
517                    .and_then(serde_json::Value::as_f64)
518                    .unwrap_or(base.cache_write_per_m),
519                cache_read_per_m: obj
520                    .get("cache_read_per_m")
521                    .and_then(serde_json::Value::as_f64)
522                    .unwrap_or(base.cache_read_per_m),
523            };
524            self.models.insert(key_norm, merged);
525        }
526    }
527}
528
529fn normalize(s: &str) -> String {
530    s.trim().to_lowercase().replace(' ', "-")
531}
532
533/// Rewrites dashed version tails into dotted ones: `sonnet-4-5` → `sonnet-4.5`.
534/// Only digit-digit boundaries are touched, so names like `phi-4-mini` or
535/// `llama-4-maverick` stay as they are.
536fn dot_versions(s: &str) -> String {
537    let b = s.as_bytes();
538    let mut out = String::with_capacity(s.len());
539    for (i, &c) in b.iter().enumerate() {
540        if c == b'-'
541            && i > 0
542            && b[i - 1].is_ascii_digit()
543            && b.get(i + 1).is_some_and(u8::is_ascii_digit)
544        {
545            out.push('.');
546        } else {
547            out.push(c as char);
548        }
549    }
550    out
551}
552
553fn non_blank(s: &str) -> Option<String> {
554    let t = s.trim();
555    if t.is_empty() {
556        None
557    } else {
558        Some(t.to_string())
559    }
560}
561
562/// Pure model resolution: env override → configured model → client hint.
563/// Split out for deterministic testing without touching global config/env.
564fn resolve_model(client: &str, env_model: Option<&str>, configured: Option<&str>) -> String {
565    env_model
566        .and_then(non_blank)
567        .or_else(|| configured.and_then(non_blank))
568        .unwrap_or_else(|| client.to_string())
569}
570
571/// Resolves the pricing model id for a client/agent: the `LEAN_CTX_MODEL`/
572/// `LCTX_MODEL` env override wins, then the `[cost]` config
573/// (`models[client]` → `default_model`), then the client/agent string itself.
574/// The returned string is fed to [`ModelPricing::quote`] for the actual price.
575pub fn resolve_model_for_client(client: &str) -> String {
576    let env_model = std::env::var("LEAN_CTX_MODEL")
577        .or_else(|_| std::env::var("LCTX_MODEL"))
578        .ok();
579    let configured = crate::core::config::Config::load()
580        .cost
581        .model_for_client(client);
582    resolve_model(client, env_model.as_deref(), configured.as_deref())
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn quote_falls_back() {
591        let p = ModelPricing::embedded();
592        let q = p.quote(Some("unknown-model"));
593        assert_eq!(q.match_kind, PricingMatchKind::Fallback);
594    }
595
596    #[test]
597    fn live_price_beats_heuristic_but_not_embedded_exact() {
598        // #1179: a live-listed model must be priced from the live table (Live),
599        // never from a family heuristic; embedded exact matches keep priority.
600        let _lock = crate::core::data_dir::test_env_lock();
601        crate::core::gain::live_pricing::install(crate::core::gain::live_pricing::LivePriceTable {
602            fetched_at: 1,
603            models: [
604                (
605                    "zzz-test/live-only-model".to_string(),
606                    ModelCost {
607                        input_per_m: 0.07,
608                        output_per_m: 0.28,
609                        cache_write_per_m: 0.07,
610                        cache_read_per_m: 0.007,
611                    },
612                ),
613                (
614                    "claude-sonnet-4-5".to_string(),
615                    ModelCost {
616                        input_per_m: 999.0,
617                        output_per_m: 999.0,
618                        cache_write_per_m: 999.0,
619                        cache_read_per_m: 999.0,
620                    },
621                ),
622            ]
623            .into_iter()
624            .collect(),
625        });
626
627        let p = ModelPricing::embedded();
628        let live = p.quote(Some("zzz-test/live-only-model"));
629        assert_eq!(live.match_kind, PricingMatchKind::Live);
630        assert!(
631            !live.match_kind.is_estimated(),
632            "live is market data, not a guess"
633        );
634        assert!((live.cost.input_per_m - 0.07).abs() < 1e-9);
635
636        // Embedded exact match wins over a (bogus) live row for the same key.
637        let exact = p.quote(Some("claude-sonnet-4.5"));
638        assert_eq!(exact.match_kind, PricingMatchKind::Exact);
639        assert!((exact.cost.input_per_m - 3.00).abs() < f64::EPSILON);
640
641        crate::core::gain::live_pricing::clear_for_tests();
642        let after = p.quote(Some("zzz-test/live-only-model"));
643        assert_eq!(
644            after.match_kind,
645            PricingMatchKind::Fallback,
646            "no snapshot → fallback"
647        );
648    }
649
650    #[test]
651    fn config_price_overrides_are_exact_and_beat_embedded_rows() {
652        // #1189: negotiated enterprise rates. A custom model name unknown to
653        // any catalog prices exactly; an embedded row is overridden in place.
654        let mut p = ModelPricing::embedded();
655        let overrides: std::collections::HashMap<String, crate::core::config::PriceOverride> = [
656            (
657                "internal-llm".to_string(),
658                crate::core::config::PriceOverride {
659                    input_per_m: Some(0.10),
660                    output_per_m: Some(0.40),
661                    ..Default::default()
662                },
663            ),
664            (
665                "claude-opus-4.5".to_string(),
666                crate::core::config::PriceOverride {
667                    input_per_m: Some(4.00), // committed-use discount
668                    ..Default::default()
669                },
670            ),
671            (
672                "empty-row".to_string(),
673                crate::core::config::PriceOverride::default(),
674            ),
675        ]
676        .into();
677        p.apply_config_overrides(&overrides);
678
679        let custom = p.quote(Some("internal-llm"));
680        assert_eq!(custom.match_kind, PricingMatchKind::Exact);
681        assert!((custom.cost.input_per_m - 0.10).abs() < 1e-9);
682        assert!(
683            (custom.cost.cache_read_per_m - 0.10).abs() < 1e-9,
684            "omitted cache rates default to the input rate"
685        );
686
687        let discounted = p.quote(Some("claude-opus-4.5"));
688        assert!((discounted.cost.input_per_m - 4.00).abs() < 1e-9);
689        assert!(
690            (discounted.cost.output_per_m - 25.00).abs() < 1e-9,
691            "omitted fields inherit the embedded row"
692        );
693
694        assert_eq!(
695            p.quote(Some("empty-row")).match_kind,
696            PricingMatchKind::Fallback,
697            "a row without any rate is ignored"
698        );
699    }
700
701    #[test]
702    fn dashed_api_ids_hit_their_exact_table_entry() {
703        // Anthropic wire ids use dashes ("claude-sonnet-4-5"); the table keys
704        // use dots. Same model — must book as Exact list price, not Heuristic.
705        let p = ModelPricing::embedded();
706        for (api_id, key) in [
707            ("claude-sonnet-4-5", "claude-sonnet-4.5"),
708            ("claude-opus-4-5", "claude-opus-4.5"),
709            ("claude-3-5-sonnet", "claude-3.5-sonnet"),
710            ("gemini-2-5-pro", "gemini-2.5-pro"),
711        ] {
712            let q = p.quote(Some(api_id));
713            assert_eq!(q.model_key, key, "{api_id} must map to {key}");
714            assert_eq!(
715                q.match_kind,
716                PricingMatchKind::Exact,
717                "{api_id} is the same model as {key} — exact, not heuristic"
718            );
719        }
720        // Dash-digit names that are NOT versions stay untouched.
721        let q = p.quote(Some("phi-4-mini"));
722        assert_eq!(q.model_key, "phi-4-mini");
723        assert_eq!(q.match_kind, PricingMatchKind::Exact);
724    }
725
726    #[test]
727    fn claude_sonnet_heuristic_maps_to_current_generation() {
728        let p = ModelPricing::embedded();
729        let q = p.quote(Some("claude-4.6-sonnet"));
730        assert!(matches!(
731            q.match_kind,
732            PricingMatchKind::Heuristic | PricingMatchKind::Alias
733        ));
734        assert_eq!(q.model_key, "claude-sonnet-4.5");
735        assert!((q.cost.input_per_m - 3.00).abs() < f64::EPSILON);
736    }
737
738    #[test]
739    fn claude_legacy_names_keep_legacy_pricing() {
740        let p = ModelPricing::embedded();
741        let q = p.quote(Some("claude-3-opus"));
742        assert_eq!(q.model_key, "claude-3-opus");
743        assert!((q.cost.input_per_m - 15.00).abs() < f64::EPSILON);
744    }
745
746    #[test]
747    fn claude_opus_current_generation_is_5_per_m() {
748        let p = ModelPricing::embedded();
749        for name in ["claude-opus-4.8", "claude-4.7-opus", "claude opus"] {
750            let q = p.quote(Some(name));
751            assert_eq!(q.model_key, "claude-opus-4.5", "for {name}");
752            assert!((q.cost.input_per_m - 5.00).abs() < f64::EPSILON);
753            assert!((q.cost.output_per_m - 25.00).abs() < f64::EPSILON);
754        }
755    }
756
757    #[test]
758    fn claude_fable_matches_frontier_tier() {
759        let p = ModelPricing::embedded();
760        let q = p.quote(Some("claude-fable-5-thinking-high"));
761        assert_eq!(q.model_key, "claude-fable-5");
762        assert!((q.cost.input_per_m - 10.00).abs() < f64::EPSILON);
763    }
764
765    #[test]
766    fn foundry_families_map_deployment_names_to_price_keys() {
767        // enterprise#14: Foundry deployment names carry suffixes; the family
768        // heuristics must land on the right (cheap) tier — mispricing the
769        // downgrade target would corrupt the savings evidence.
770        let p = ModelPricing::embedded();
771        for (name, key, input) in [
772            ("Phi-4", "phi-4", 0.125),
773            ("Phi-4-reasoning", "phi-4", 0.125),
774            ("Phi-4-mini-instruct", "phi-4-mini", 0.075),
775            ("DeepSeek-V3-0324", "deepseek-v3", 1.14),
776            ("DeepSeek-V3.2", "deepseek-v3.2", 0.58),
777            ("Llama-3.3-70B-Instruct", "llama-3.3-70b", 0.71),
778            ("Llama-4-Maverick-17B-128E", "llama-4-maverick", 0.25),
779        ] {
780            let q = p.quote(Some(name));
781            assert_eq!(q.model_key, key, "for {name}");
782            assert!(
783                (q.cost.input_per_m - input).abs() < f64::EPSILON,
784                "for {name}"
785            );
786            assert_ne!(q.match_kind, PricingMatchKind::Fallback, "for {name}");
787        }
788    }
789
790    #[test]
791    fn resolve_model_precedence() {
792        // env override wins over everything.
793        assert_eq!(
794            resolve_model("cursor", Some("gpt-5.4"), Some("claude-opus-4.5")),
795            "gpt-5.4"
796        );
797        // configured model used when no env override.
798        assert_eq!(
799            resolve_model("cursor", None, Some("claude-opus-4.5")),
800            "claude-opus-4.5"
801        );
802        // client/agent string is the final hint.
803        assert_eq!(
804            resolve_model("claude-haiku-4.5", None, None),
805            "claude-haiku-4.5"
806        );
807        // blanks are ignored at each level.
808        assert_eq!(resolve_model("cursor", Some("  "), Some("  ")), "cursor");
809    }
810}