use std::collections::HashMap;
use std::sync::{Arc, OnceLock, RwLock};
use serde::{Deserialize, Serialize};
use super::model_pricing::ModelCost;
const REFRESH_INTERVAL_SECS: u64 = 12 * 60 * 60;
const CACHE_FILE: &str = "model-prices.json";
const MODELS_URL: &str = "https://openrouter.ai/api/v1/models";
const LITELLM_URL: &str =
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LivePriceTable {
pub fetched_at: u64,
pub models: HashMap<String, ModelCost>,
}
impl LivePriceTable {
#[must_use]
pub fn len(&self) -> usize {
self.models.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.models.is_empty()
}
}
fn snapshot() -> &'static RwLock<Option<Arc<LivePriceTable>>> {
static SNAP: OnceLock<RwLock<Option<Arc<LivePriceTable>>>> = OnceLock::new();
SNAP.get_or_init(|| RwLock::new(None))
}
fn enabled() -> bool {
let v = std::env::var("LEAN_CTX_LIVE_PRICING").unwrap_or_default();
!matches!(
v.trim().to_ascii_lowercase().as_str(),
"off" | "0" | "false" | "no"
)
}
#[must_use]
pub fn lookup(model: &str) -> Option<(String, ModelCost)> {
if !enabled() {
return None;
}
let guard = snapshot()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let table = guard.as_ref()?;
for key in lookup_candidates(model) {
if let Some(cost) = table.models.get(&key) {
return Some((key, *cost));
}
}
None
}
pub fn ensure_loaded() -> usize {
if !enabled() {
return 0;
}
{
let guard = snapshot()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(t) = guard.as_ref() {
return t.len();
}
}
let Some(table) = load_cache_file() else {
return 0;
};
let len = table.len();
install(table);
len
}
pub fn install(table: LivePriceTable) {
let mut guard = snapshot()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = Some(Arc::new(table));
}
#[must_use]
pub fn status() -> Option<(u64, usize)> {
if !enabled() {
return None;
}
let guard = snapshot()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.as_ref().map(|t| (t.fetched_at, t.len()))
}
#[cfg(test)]
pub fn clear_for_tests() {
let mut guard = snapshot()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = None;
}
fn cache_path() -> Option<std::path::PathBuf> {
crate::core::paths::cache_dir()
.ok()
.map(|d| d.join(CACHE_FILE))
}
fn load_cache_file() -> Option<LivePriceTable> {
let path = cache_path()?;
let raw = std::fs::read(path).ok()?;
let table: LivePriceTable = serde_json::from_slice(&raw).ok()?;
if table.is_empty() { None } else { Some(table) }
}
fn store_cache_file(table: &LivePriceTable) {
let Some(path) = cache_path() else { return };
if let Some(dir) = path.parent()
&& std::fs::create_dir_all(dir).is_err()
{
return;
}
let Ok(json) = serde_json::to_vec(table) else {
return;
};
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
async fn fetch_catalog(
client: &reqwest::Client,
url: &str,
parse: fn(&serde_json::Value) -> HashMap<String, ModelCost>,
) -> anyhow::Result<HashMap<String, ModelCost>> {
let body = client
.get(url)
.timeout(std::time::Duration::from_secs(30))
.send()
.await?
.error_for_status()?
.bytes()
.await?;
let json: serde_json::Value = serde_json::from_slice(&body)?;
let map = parse(&json);
anyhow::ensure!(!map.is_empty(), "catalog {url} parsed empty");
Ok(map)
}
pub async fn refresh_now(client: &reqwest::Client) -> anyhow::Result<usize> {
let (openrouter, litellm) = tokio::join!(
fetch_catalog(client, MODELS_URL, parse_openrouter_models),
fetch_catalog(client, LITELLM_URL, parse_litellm_models),
);
let mut models = match &openrouter {
Ok(map) => map.clone(),
Err(e) => {
tracing::warn!("OpenRouter price catalog unavailable: {e:#}");
HashMap::new()
}
};
match &litellm {
Ok(map) => {
for (k, v) in map {
models.entry(k.clone()).or_insert(*v);
}
}
Err(e) => tracing::warn!("LiteLLM price catalog unavailable: {e:#}"),
}
anyhow::ensure!(
!models.is_empty(),
"no price catalog reachable (OpenRouter: {}, LiteLLM: {})",
openrouter
.as_ref()
.map_or_else(ToString::to_string, |m| format!("{} keys", m.len())),
litellm
.as_ref()
.map_or_else(ToString::to_string, |m| format!("{} keys", m.len())),
);
let table = LivePriceTable {
fetched_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()),
models,
};
let len = table.len();
store_cache_file(&table);
install(table);
Ok(len)
}
pub fn spawn_background_refresh() {
static SPAWNED: OnceLock<()> = OnceLock::new();
if !enabled() {
return;
}
ensure_loaded();
if SPAWNED.set(()).is_err() {
return;
}
tokio::spawn(async {
let client = reqwest::Client::new();
loop {
let stale = {
let guard = snapshot()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.as_ref().is_none_or(|t| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
now.saturating_sub(t.fetched_at) >= REFRESH_INTERVAL_SECS
})
};
if stale {
match refresh_now(&client).await {
Ok(n) => tracing::info!("live model pricing refreshed ({n} lookup keys)"),
Err(e) => tracing::warn!(
"live model pricing refresh failed (keeping previous table): {e:#}"
),
}
}
tokio::time::sleep(std::time::Duration::from_secs(REFRESH_INTERVAL_SECS / 12)).await;
}
});
}
fn canon(s: &str) -> String {
s.trim().to_lowercase().replace([' ', '.'], "-")
}
fn strip_date_suffix(s: &str) -> Option<&str> {
let (base, tail) = s.rsplit_once('-')?;
if tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit()) {
Some(base)
} else {
None
}
}
fn lookup_candidates(model: &str) -> Vec<String> {
let full = canon(model);
if full.is_empty() {
return Vec::new();
}
let mut out = vec![full.clone()];
let mut push = |s: String| {
if !s.is_empty() && !out.contains(&s) {
out.push(s);
}
};
let no_vendor = full.split_once('/').map(|(_, m)| m.to_string());
if let Some(nv) = &no_vendor {
push(nv.clone());
}
for base in [Some(full.as_str()), no_vendor.as_deref()]
.into_iter()
.flatten()
{
let no_variant = base.split(':').next().unwrap_or(base);
push(no_variant.to_string());
if let Some(no_date) = strip_date_suffix(no_variant) {
push(no_date.to_string());
}
}
out
}
fn index_keys(id: &str) -> (Vec<String>, bool) {
let full = canon(id);
let has_variant = full.contains(':');
let mut keys = vec![full.clone()];
let mut push = |s: String| {
if !s.is_empty() && !keys.contains(&s) {
keys.push(s);
}
};
let no_vendor = full.split_once('/').map(|(_, m)| m.to_string());
if let Some(nv) = &no_vendor {
push(nv.clone());
}
for base in [Some(full.as_str()), no_vendor.as_deref()]
.into_iter()
.flatten()
{
let no_variant = base.split(':').next().unwrap_or(base);
push(no_variant.to_string());
if let Some(no_date) = strip_date_suffix(no_variant) {
push(no_date.to_string());
}
}
(keys, has_variant)
}
fn per_mtok(pricing: &serde_json::Value, field: &str) -> Option<f64> {
let v = pricing.get(field)?;
let n = v
.as_str()
.map_or_else(|| v.as_f64(), |s| s.trim().parse::<f64>().ok())?;
if n.is_finite() && n >= 0.0 {
Some(n * 1_000_000.0)
} else {
None
}
}
fn parse_openrouter_models(json: &serde_json::Value) -> HashMap<String, ModelCost> {
let mut map: HashMap<String, ModelCost> = HashMap::new();
let Some(data) = json.get("data").and_then(serde_json::Value::as_array) else {
return map;
};
let mut deferred: Vec<(&serde_json::Value, &str)> = Vec::new();
let absorb = |map: &mut HashMap<String, ModelCost>, m: &serde_json::Value, id: &str| {
let Some(pricing) = m.get("pricing") else {
return;
};
let (Some(input), Some(output)) =
(per_mtok(pricing, "prompt"), per_mtok(pricing, "completion"))
else {
return;
};
let cost = ModelCost {
input_per_m: input,
output_per_m: output,
cache_write_per_m: per_mtok(pricing, "input_cache_write").unwrap_or(input),
cache_read_per_m: per_mtok(pricing, "input_cache_read").unwrap_or(input),
};
let (keys, _) = index_keys(id);
for key in keys {
map.entry(key).or_insert(cost);
}
if let Some(slug) = m.get("canonical_slug").and_then(serde_json::Value::as_str) {
let (slug_keys, _) = index_keys(slug);
for key in slug_keys {
map.entry(key).or_insert(cost);
}
}
};
for m in data {
let Some(id) = m.get("id").and_then(serde_json::Value::as_str) else {
continue;
};
if canon(id).contains(':') {
deferred.push((m, id));
} else {
absorb(&mut map, m, id);
}
}
for (m, id) in deferred {
absorb(&mut map, m, id);
}
map
}
fn litellm_per_mtok(entry: &serde_json::Value, field: &str) -> Option<f64> {
let n = entry.get(field)?.as_f64()?;
if n.is_finite() && n >= 0.0 {
Some(n * 1_000_000.0)
} else {
None
}
}
fn parse_litellm_models(json: &serde_json::Value) -> HashMap<String, ModelCost> {
let mut map: HashMap<String, ModelCost> = HashMap::new();
let Some(entries) = json.as_object() else {
return map;
};
for (key, entry) in entries {
if key == "sample_spec" || !entry.is_object() {
continue;
}
let Some(input) = litellm_per_mtok(entry, "input_cost_per_token") else {
continue;
};
let output = match litellm_per_mtok(entry, "output_cost_per_token") {
Some(o) => o,
None if entry.get("mode").and_then(serde_json::Value::as_str) == Some("embedding") => {
0.0
}
None => continue,
};
let cost = ModelCost {
input_per_m: input,
output_per_m: output,
cache_write_per_m: litellm_per_mtok(entry, "cache_creation_input_token_cost")
.unwrap_or(input),
cache_read_per_m: litellm_per_mtok(entry, "cache_read_input_token_cost")
.unwrap_or(input),
};
let (keys, _) = index_keys(key);
for k in keys {
map.entry(k).or_insert(cost);
}
}
map
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> serde_json::Value {
serde_json::json!({
"data": [
{
"id": "deepseek/deepseek-v4-flash",
"canonical_slug": "deepseek/deepseek-v4-flash-20260423",
"pricing": {"prompt": "0.00000007", "completion": "0.00000028",
"input_cache_read": "0.000000007"}
},
{
"id": "anthropic/claude-sonnet-5",
"canonical_slug": "anthropic/claude-sonnet-5-20260630",
"pricing": {"prompt": "0.000002", "completion": "0.00001",
"web_search": "0.01",
"input_cache_read": "0.0000002",
"input_cache_write": "0.0000025"}
},
{
"id": "poolside/laguna-xs-2.1:free",
"canonical_slug": "poolside/laguna-xs-2.1-20260625",
"pricing": {"prompt": "0", "completion": "0"}
},
{
"id": "poolside/laguna-xs-2.1",
"canonical_slug": "poolside/laguna-xs-2.1-20260625",
"pricing": {"prompt": "0.00000006", "completion": "0.00000012"}
},
{"id": "broken/no-pricing"}
]
})
}
#[test]
fn parses_usd_per_token_strings_into_per_mtok() {
let map = parse_openrouter_models(&fixture());
let flash = map.get("deepseek/deepseek-v4-flash").expect("indexed");
assert!((flash.input_per_m - 0.07).abs() < 1e-9);
assert!((flash.output_per_m - 0.28).abs() < 1e-9);
assert!((flash.cache_read_per_m - 0.007).abs() < 1e-9);
assert!((flash.cache_write_per_m - 0.07).abs() < 1e-9);
assert!(!map.contains_key("broken/no-pricing"));
}
#[test]
fn date_stamped_and_vendor_prefixed_names_resolve() {
let map = parse_openrouter_models(&fixture());
for name in [
"deepseek/deepseek-v4-flash-20260423",
"deepseek-v4-flash-20260423",
"deepseek-v4-flash",
] {
let mut found = false;
for key in lookup_candidates(name) {
if map.contains_key(&key) {
found = true;
break;
}
}
assert!(found, "{name} must resolve against the live table");
}
}
#[test]
fn free_variant_never_hijacks_the_paid_model_key() {
let map = parse_openrouter_models(&fixture());
let paid = map
.get("poolside/laguna-xs-2-1")
.expect("paid model indexed");
assert!(
paid.input_per_m > 0.0,
"canonical key must carry the paid price"
);
let free = map
.get("poolside/laguna-xs-2-1:free")
.expect("variant indexed");
assert_eq!(
free.input_per_m, 0.0,
"the :free variant stays free under its full name"
);
}
#[test]
fn dot_dash_and_case_unify() {
assert_eq!(canon("Claude-Opus-4.5"), "claude-opus-4-5");
assert_eq!(
strip_date_suffix("deepseek-v4-flash-20260423"),
Some("deepseek-v4-flash")
);
assert_eq!(
strip_date_suffix("claude-opus-4-5"),
None,
"short numeric tails are versions"
);
assert_eq!(strip_date_suffix("no-date"), None);
}
fn litellm_fixture() -> serde_json::Value {
serde_json::json!({
"sample_spec": {
"input_cost_per_token": 0.0,
"output_cost_per_token": 0.0,
"mode": "one of: chat, embedding, completion, …"
},
"azure/gpt-4o": {
"input_cost_per_token": 2.5e-6,
"output_cost_per_token": 1e-5,
"cache_read_input_token_cost": 1.25e-6,
"mode": "chat",
"litellm_provider": "azure"
},
"bedrock/anthropic.claude-sonnet-4-5": {
"input_cost_per_token": 3e-6,
"output_cost_per_token": 1.5e-5,
"cache_creation_input_token_cost": 3.75e-6,
"cache_read_input_token_cost": 3e-7,
"mode": "chat"
},
"text-embedding-3-small": {
"input_cost_per_token": 2e-8,
"mode": "embedding"
},
"vertex_ai/imagegeneration": {
"output_cost_per_image": 0.02,
"mode": "image_generation"
}
})
}
#[test]
fn litellm_map_parses_prefixes_embeddings_and_skips_junk() {
let map = parse_litellm_models(&litellm_fixture());
let azure = map.get("azure/gpt-4o").expect("prefixed key");
assert!((azure.input_per_m - 2.5).abs() < 1e-9);
assert!((azure.output_per_m - 10.0).abs() < 1e-9);
assert!((azure.cache_read_per_m - 1.25).abs() < 1e-9);
assert!(map.contains_key("gpt-4o"), "bare key indexed too");
let bedrock = map
.get("bedrock/anthropic-claude-sonnet-4-5")
.expect("bedrock key (canon: dots→dashes)");
assert!((bedrock.cache_write_per_m - 3.75).abs() < 1e-9);
let emb = map.get("text-embedding-3-small").expect("embedding");
assert!((emb.input_per_m - 0.02).abs() < 1e-9);
assert!((emb.output_per_m - 0.0).abs() < f64::EPSILON);
assert!(!map.contains_key("sample_spec"));
assert!(!map.contains_key("vertex_ai/imagegeneration"));
}
#[test]
fn merged_table_lets_openrouter_win_and_litellm_fill_gaps() {
let mut merged = parse_openrouter_models(&fixture());
for (k, v) in parse_litellm_models(&litellm_fixture()) {
merged.entry(k).or_insert(v);
}
assert!(merged.contains_key("azure/gpt-4o"));
let flash = merged
.get("deepseek/deepseek-v4-flash")
.expect("openrouter");
assert!((flash.input_per_m - 0.07).abs() < 1e-9);
}
#[test]
fn snapshot_lookup_respects_kill_switch_and_install() {
let _lock = crate::core::data_dir::test_env_lock();
clear_for_tests();
assert!(
lookup("deepseek/deepseek-v4-flash").is_none(),
"empty snapshot"
);
install(LivePriceTable {
fetched_at: 1,
models: parse_openrouter_models(&fixture()),
});
let (_, cost) = lookup("deepseek/deepseek-v4-flash-20260423").expect("live hit");
assert!((cost.input_per_m - 0.07).abs() < 1e-9);
crate::test_env::set_var("LEAN_CTX_LIVE_PRICING", "off");
assert!(
lookup("deepseek/deepseek-v4-flash").is_none(),
"kill switch"
);
crate::test_env::remove_var("LEAN_CTX_LIVE_PRICING");
clear_for_tests();
}
}