Skip to main content

lean_ctx/core/gain/
live_pricing.rs

1//! Live model prices from public provider catalogs (#1179, universal #1189).
2//!
3//! The embedded table in [`super::model_pricing`] can only ever know the
4//! models that existed at release time; anything newer used to fall into a
5//! family heuristic and could be priced an order of magnitude off (a tester's
6//! DeepSeek V4 Flash was billed at 2025 V3 list prices — ~15× too high).
7//!
8//! Two public, key-less catalogs are fetched and merged (#1189):
9//!
10//! 1. `GET https://openrouter.ai/api/v1/models` — ~340 market-priced models
11//!    covering every major vendor, incl. `:free`/`:extended` variants.
12//!    Wins on key conflicts: it is market data refreshed continuously.
13//! 2. The LiteLLM community price map (`model_prices_and_context_window.json`,
14//!    ~2900 entries) — fills everything OpenRouter does not list: `azure/`,
15//!    `bedrock/`, `vertex_ai/`, `groq/`, `mistral/`, embeddings, niche hosts.
16//!
17//! Either source failing is tolerated (partial refresh, fail-open); both
18//! failing keeps the previous table. USD-per-token values are converted to
19//! per-MTok [`ModelCost`] rows and cached on disk. The table is loaded into a
20//! process-wide snapshot **only when a run-mode opts in** (`ensure_loaded` /
21//! `spawn_background_refresh` from the proxy, gateway or spend CLI): plain
22//! CLI tools and the test suite keep the deterministic embedded table.
23//!
24//! Precedence inside [`super::model_pricing::ModelPricing::quote`]:
25//! embedded exact > **live** > heuristic > blended fallback. Live hits are
26//! [`super::model_pricing::PricingMatchKind::Live`] — current market data,
27//! not an estimate.
28//!
29//! Kill switch: `LEAN_CTX_LIVE_PRICING=off|0|false`.
30
31use std::collections::HashMap;
32use std::sync::{Arc, OnceLock, RwLock};
33
34use serde::{Deserialize, Serialize};
35
36use super::model_pricing::ModelCost;
37
38/// Refresh cadence for the background task. Provider price changes are rare
39/// events; half a day keeps drift negligible without hammering the API.
40const REFRESH_INTERVAL_SECS: u64 = 12 * 60 * 60;
41
42/// On-disk cache file, under the lean-ctx cache directory.
43const CACHE_FILE: &str = "model-prices.json";
44
45const MODELS_URL: &str = "https://openrouter.ai/api/v1/models";
46
47/// LiteLLM community price map (#1189) — the de-facto industry catalog for
48/// models OpenRouter does not route (Azure, Bedrock, Vertex, embeddings…).
49const LITELLM_URL: &str =
50    "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
51
52/// A fetched-and-indexed price table. `models` is keyed by canonicalized
53/// lookup keys (see `canon`) — several keys may point at the same cost row.
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct LivePriceTable {
56    /// Unix seconds of the successful fetch that produced this table.
57    pub fetched_at: u64,
58    pub models: HashMap<String, ModelCost>,
59}
60
61impl LivePriceTable {
62    /// Number of distinct lookup keys (not models) in the table.
63    #[must_use]
64    pub fn len(&self) -> usize {
65        self.models.len()
66    }
67
68    #[must_use]
69    pub fn is_empty(&self) -> bool {
70        self.models.is_empty()
71    }
72}
73
74fn snapshot() -> &'static RwLock<Option<Arc<LivePriceTable>>> {
75    static SNAP: OnceLock<RwLock<Option<Arc<LivePriceTable>>>> = OnceLock::new();
76    SNAP.get_or_init(|| RwLock::new(None))
77}
78
79/// True unless the operator disabled live pricing.
80fn enabled() -> bool {
81    let v = std::env::var("LEAN_CTX_LIVE_PRICING").unwrap_or_default();
82    !matches!(
83        v.trim().to_ascii_lowercase().as_str(),
84        "off" | "0" | "false" | "no"
85    )
86}
87
88/// Looks a model up in the current live snapshot. `None` when the snapshot
89/// was never loaded (CLI tools, tests), the kill switch is set, or the model
90/// is genuinely unknown to the provider list.
91#[must_use]
92pub fn lookup(model: &str) -> Option<(String, ModelCost)> {
93    if !enabled() {
94        return None;
95    }
96    let guard = snapshot()
97        .read()
98        .unwrap_or_else(std::sync::PoisonError::into_inner);
99    let table = guard.as_ref()?;
100    for key in lookup_candidates(model) {
101        if let Some(cost) = table.models.get(&key) {
102            return Some((key, *cost));
103        }
104    }
105    None
106}
107
108/// Loads the disk cache into the process snapshot (idempotent, no network).
109/// Returns the number of lookup keys now available. Call sites are the
110/// run-modes that *want* live prices: proxy, gateway server, spend CLI.
111pub fn ensure_loaded() -> usize {
112    if !enabled() {
113        return 0;
114    }
115    {
116        let guard = snapshot()
117            .read()
118            .unwrap_or_else(std::sync::PoisonError::into_inner);
119        if let Some(t) = guard.as_ref() {
120            return t.len();
121        }
122    }
123    let Some(table) = load_cache_file() else {
124        return 0;
125    };
126    let len = table.len();
127    install(table);
128    len
129}
130
131/// Installs a table as the process snapshot (also used by tests).
132pub fn install(table: LivePriceTable) {
133    let mut guard = snapshot()
134        .write()
135        .unwrap_or_else(std::sync::PoisonError::into_inner);
136    *guard = Some(Arc::new(table));
137}
138
139/// `(fetched_at_unix, lookup_keys)` of the active snapshot, for status
140/// surfaces. `None` when live pricing is off or never loaded.
141#[must_use]
142pub fn status() -> Option<(u64, usize)> {
143    if !enabled() {
144        return None;
145    }
146    let guard = snapshot()
147        .read()
148        .unwrap_or_else(std::sync::PoisonError::into_inner);
149    guard.as_ref().map(|t| (t.fetched_at, t.len()))
150}
151
152/// Test-only: clears the process snapshot so other tests see the embedded table.
153#[cfg(test)]
154pub fn clear_for_tests() {
155    let mut guard = snapshot()
156        .write()
157        .unwrap_or_else(std::sync::PoisonError::into_inner);
158    *guard = None;
159}
160
161fn cache_path() -> Option<std::path::PathBuf> {
162    crate::core::paths::cache_dir()
163        .ok()
164        .map(|d| d.join(CACHE_FILE))
165}
166
167fn load_cache_file() -> Option<LivePriceTable> {
168    let path = cache_path()?;
169    let raw = std::fs::read(path).ok()?;
170    let table: LivePriceTable = serde_json::from_slice(&raw).ok()?;
171    if table.is_empty() { None } else { Some(table) }
172}
173
174/// Atomic write (tmp + rename) so a crashed refresh never truncates the cache.
175fn store_cache_file(table: &LivePriceTable) {
176    let Some(path) = cache_path() else { return };
177    if let Some(dir) = path.parent()
178        && std::fs::create_dir_all(dir).is_err()
179    {
180        return;
181    }
182    let Ok(json) = serde_json::to_vec(table) else {
183        return;
184    };
185    let tmp = path.with_extension("json.tmp");
186    if std::fs::write(&tmp, json).is_ok() {
187        let _ = std::fs::rename(&tmp, &path);
188    }
189}
190
191/// Fetches one catalog URL and parses it into a lookup map.
192async fn fetch_catalog(
193    client: &reqwest::Client,
194    url: &str,
195    parse: fn(&serde_json::Value) -> HashMap<String, ModelCost>,
196) -> anyhow::Result<HashMap<String, ModelCost>> {
197    let body = client
198        .get(url)
199        .timeout(std::time::Duration::from_secs(30))
200        .send()
201        .await?
202        .error_for_status()?
203        .bytes()
204        .await?;
205    let json: serde_json::Value = serde_json::from_slice(&body)?;
206    let map = parse(&json);
207    anyhow::ensure!(!map.is_empty(), "catalog {url} parsed empty");
208    Ok(map)
209}
210
211/// Fetches both public catalogs, merges them (OpenRouter wins on conflicts —
212/// market data including variants; LiteLLM fills the gaps: Azure, Bedrock,
213/// Vertex, embeddings, niche hosts, #1189) and swaps the snapshot + disk
214/// cache. One source failing is tolerated; the refresh only errors when *no*
215/// source delivered anything.
216///
217/// # Errors
218/// Network / decode errors propagate; the caller decides whether they matter
219/// (the background task just logs and keeps the previous table — fail-open).
220pub async fn refresh_now(client: &reqwest::Client) -> anyhow::Result<usize> {
221    let (openrouter, litellm) = tokio::join!(
222        fetch_catalog(client, MODELS_URL, parse_openrouter_models),
223        fetch_catalog(client, LITELLM_URL, parse_litellm_models),
224    );
225
226    let mut models = match &openrouter {
227        Ok(map) => map.clone(),
228        Err(e) => {
229            tracing::warn!("OpenRouter price catalog unavailable: {e:#}");
230            HashMap::new()
231        }
232    };
233    match &litellm {
234        // `or_insert`: OpenRouter keys keep priority, LiteLLM extends coverage.
235        Ok(map) => {
236            for (k, v) in map {
237                models.entry(k.clone()).or_insert(*v);
238            }
239        }
240        Err(e) => tracing::warn!("LiteLLM price catalog unavailable: {e:#}"),
241    }
242    anyhow::ensure!(
243        !models.is_empty(),
244        "no price catalog reachable (OpenRouter: {}, LiteLLM: {})",
245        openrouter
246            .as_ref()
247            .map_or_else(ToString::to_string, |m| format!("{} keys", m.len())),
248        litellm
249            .as_ref()
250            .map_or_else(ToString::to_string, |m| format!("{} keys", m.len())),
251    );
252
253    let table = LivePriceTable {
254        fetched_at: std::time::SystemTime::now()
255            .duration_since(std::time::UNIX_EPOCH)
256            .map_or(0, |d| d.as_secs()),
257        models,
258    };
259    let len = table.len();
260    store_cache_file(&table);
261    install(table);
262    Ok(len)
263}
264
265/// Loads the disk cache immediately, then keeps the table fresh in the
266/// background (first fetch runs at once when the cache is missing or older
267/// than the refresh interval). Never blocks or fails the caller; idempotent —
268/// a process embedding both proxy and gateway spawns exactly one refresher.
269pub fn spawn_background_refresh() {
270    static SPAWNED: OnceLock<()> = OnceLock::new();
271    if !enabled() {
272        return;
273    }
274    ensure_loaded();
275    if SPAWNED.set(()).is_err() {
276        return;
277    }
278    tokio::spawn(async {
279        let client = reqwest::Client::new();
280        loop {
281            let stale = {
282                let guard = snapshot()
283                    .read()
284                    .unwrap_or_else(std::sync::PoisonError::into_inner);
285                guard.as_ref().is_none_or(|t| {
286                    let now = std::time::SystemTime::now()
287                        .duration_since(std::time::UNIX_EPOCH)
288                        .map_or(0, |d| d.as_secs());
289                    now.saturating_sub(t.fetched_at) >= REFRESH_INTERVAL_SECS
290                })
291            };
292            if stale {
293                match refresh_now(&client).await {
294                    Ok(n) => tracing::info!("live model pricing refreshed ({n} lookup keys)"),
295                    Err(e) => tracing::warn!(
296                        "live model pricing refresh failed (keeping previous table): {e:#}"
297                    ),
298                }
299            }
300            tokio::time::sleep(std::time::Duration::from_secs(REFRESH_INTERVAL_SECS / 12)).await;
301        }
302    });
303}
304
305/// Canonical lookup form: lowercase, `.`→`-`, whitespace→`-`. Applied to both
306/// index keys and queries so `claude-opus-4.5` and `claude-opus-4-5` unify.
307fn canon(s: &str) -> String {
308    s.trim().to_lowercase().replace([' ', '.'], "-")
309}
310
311/// Strips a trailing `-YYYYMMDD` date stamp (`deepseek-v4-flash-20260423`).
312fn strip_date_suffix(s: &str) -> Option<&str> {
313    let (base, tail) = s.rsplit_once('-')?;
314    if tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit()) {
315        Some(base)
316    } else {
317        None
318    }
319}
320
321/// Ordered candidate keys for a query: exact canon first, then progressively
322/// vendor-stripped / variant-stripped / date-stripped forms.
323fn lookup_candidates(model: &str) -> Vec<String> {
324    let full = canon(model);
325    if full.is_empty() {
326        return Vec::new();
327    }
328    let mut out = vec![full.clone()];
329    let mut push = |s: String| {
330        if !s.is_empty() && !out.contains(&s) {
331            out.push(s);
332        }
333    };
334    let no_vendor = full.split_once('/').map(|(_, m)| m.to_string());
335    if let Some(nv) = &no_vendor {
336        push(nv.clone());
337    }
338    for base in [Some(full.as_str()), no_vendor.as_deref()]
339        .into_iter()
340        .flatten()
341    {
342        let no_variant = base.split(':').next().unwrap_or(base);
343        push(no_variant.to_string());
344        if let Some(no_date) = strip_date_suffix(no_variant) {
345            push(no_date.to_string());
346        }
347    }
348    out
349}
350
351/// Index keys for one provider model id/slug. Mirrors [`lookup_candidates`]
352/// so every form a client might send resolves. `with_variant` ids (`:free`)
353/// only claim the variant-less keys when nothing else owns them.
354fn index_keys(id: &str) -> (Vec<String>, bool) {
355    let full = canon(id);
356    let has_variant = full.contains(':');
357    let mut keys = vec![full.clone()];
358    let mut push = |s: String| {
359        if !s.is_empty() && !keys.contains(&s) {
360            keys.push(s);
361        }
362    };
363    let no_vendor = full.split_once('/').map(|(_, m)| m.to_string());
364    if let Some(nv) = &no_vendor {
365        push(nv.clone());
366    }
367    for base in [Some(full.as_str()), no_vendor.as_deref()]
368        .into_iter()
369        .flatten()
370    {
371        let no_variant = base.split(':').next().unwrap_or(base);
372        push(no_variant.to_string());
373        if let Some(no_date) = strip_date_suffix(no_variant) {
374            push(no_date.to_string());
375        }
376    }
377    (keys, has_variant)
378}
379
380/// USD-per-token decimal string → USD per MTok. `None` for absent/invalid.
381fn per_mtok(pricing: &serde_json::Value, field: &str) -> Option<f64> {
382    let v = pricing.get(field)?;
383    let n = v
384        .as_str()
385        .map_or_else(|| v.as_f64(), |s| s.trim().parse::<f64>().ok())?;
386    if n.is_finite() && n >= 0.0 {
387        Some(n * 1_000_000.0)
388    } else {
389        None
390    }
391}
392
393/// Parses the OpenRouter `GET /api/v1/models` payload into the lookup map.
394///
395/// Pricing fields are USD-per-token strings; absent cache fields mean "no
396/// separate cache pricing" — those tokens bill at the input rate (the same
397/// convention the embedded table uses for Gemini/Foundry). `web_search` and
398/// other per-request fees are not token prices and are ignored.
399fn parse_openrouter_models(json: &serde_json::Value) -> HashMap<String, ModelCost> {
400    let mut map: HashMap<String, ModelCost> = HashMap::new();
401    let Some(data) = json.get("data").and_then(serde_json::Value::as_array) else {
402        return map;
403    };
404
405    // Two passes: variant-less ids first so `model:free` never hijacks the
406    // canonical `model` key; variants still resolve under their full name.
407    let mut deferred: Vec<(&serde_json::Value, &str)> = Vec::new();
408    let absorb = |map: &mut HashMap<String, ModelCost>, m: &serde_json::Value, id: &str| {
409        let Some(pricing) = m.get("pricing") else {
410            return;
411        };
412        let (Some(input), Some(output)) =
413            (per_mtok(pricing, "prompt"), per_mtok(pricing, "completion"))
414        else {
415            return;
416        };
417        let cost = ModelCost {
418            input_per_m: input,
419            output_per_m: output,
420            cache_write_per_m: per_mtok(pricing, "input_cache_write").unwrap_or(input),
421            cache_read_per_m: per_mtok(pricing, "input_cache_read").unwrap_or(input),
422        };
423        let (keys, _) = index_keys(id);
424        for key in keys {
425            map.entry(key).or_insert(cost);
426        }
427        // The dated canonical slug resolves date-stamped client model names
428        // (`deepseek-v4-flash-20260423`) even when the id carries no date.
429        if let Some(slug) = m.get("canonical_slug").and_then(serde_json::Value::as_str) {
430            let (slug_keys, _) = index_keys(slug);
431            for key in slug_keys {
432                map.entry(key).or_insert(cost);
433            }
434        }
435    };
436
437    for m in data {
438        let Some(id) = m.get("id").and_then(serde_json::Value::as_str) else {
439            continue;
440        };
441        if canon(id).contains(':') {
442            deferred.push((m, id));
443        } else {
444            absorb(&mut map, m, id);
445        }
446    }
447    for (m, id) in deferred {
448        absorb(&mut map, m, id);
449    }
450    map
451}
452
453/// A LiteLLM USD-per-token number field → USD per MTok. Unlike OpenRouter's
454/// string prices these are plain JSON numbers; `null`/absent → `None`.
455fn litellm_per_mtok(entry: &serde_json::Value, field: &str) -> Option<f64> {
456    let n = entry.get(field)?.as_f64()?;
457    if n.is_finite() && n >= 0.0 {
458        Some(n * 1_000_000.0)
459    } else {
460        None
461    }
462}
463
464/// Parses the LiteLLM `model_prices_and_context_window.json` map (#1189).
465///
466/// Top level is `{ "<model-key>": { input_cost_per_token, output_cost_per_token,
467/// cache_read_input_token_cost, cache_creation_input_token_cost, mode, … } }`.
468/// Keys carry LiteLLM's routing prefixes (`azure/gpt-4o`,
469/// `bedrock/anthropic.claude-…`) which [`index_keys`] also resolves bare.
470/// `sample_spec` is documentation, not a model. Entries priced per request /
471/// per image / per second (no token prices) are skipped — the meter prices
472/// tokens. Embedding rows (output cost 0) are kept: their input side is real.
473fn parse_litellm_models(json: &serde_json::Value) -> HashMap<String, ModelCost> {
474    let mut map: HashMap<String, ModelCost> = HashMap::new();
475    let Some(entries) = json.as_object() else {
476        return map;
477    };
478    for (key, entry) in entries {
479        if key == "sample_spec" || !entry.is_object() {
480            continue;
481        }
482        let Some(input) = litellm_per_mtok(entry, "input_cost_per_token") else {
483            continue;
484        };
485        // Embeddings legitimately have no output price — bill output at 0
486        // only when the mode says so; chat rows without output cost are junk.
487        let output = match litellm_per_mtok(entry, "output_cost_per_token") {
488            Some(o) => o,
489            None if entry.get("mode").and_then(serde_json::Value::as_str) == Some("embedding") => {
490                0.0
491            }
492            None => continue,
493        };
494        let cost = ModelCost {
495            input_per_m: input,
496            output_per_m: output,
497            cache_write_per_m: litellm_per_mtok(entry, "cache_creation_input_token_cost")
498                .unwrap_or(input),
499            cache_read_per_m: litellm_per_mtok(entry, "cache_read_input_token_cost")
500                .unwrap_or(input),
501        };
502        let (keys, _) = index_keys(key);
503        for k in keys {
504            map.entry(k).or_insert(cost);
505        }
506    }
507    map
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    fn fixture() -> serde_json::Value {
515        serde_json::json!({
516            "data": [
517                {
518                    "id": "deepseek/deepseek-v4-flash",
519                    "canonical_slug": "deepseek/deepseek-v4-flash-20260423",
520                    "pricing": {"prompt": "0.00000007", "completion": "0.00000028",
521                                 "input_cache_read": "0.000000007"}
522                },
523                {
524                    "id": "anthropic/claude-sonnet-5",
525                    "canonical_slug": "anthropic/claude-sonnet-5-20260630",
526                    "pricing": {"prompt": "0.000002", "completion": "0.00001",
527                                 "web_search": "0.01",
528                                 "input_cache_read": "0.0000002",
529                                 "input_cache_write": "0.0000025"}
530                },
531                {
532                    "id": "poolside/laguna-xs-2.1:free",
533                    "canonical_slug": "poolside/laguna-xs-2.1-20260625",
534                    "pricing": {"prompt": "0", "completion": "0"}
535                },
536                {
537                    "id": "poolside/laguna-xs-2.1",
538                    "canonical_slug": "poolside/laguna-xs-2.1-20260625",
539                    "pricing": {"prompt": "0.00000006", "completion": "0.00000012"}
540                },
541                {"id": "broken/no-pricing"}
542            ]
543        })
544    }
545
546    #[test]
547    fn parses_usd_per_token_strings_into_per_mtok() {
548        let map = parse_openrouter_models(&fixture());
549        let flash = map.get("deepseek/deepseek-v4-flash").expect("indexed");
550        assert!((flash.input_per_m - 0.07).abs() < 1e-9);
551        assert!((flash.output_per_m - 0.28).abs() < 1e-9);
552        assert!((flash.cache_read_per_m - 0.007).abs() < 1e-9);
553        // No explicit cache write price → bills at the input rate.
554        assert!((flash.cache_write_per_m - 0.07).abs() < 1e-9);
555        assert!(!map.contains_key("broken/no-pricing"));
556    }
557
558    #[test]
559    fn date_stamped_and_vendor_prefixed_names_resolve() {
560        let map = parse_openrouter_models(&fixture());
561        // The exact name Nicolas' client sent (#1179):
562        for name in [
563            "deepseek/deepseek-v4-flash-20260423",
564            "deepseek-v4-flash-20260423",
565            "deepseek-v4-flash",
566        ] {
567            let mut found = false;
568            for key in lookup_candidates(name) {
569                if map.contains_key(&key) {
570                    found = true;
571                    break;
572                }
573            }
574            assert!(found, "{name} must resolve against the live table");
575        }
576    }
577
578    #[test]
579    fn free_variant_never_hijacks_the_paid_model_key() {
580        let map = parse_openrouter_models(&fixture());
581        let paid = map
582            .get("poolside/laguna-xs-2-1")
583            .expect("paid model indexed");
584        assert!(
585            paid.input_per_m > 0.0,
586            "canonical key must carry the paid price"
587        );
588        let free = map
589            .get("poolside/laguna-xs-2-1:free")
590            .expect("variant indexed");
591        assert_eq!(
592            free.input_per_m, 0.0,
593            "the :free variant stays free under its full name"
594        );
595    }
596
597    #[test]
598    fn dot_dash_and_case_unify() {
599        assert_eq!(canon("Claude-Opus-4.5"), "claude-opus-4-5");
600        assert_eq!(
601            strip_date_suffix("deepseek-v4-flash-20260423"),
602            Some("deepseek-v4-flash")
603        );
604        assert_eq!(
605            strip_date_suffix("claude-opus-4-5"),
606            None,
607            "short numeric tails are versions"
608        );
609        assert_eq!(strip_date_suffix("no-date"), None);
610    }
611
612    fn litellm_fixture() -> serde_json::Value {
613        serde_json::json!({
614            "sample_spec": {
615                "input_cost_per_token": 0.0,
616                "output_cost_per_token": 0.0,
617                "mode": "one of: chat, embedding, completion, …"
618            },
619            "azure/gpt-4o": {
620                "input_cost_per_token": 2.5e-6,
621                "output_cost_per_token": 1e-5,
622                "cache_read_input_token_cost": 1.25e-6,
623                "mode": "chat",
624                "litellm_provider": "azure"
625            },
626            "bedrock/anthropic.claude-sonnet-4-5": {
627                "input_cost_per_token": 3e-6,
628                "output_cost_per_token": 1.5e-5,
629                "cache_creation_input_token_cost": 3.75e-6,
630                "cache_read_input_token_cost": 3e-7,
631                "mode": "chat"
632            },
633            "text-embedding-3-small": {
634                "input_cost_per_token": 2e-8,
635                "mode": "embedding"
636            },
637            "vertex_ai/imagegeneration": {
638                "output_cost_per_image": 0.02,
639                "mode": "image_generation"
640            }
641        })
642    }
643
644    #[test]
645    fn litellm_map_parses_prefixes_embeddings_and_skips_junk() {
646        let map = parse_litellm_models(&litellm_fixture());
647
648        // Azure deployment resolves under the prefixed AND the bare name.
649        let azure = map.get("azure/gpt-4o").expect("prefixed key");
650        assert!((azure.input_per_m - 2.5).abs() < 1e-9);
651        assert!((azure.output_per_m - 10.0).abs() < 1e-9);
652        assert!((azure.cache_read_per_m - 1.25).abs() < 1e-9);
653        assert!(map.contains_key("gpt-4o"), "bare key indexed too");
654
655        // Bedrock naming resolves; explicit cache-write price kept.
656        let bedrock = map
657            .get("bedrock/anthropic-claude-sonnet-4-5")
658            .expect("bedrock key (canon: dots→dashes)");
659        assert!((bedrock.cache_write_per_m - 3.75).abs() < 1e-9);
660
661        // Embedding rows are real prices with zero output cost.
662        let emb = map.get("text-embedding-3-small").expect("embedding");
663        assert!((emb.input_per_m - 0.02).abs() < 1e-9);
664        assert!((emb.output_per_m - 0.0).abs() < f64::EPSILON);
665
666        // Documentation stub and per-image rows never enter the table.
667        assert!(!map.contains_key("sample_spec"));
668        assert!(!map.contains_key("vertex_ai/imagegeneration"));
669    }
670
671    #[test]
672    fn merged_table_lets_openrouter_win_and_litellm_fill_gaps() {
673        // Mirrors the merge in `refresh_now`: OpenRouter first, LiteLLM only
674        // fills keys OpenRouter does not own.
675        let mut merged = parse_openrouter_models(&fixture());
676        for (k, v) in parse_litellm_models(&litellm_fixture()) {
677            merged.entry(k).or_insert(v);
678        }
679
680        // Gap-fill: Azure exists only in LiteLLM.
681        assert!(merged.contains_key("azure/gpt-4o"));
682        // OpenRouter ownership survives: the fixture's deepseek price stays.
683        let flash = merged
684            .get("deepseek/deepseek-v4-flash")
685            .expect("openrouter");
686        assert!((flash.input_per_m - 0.07).abs() < 1e-9);
687    }
688
689    #[test]
690    fn snapshot_lookup_respects_kill_switch_and_install() {
691        let _lock = crate::core::data_dir::test_env_lock();
692        clear_for_tests();
693        assert!(
694            lookup("deepseek/deepseek-v4-flash").is_none(),
695            "empty snapshot"
696        );
697
698        install(LivePriceTable {
699            fetched_at: 1,
700            models: parse_openrouter_models(&fixture()),
701        });
702        let (_, cost) = lookup("deepseek/deepseek-v4-flash-20260423").expect("live hit");
703        assert!((cost.input_per_m - 0.07).abs() < 1e-9);
704
705        crate::test_env::set_var("LEAN_CTX_LIVE_PRICING", "off");
706        assert!(
707            lookup("deepseek/deepseek-v4-flash").is_none(),
708            "kill switch"
709        );
710        crate::test_env::remove_var("LEAN_CTX_LIVE_PRICING");
711        clear_for_tests();
712    }
713}