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    /// Every priced row (embedded table plus operator overrides), for the
267    /// cockpit's "Numbers explained" price table.
268    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        // Exact = a direct hit in the loaded table (embedded + operator
275        // overrides, #1189) — not a fixed key list, so `[cost.prices]` rows
276        // with custom names ("internal-llm") price exactly. Dashed API ids
277        // (`claude-sonnet-4-5`) retry with dotted versions (`…-4.5`).
278        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        // Live provider price list (#1179): exact market prices for models the
292        // embedded table doesn't know — checked BEFORE any family heuristic so
293        // a new model is never priced by its older, differently-priced kin.
294        // No-op unless a run-mode loaded the snapshot (proxy/gateway/spend).
295        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    /// Resolves a pricing model for a client/agent, then quotes it. Resolution
331    /// order: `LEAN_CTX_MODEL`/`LCTX_MODEL` env → `[cost.models]` entry →
332    /// `[cost] default_model` → the client/agent string as a heuristic hint →
333    /// blended fallback (inside [`ModelPricing::quote`]). This is what lets
334    /// MCP-only IDEs (Cursor, Copilot, …) be priced with a declared model.
335    pub fn quote_for_client(&self, client: &str) -> ModelQuote {
336        self.quote(Some(&resolve_model_for_client(client)))
337    }
338
339    /// Back-compat alias for [`ModelPricing::quote_for_client`]; now also honors
340    /// the `[cost]` config, not just the env override.
341    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        // Claude family: accept loose naming (e.g. "claude sonnet", "claude-4.6-sonnet").
352        // 3.x names map to legacy tiers; everything else gets the current
353        // generation's price — defaulting to 3.x would overstate Opus cost 3×.
354        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        // OpenAI family: accept "gpt-5.4" variants and legacy "gpt-4o" as alias to blended fallback.
398        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        // Foundry OSS families (enterprise#14): deployment names carry suffixes
412        // ("Phi-4-reasoning", "DeepSeek-V3-0324", "Llama-3.3-70B-Instruct") —
413        // match the family, keep mini/lite variants on their cheaper tier.
414        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    /// Merges `[cost.prices]` operator overrides (#1189) into the table as
440    /// exact entries. Negotiated enterprise rates override embedded rows and
441    /// (via exact-match precedence) every live/heuristic price; only a
442    /// provider-measured bill beats them. Rows without any rate are ignored;
443    /// omitted fields inherit from the existing row for that key, falling back
444    /// to the blended profile — cache rates default to the input rate.
445    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
539/// Rewrites dashed version tails into dotted ones: `sonnet-4-5` → `sonnet-4.5`.
540/// Only digit-digit boundaries are touched, so names like `phi-4-mini` or
541/// `llama-4-maverick` stay as they are.
542fn 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
568/// Pure model resolution: env override → configured model → client hint.
569/// Split out for deterministic testing without touching global config/env.
570fn 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
577/// Resolves the pricing model id for a client/agent: the `LEAN_CTX_MODEL`/
578/// `LCTX_MODEL` env override wins, then the `[cost]` config
579/// (`models[client]` → `default_model`), then the client/agent string itself.
580/// The returned string is fed to [`ModelPricing::quote`] for the actual price.
581pub 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        // #1179: a live-listed model must be priced from the live table (Live),
605        // never from a family heuristic; embedded exact matches keep priority.
606        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        // Embedded exact match wins over a (bogus) live row for the same key.
643        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        // #1189: negotiated enterprise rates. A custom model name unknown to
659        // any catalog prices exactly; an embedded row is overridden in place.
660        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), // committed-use discount
674                    ..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        // Anthropic wire ids use dashes ("claude-sonnet-4-5"); the table keys
710        // use dots. Same model — must book as Exact list price, not Heuristic.
711        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        // Dash-digit names that are NOT versions stay untouched.
727        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        // enterprise#14: Foundry deployment names carry suffixes; the family
774        // heuristics must land on the right (cheap) tier — mispricing the
775        // downgrade target would corrupt the savings evidence.
776        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        // env override wins over everything.
799        assert_eq!(
800            resolve_model("cursor", Some("gpt-5.4"), Some("claude-opus-4.5")),
801            "gpt-5.4"
802        );
803        // configured model used when no env override.
804        assert_eq!(
805            resolve_model("cursor", None, Some("claude-opus-4.5")),
806            "claude-opus-4.5"
807        );
808        // client/agent string is the final hint.
809        assert_eq!(
810            resolve_model("claude-haiku-4.5", None, None),
811            "claude-haiku-4.5"
812        );
813        // blanks are ignored at each level.
814        assert_eq!(resolve_model("cursor", Some("  "), Some("  ")), "cursor");
815    }
816}