use std::collections::{BTreeMap, HashMap};
use std::sync::LazyLock;
use serde::{Deserialize, Serialize};
const CATALOG_JSON: &str = include_str!("../schemas/catalog.json");
const PRIMARY_PROVIDERS: [&str; 3] = ["anthropic", "google", "openai"];
static PRICING: LazyLock<std::result::Result<HashMap<String, ModelPricing>, String>> =
LazyLock::new(|| registry_from_catalog_str(CATALOG_JSON));
static EMBEDDED_CATALOG_INFO: LazyLock<std::result::Result<CatalogInfo, String>> =
LazyLock::new(|| catalog_info_from_str(CATALOG_JSON, "embedded"));
fn registry() -> Option<&'static HashMap<String, ModelPricing>> {
PRICING.as_ref().ok()
}
#[derive(Debug, Deserialize)]
struct CatalogFile {
#[serde(rename = "$provenance")]
provenance: CatalogProvenance,
#[serde(rename = "$schema_version")]
schema_version: u64,
providers: BTreeMap<String, CatalogProviderRow>,
}
#[derive(Debug, Deserialize)]
struct CatalogProvenance {
source: String,
source_sha256: String,
fetched: String,
library_version: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogInfo {
pub source: String,
pub source_sha256: String,
pub fetched: String,
pub library_version: String,
pub schema_version: u64,
pub origin: String,
}
#[derive(Debug, Deserialize)]
struct CatalogProviderRow {
#[serde(default)]
models: BTreeMap<String, CatalogModelRow>,
}
#[derive(Debug, Deserialize)]
struct CatalogModelRow {
#[serde(default)]
pricing: Option<CatalogPricingRow>,
limit: CatalogLimitRow,
#[serde(default)]
mode: Option<String>,
capabilities: CatalogCapabilitiesRow,
}
#[derive(Debug, Deserialize)]
struct CatalogPricingRow {
input_cost_per_token: f64,
output_cost_per_token: f64,
#[serde(default)]
cache_read_input_token_cost: Option<f64>,
#[serde(default)]
cache_creation_input_token_cost: Option<f64>,
#[serde(default)]
input_cost_per_audio_token: Option<f64>,
#[serde(default)]
output_cost_per_audio_token: Option<f64>,
#[serde(default)]
output_cost_per_reasoning_token: Option<f64>,
#[serde(default)]
tiers: Vec<CatalogPricingTierRow>,
}
#[derive(Debug, Deserialize)]
struct CatalogPricingTierRow {
min_context_tokens: u64,
input_cost_per_token: f64,
output_cost_per_token: f64,
#[serde(default)]
cache_read_input_token_cost: Option<f64>,
#[serde(default)]
cache_creation_input_token_cost: Option<f64>,
#[serde(default)]
input_cost_per_audio_token: Option<f64>,
#[serde(default)]
output_cost_per_audio_token: Option<f64>,
#[serde(default)]
output_cost_per_reasoning_token: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct CatalogLimitRow {
context: u64,
#[serde(default)]
input: Option<u64>,
output: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct CatalogCapabilitiesRow {
vision: bool,
function_calling: bool,
reasoning: bool,
structured_output: bool,
audio_input: bool,
audio_output: bool,
prompt_caching: bool,
}
impl From<&CatalogPricingTierRow> for PricingTier {
fn from(row: &CatalogPricingTierRow) -> Self {
PricingTier {
min_context_tokens: row.min_context_tokens,
input_cost_per_token: row.input_cost_per_token,
output_cost_per_token: row.output_cost_per_token,
cache_read_input_token_cost: row.cache_read_input_token_cost,
cache_creation_input_token_cost: row.cache_creation_input_token_cost,
input_cost_per_audio_token: row.input_cost_per_audio_token,
output_cost_per_audio_token: row.output_cost_per_audio_token,
output_cost_per_reasoning_token: row.output_cost_per_reasoning_token,
}
}
}
fn flatten_model(model: &CatalogModelRow) -> ModelPricing {
let (
input_cost_per_token,
output_cost_per_token,
cache_read_input_token_cost,
cache_creation_input_token_cost,
input_cost_per_audio_token,
output_cost_per_audio_token,
output_cost_per_reasoning_token,
tiers,
) = match &model.pricing {
Some(pricing) => (
pricing.input_cost_per_token,
pricing.output_cost_per_token,
pricing.cache_read_input_token_cost,
pricing.cache_creation_input_token_cost,
pricing.input_cost_per_audio_token,
pricing.output_cost_per_audio_token,
pricing.output_cost_per_reasoning_token,
pricing.tiers.iter().map(PricingTier::from).collect(),
),
None => (0.0, 0.0, None, None, None, None, None, Vec::new()),
};
ModelPricing {
price_known: Some(model.pricing.as_ref().is_some_and(|pricing| {
pricing.input_cost_per_token != 0.0
|| pricing.output_cost_per_token != 0.0
|| model.mode.as_deref() == Some("embedding")
})),
input_cost_per_token,
output_cost_per_token,
cache_read_input_token_cost,
cache_creation_input_token_cost,
input_cost_per_audio_token,
output_cost_per_audio_token,
output_cost_per_reasoning_token,
max_tokens: Some(model.limit.context),
max_input_tokens: Some(model.limit.input.unwrap_or(model.limit.context)),
max_output_tokens: model.limit.output,
mode: model.mode.clone(),
supports_vision: Some(model.capabilities.vision),
supports_function_calling: Some(model.capabilities.function_calling),
supports_reasoning: Some(model.capabilities.reasoning),
supports_structured_output: Some(model.capabilities.structured_output),
supports_audio_input: Some(model.capabilities.audio_input),
supports_audio_output: Some(model.capabilities.audio_output),
supports_prompt_caching: Some(model.capabilities.prompt_caching),
tiers,
}
}
fn registry_from_catalog_str(catalog_json: &str) -> std::result::Result<HashMap<String, ModelPricing>, String> {
let catalog: CatalogFile = serde_json::from_str(catalog_json).map_err(|e| e.to_string())?;
let mut registry = HashMap::new();
for (provider_id, provider) in &catalog.providers {
for (model_id, model) in &provider.models {
let pricing = flatten_model(model);
if PRIMARY_PROVIDERS.contains(&provider_id.as_str()) {
registry.entry(model_id.clone()).or_insert_with(|| pricing.clone());
}
registry.insert(format!("{provider_id}/{model_id}"), pricing);
}
}
Ok(registry)
}
fn catalog_info_from_str(catalog_json: &str, origin: &str) -> std::result::Result<CatalogInfo, String> {
let catalog: CatalogFile = serde_json::from_str(catalog_json).map_err(|e| e.to_string())?;
Ok(CatalogInfo {
source: catalog.provenance.source,
source_sha256: catalog.provenance.source_sha256,
fetched: catalog.provenance.fetched,
library_version: catalog.provenance.library_version,
schema_version: catalog.schema_version,
origin: origin.to_owned(),
})
}
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(alef, alef(skip))]
pub struct ModelPricing {
#[serde(default)]
pub price_known: Option<bool>,
pub input_cost_per_token: f64,
pub output_cost_per_token: f64,
#[serde(default)]
pub cache_read_input_token_cost: Option<f64>,
#[serde(default)]
pub cache_creation_input_token_cost: Option<f64>,
#[serde(default)]
pub input_cost_per_audio_token: Option<f64>,
#[serde(default)]
pub output_cost_per_audio_token: Option<f64>,
#[serde(default)]
pub output_cost_per_reasoning_token: Option<f64>,
#[serde(default)]
pub max_tokens: Option<u64>,
#[serde(default)]
pub max_input_tokens: Option<u64>,
#[serde(default)]
pub max_output_tokens: Option<u64>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub supports_vision: Option<bool>,
#[serde(default)]
pub supports_function_calling: Option<bool>,
#[serde(default)]
pub supports_reasoning: Option<bool>,
#[serde(default)]
pub supports_structured_output: Option<bool>,
#[serde(default)]
pub supports_audio_input: Option<bool>,
#[serde(default)]
pub supports_audio_output: Option<bool>,
#[serde(default)]
pub supports_prompt_caching: Option<bool>,
#[serde(default)]
pub tiers: Vec<PricingTier>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(alef, alef(skip))]
pub struct PricingTier {
pub min_context_tokens: u64,
pub input_cost_per_token: f64,
pub output_cost_per_token: f64,
#[serde(default)]
pub cache_read_input_token_cost: Option<f64>,
#[serde(default)]
pub cache_creation_input_token_cost: Option<f64>,
#[serde(default)]
pub input_cost_per_audio_token: Option<f64>,
#[serde(default)]
pub output_cost_per_audio_token: Option<f64>,
#[serde(default)]
pub output_cost_per_reasoning_token: Option<f64>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelInfo {
pub matched_key: String,
pub exact: bool,
pub price_known: bool,
pub input_cost_per_token: f64,
pub output_cost_per_token: f64,
pub cache_read_input_token_cost: Option<f64>,
pub cache_creation_input_token_cost: Option<f64>,
pub input_cost_per_audio_token: Option<f64>,
pub output_cost_per_audio_token: Option<f64>,
pub output_cost_per_reasoning_token: Option<f64>,
pub max_tokens: Option<u64>,
pub max_input_tokens: Option<u64>,
pub max_output_tokens: Option<u64>,
pub mode: Option<String>,
pub supports_vision: Option<bool>,
pub supports_function_calling: Option<bool>,
pub supports_reasoning: Option<bool>,
pub supports_structured_output: Option<bool>,
pub supports_audio_input: Option<bool>,
pub supports_audio_output: Option<bool>,
pub supports_prompt_caching: Option<bool>,
pub tiers: Vec<ModelTier>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelTier {
pub min_context_tokens: u64,
pub input_cost_per_token: f64,
pub output_cost_per_token: f64,
pub cache_read_input_token_cost: Option<f64>,
pub cache_creation_input_token_cost: Option<f64>,
pub input_cost_per_audio_token: Option<f64>,
pub output_cost_per_audio_token: Option<f64>,
pub output_cost_per_reasoning_token: Option<f64>,
}
impl From<&PricingTier> for ModelTier {
fn from(tier: &PricingTier) -> Self {
ModelTier {
min_context_tokens: tier.min_context_tokens,
input_cost_per_token: tier.input_cost_per_token,
output_cost_per_token: tier.output_cost_per_token,
cache_read_input_token_cost: tier.cache_read_input_token_cost,
cache_creation_input_token_cost: tier.cache_creation_input_token_cost,
input_cost_per_audio_token: tier.input_cost_per_audio_token,
output_cost_per_audio_token: tier.output_cost_per_audio_token,
output_cost_per_reasoning_token: tier.output_cost_per_reasoning_token,
}
}
}
impl From<&ModelPricing> for ModelInfo {
fn from(pricing: &ModelPricing) -> Self {
ModelInfo {
matched_key: String::new(),
exact: false,
price_known: pricing.price_known.unwrap_or(true),
input_cost_per_token: pricing.input_cost_per_token,
output_cost_per_token: pricing.output_cost_per_token,
cache_read_input_token_cost: pricing.cache_read_input_token_cost,
cache_creation_input_token_cost: pricing.cache_creation_input_token_cost,
input_cost_per_audio_token: pricing.input_cost_per_audio_token,
output_cost_per_audio_token: pricing.output_cost_per_audio_token,
output_cost_per_reasoning_token: pricing.output_cost_per_reasoning_token,
max_tokens: pricing.max_tokens,
max_input_tokens: pricing.max_input_tokens,
max_output_tokens: pricing.max_output_tokens,
mode: pricing.mode.clone(),
supports_vision: pricing.supports_vision,
supports_function_calling: pricing.supports_function_calling,
supports_reasoning: pricing.supports_reasoning,
supports_structured_output: pricing.supports_structured_output,
supports_audio_input: pricing.supports_audio_input,
supports_audio_output: pricing.supports_audio_output,
supports_prompt_caching: pricing.supports_prompt_caching,
tiers: pricing.tiers.iter().map(ModelTier::from).collect(),
}
}
}
#[must_use]
pub fn completion_cost(model: &str, prompt_tokens: u64, completion_tokens: u64) -> Option<f64> {
completion_cost_with_cache(model, prompt_tokens, 0, completion_tokens)
}
#[must_use]
pub fn completion_cost_with_cache(
model: &str,
prompt_tokens: u64,
cached_tokens: u64,
completion_tokens: u64,
) -> Option<f64> {
completion_cost_with_cache_details(model, prompt_tokens, cached_tokens, 0, completion_tokens)
}
#[must_use]
pub fn completion_cost_with_cache_details(
model: &str,
prompt_tokens: u64,
cached_tokens: u64,
cache_creation_tokens: u64,
completion_tokens: u64,
) -> Option<f64> {
with_active_registry(|reg| {
compute_cost_in(
reg,
model,
prompt_tokens,
cached_tokens,
cache_creation_tokens,
completion_tokens,
)
})
}
fn compute_cost_in(
reg: &HashMap<String, ModelPricing>,
model: &str,
prompt_tokens: u64,
cached_tokens: u64,
cache_creation_tokens: u64,
completion_tokens: u64,
) -> Option<f64> {
let (_, pricing) = lookup_in(reg, model)?;
if pricing.price_known == Some(false) {
return None;
}
Some(compute_cost(
pricing,
prompt_tokens,
cached_tokens,
cache_creation_tokens,
completion_tokens,
))
}
fn select_tier(pricing: &ModelPricing, prompt_tokens: u64) -> Option<&PricingTier> {
pricing
.tiers
.iter()
.filter(|tier| tier.min_context_tokens <= prompt_tokens)
.max_by_key(|tier| tier.min_context_tokens)
}
fn compute_cost(
pricing: &ModelPricing,
prompt_tokens: u64,
cached_tokens: u64,
cache_creation_tokens: u64,
completion_tokens: u64,
) -> f64 {
let cached = cached_tokens.min(prompt_tokens);
let created = cache_creation_tokens.min(prompt_tokens - cached);
let uncached = prompt_tokens - cached - created;
let tier = select_tier(pricing, prompt_tokens);
let input_rate = tier.map_or(pricing.input_cost_per_token, |t| t.input_cost_per_token);
let output_rate = tier.map_or(pricing.output_cost_per_token, |t| t.output_cost_per_token);
let cache_rate = tier
.and_then(|t| t.cache_read_input_token_cost)
.or(pricing.cache_read_input_token_cost)
.unwrap_or(input_rate);
let cache_creation_rate = tier
.and_then(|t| t.cache_creation_input_token_cost)
.or(pricing.cache_creation_input_token_cost)
.unwrap_or(input_rate);
(uncached as f64) * input_rate
+ (cached as f64) * cache_rate
+ (created as f64) * cache_creation_rate
+ (completion_tokens as f64) * output_rate
}
fn lookup(model: &str) -> Option<&'static ModelPricing> {
lookup_in(registry()?, model).map(|(_, pricing)| pricing)
}
fn lookup_in<'a>(models: &'a HashMap<String, ModelPricing>, model: &str) -> Option<(&'a str, &'a ModelPricing)> {
if let Some((key, pricing)) = models.get_key_value(model) {
return Some((key.as_str(), pricing));
}
if let Some(candidate) = fallback_model_key(model)
&& let Some((key, pricing)) = models.get_key_value(candidate)
{
return Some((key.as_str(), pricing));
}
if let Some((prefix, model_id)) = model.split_once('/') {
let catalog_provider = match prefix {
"gemini" | "google_ai" => Some("google"),
"vertex_ai" => Some("google-vertex"),
_ => None,
};
if let Some(provider) = catalog_provider {
return lookup_in(models, &format!("{provider}/{model_id}"));
}
}
None
}
fn fallback_model_key(model: &str) -> Option<&str> {
let separator = model.rfind(['-', '.'])?;
let suffix = &model[separator + 1..];
let base = &model[..separator];
let is_numeric_revision = suffix.len() >= 3 && suffix.bytes().all(|byte| byte.is_ascii_digit());
let is_version = suffix
.strip_prefix('v')
.is_some_and(|version| !version.is_empty() && version.bytes().all(|byte| byte.is_ascii_digit()));
if suffix == "latest" || is_numeric_revision || is_version {
return Some(base);
}
let date_start = model.len().checked_sub(11)?;
let date_separator = model.as_bytes().get(date_start)?;
let date = &model[date_start + 1..];
let is_date = matches!(date_separator, b'-' | b'.')
&& date.len() == 10
&& date.bytes().enumerate().all(|(index, byte)| {
if matches!(index, 4 | 7) {
byte == b'-'
} else {
byte.is_ascii_digit()
}
});
is_date.then_some(&model[..date_start])
}
#[cfg_attr(alef, alef(skip))]
#[must_use]
pub fn model_pricing(model: &str) -> Option<&'static ModelPricing> {
lookup(model)
}
#[must_use]
pub fn model_info(model: &str) -> Option<ModelInfo> {
with_active_registry(|reg| model_info_in(reg, model))
}
#[must_use]
pub fn exact_model_info(model: &str) -> Option<ModelInfo> {
with_active_registry(|reg| {
let pricing = reg.get(model)?;
let mut info = ModelInfo::from(pricing);
info.matched_key = model.to_owned();
info.exact = true;
Some(info)
})
}
#[must_use]
pub fn catalog_info() -> Option<CatalogInfo> {
refresh::overlay_catalog_info().or_else(|| EMBEDDED_CATALOG_INFO.as_ref().ok().cloned())
}
fn model_info_in(reg: &HashMap<String, ModelPricing>, model: &str) -> Option<ModelInfo> {
let (matched_key, pricing) = lookup_in(reg, model)?;
let mut info = ModelInfo::from(pricing);
info.matched_key = matched_key.to_owned();
info.exact = matched_key == model;
Some(info)
}
fn with_active_registry<T>(mut f: impl FnMut(&HashMap<String, ModelPricing>) -> Option<T>) -> Option<T> {
if let Some(overlay) = refresh::overlay_registry()
&& let Some(value) = f(&overlay)
{
return Some(value);
}
f(registry()?)
}
pub mod refresh;
pub use refresh::{
CatalogRefreshConfig, CatalogRefreshError, DEFAULT_CATALOG_URL, RefreshOutcome, clear_catalog_overlay,
install_catalog_overlay_from_str, refresh_catalog,
};
#[cfg(test)]
mod refresh_tests;
#[cfg(test)]
mod tests;