use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::Duration;
use reqwest::Client;
use serde::Deserialize;
const SOURCE_URL: &str =
"https://raw.githubusercontent.com/unhappychoice/splashboard/main/data/llm_pricing.json";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Default)]
pub struct Price {
#[serde(default)]
pub input: f64,
#[serde(default)]
pub output: f64,
#[serde(default)]
pub cache_read: f64,
#[serde(default)]
pub cache_write_5m: f64,
#[serde(default)]
pub cache_write_1h: f64,
}
pub type PriceMap = HashMap<String, Price>;
pub async fn fetch_pricing(http: &Client) -> PriceMap {
fetch_remote(http)
.await
.unwrap_or_else(|_| embedded_floor())
}
async fn fetch_remote(http: &Client) -> Result<PriceMap, String> {
let res = http
.get(SOURCE_URL)
.timeout(REQUEST_TIMEOUT)
.send()
.await
.map_err(|e| format!("request: {e}"))?;
if !res.status().is_success() {
return Err(format!("HTTP {}", res.status()));
}
let body: Snapshot = res.json().await.map_err(|e| format!("parse: {e}"))?;
Ok(body.models)
}
#[derive(Deserialize)]
struct Snapshot {
#[serde(default)]
models: PriceMap,
}
pub fn price_for<'a>(prices: &'a PriceMap, model: &str) -> Option<&'a Price> {
let lower = model.to_lowercase();
if let Some(p) = prices.get(&lower) {
return Some(p);
}
prices
.iter()
.filter(|(k, _)| !k.is_empty() && lower.starts_with(&k.to_lowercase()))
.max_by_key(|(k, _)| k.len())
.map(|(_, p)| p)
}
pub fn cost_usd(
prices: &PriceMap,
model: &str,
input_tokens: u64,
output_tokens: u64,
cache_read: u64,
cache_write_5m: u64,
cache_write_1h: u64,
) -> f64 {
let Some(p) = price_for(prices, model) else {
return 0.0;
};
let per_token = |rate: f64, count: u64| (count as f64) * rate / 1_000_000.0;
per_token(p.input, input_tokens)
+ per_token(p.output, output_tokens)
+ per_token(p.cache_read, cache_read)
+ per_token(p.cache_write_5m, cache_write_5m)
+ per_token(p.cache_write_1h, cache_write_1h)
}
pub fn embedded_floor() -> PriceMap {
static FLOOR: OnceLock<PriceMap> = OnceLock::new();
FLOOR
.get_or_init(|| {
let mut m = PriceMap::new();
m.insert(
"gpt-5".into(),
Price {
input: 1.25,
output: 10.0,
cache_read: 0.125,
..Default::default()
},
);
m.insert(
"gpt-5-mini".into(),
Price {
input: 0.25,
output: 2.0,
cache_read: 0.025,
..Default::default()
},
);
m.insert(
"claude-opus-4-7".into(),
Price {
input: 5.0,
output: 25.0,
cache_read: 0.5,
cache_write_5m: 6.25,
cache_write_1h: 10.0,
},
);
m.insert(
"claude-sonnet-4-6".into(),
Price {
input: 3.0,
output: 15.0,
cache_read: 0.3,
cache_write_5m: 3.75,
cache_write_1h: 6.0,
},
);
m.insert(
"claude-haiku-4-5".into(),
Price {
input: 1.0,
output: 5.0,
cache_read: 0.1,
cache_write_5m: 1.25,
cache_write_1h: 2.0,
},
);
m
})
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_prices() -> PriceMap {
let mut m = PriceMap::new();
m.insert(
"gpt-5".into(),
Price {
input: 1.25,
output: 10.0,
cache_read: 0.125,
..Default::default()
},
);
m.insert(
"gpt-5-mini".into(),
Price {
input: 0.25,
output: 2.0,
cache_read: 0.025,
..Default::default()
},
);
m.insert(
"claude-opus-4-7".into(),
Price {
input: 5.0,
output: 25.0,
cache_read: 0.5,
cache_write_5m: 6.25,
cache_write_1h: 10.0,
},
);
m
}
#[test]
fn price_for_exact_match_wins() {
let p = sample_prices();
assert_eq!(price_for(&p, "gpt-5").map(|x| x.input), Some(1.25));
}
#[test]
fn price_for_falls_back_to_longest_prefix() {
let p = sample_prices();
let row = price_for(&p, "gpt-5-2025-08-12").expect("must resolve via prefix");
assert_eq!(row.input, 1.25);
}
#[test]
fn price_for_longest_prefix_wins_over_shorter() {
let p = sample_prices();
let row = price_for(&p, "gpt-5-mini-2025-08-12").expect("must resolve via prefix");
assert_eq!(row.input, 0.25);
}
#[test]
fn price_for_is_case_insensitive() {
let p = sample_prices();
assert!(price_for(&p, "GPT-5").is_some());
assert!(price_for(&p, "Claude-Opus-4-7").is_some());
}
#[test]
fn price_for_unknown_model_returns_none() {
let p = sample_prices();
assert!(price_for(&p, "mistral-large").is_none());
assert!(price_for(&p, "").is_none());
}
#[test]
fn cost_usd_zero_for_unknown_model_even_with_tokens() {
let p = sample_prices();
assert_eq!(
cost_usd(&p, "mistral-large", 1_000_000, 1_000_000, 0, 0, 0),
0.0
);
}
#[test]
fn cost_usd_sums_per_tier_rates() {
let p = sample_prices();
let c = cost_usd(&p, "gpt-5", 1_000_000, 1_000_000, 0, 0, 0);
assert!((c - 11.25).abs() < 1e-9, "expected $11.25, got {c}");
}
#[test]
fn cost_usd_charges_anthropic_cache_write_tiers() {
let p = sample_prices();
let c = cost_usd(&p, "claude-opus-4-7", 0, 0, 0, 0, 1_000_000);
assert!((c - 10.0).abs() < 1e-9, "expected $10, got {c}");
}
#[test]
fn embedded_floor_covers_current_headline_models() {
let f = embedded_floor();
assert!(f.contains_key("gpt-5"));
assert!(f.contains_key("gpt-5-mini"));
assert!(f.contains_key("claude-opus-4-7"));
assert!(f.contains_key("claude-sonnet-4-6"));
assert!(f.contains_key("claude-haiku-4-5"));
}
#[test]
fn snapshot_parses_repo_seed() {
let raw = include_str!("../../data/llm_pricing.json");
let snap: Snapshot = serde_json::from_str(raw).expect("seed must parse");
assert!(snap.models.contains_key("gpt-5"));
assert!(snap.models.contains_key("claude-opus-4-7"));
}
}