use anyhow::{Context, Result};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use super::{CatalogEntry, ModelCatalogSource, PricingTier, applicable_pricing};
use crate::model_capabilities::{Pricing, get_model_capabilities};
use agent_sdk_foundation::llm::Usage;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedSource {
Override,
Feed,
Static,
Unknown,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedModel {
pub context_window: Option<u32>,
pub max_output_tokens: Option<u32>,
pub pricing: Option<Pricing>,
pub pricing_tiers: Vec<PricingTier>,
pub supports_thinking: Option<bool>,
pub source: ResolvedSource,
}
type ModelKey = (String, String);
fn model_key(provider: &str, model: &str) -> ModelKey {
(provider.to_ascii_lowercase(), model.to_ascii_lowercase())
}
#[derive(Clone, Default)]
pub struct ModelRegistry {
overrides: HashMap<ModelKey, CatalogEntry>,
feed_cache: Arc<RwLock<HashMap<ModelKey, CatalogEntry>>>,
}
impl ModelRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_override(
mut self,
provider: impl AsRef<str>,
model: impl AsRef<str>,
entry: CatalogEntry,
) -> Self {
self.overrides
.insert(model_key(provider.as_ref(), model.as_ref()), entry);
self
}
pub fn register(
&mut self,
provider: impl AsRef<str>,
model: impl AsRef<str>,
entry: CatalogEntry,
) {
self.overrides
.insert(model_key(provider.as_ref(), model.as_ref()), entry);
}
pub async fn refresh(&self, source: &dyn ModelCatalogSource) -> Result<usize> {
let entries = source.fetch().await?;
let mut cache = self
.feed_cache
.write()
.ok()
.context("feed cache lock poisoned")?;
cache.clear();
for entry in entries {
cache.insert(model_key(&entry.provider, &entry.model_id), entry);
}
Ok(cache.len())
}
#[must_use]
pub fn resolve(&self, provider: &str, model: &str) -> ResolvedModel {
if let Some(resolved) = self.resolve_dynamic(provider, model) {
return resolved;
}
if let Some(caps) = get_model_capabilities(provider, model) {
return ResolvedModel {
context_window: caps.context_window,
max_output_tokens: caps.max_output_tokens,
pricing: caps.pricing,
pricing_tiers: Vec::new(),
supports_thinking: Some(caps.supports_thinking),
source: ResolvedSource::Static,
};
}
ResolvedModel {
context_window: None,
max_output_tokens: None,
pricing: None,
pricing_tiers: Vec::new(),
supports_thinking: None,
source: ResolvedSource::Unknown,
}
}
#[must_use]
pub fn resolve_dynamic(&self, provider: &str, model: &str) -> Option<ResolvedModel> {
let key = model_key(provider, model);
if let Some(entry) = self.overrides.get(&key) {
return Some(resolved_from_entry(entry, ResolvedSource::Override));
}
let cache = self.feed_cache.read().ok()?;
cache
.get(&key)
.map(|entry| resolved_from_entry(entry, ResolvedSource::Feed))
}
#[must_use]
pub fn resolve_override(&self, provider: &str, model: &str) -> Option<ResolvedModel> {
self.overrides
.get(&model_key(provider, model))
.map(|entry| resolved_from_entry(entry, ResolvedSource::Override))
}
#[must_use]
pub fn estimate_override_cost_usd(
&self,
provider: &str,
model: &str,
usage: &Usage,
) -> Option<f64> {
estimate_resolved(
&self.resolve_override(provider, model)?,
usage,
TierMode::Apply,
)
}
#[must_use]
pub fn estimate_override_base_cost_usd(
&self,
provider: &str,
model: &str,
usage: &Usage,
) -> Option<f64> {
estimate_resolved(
&self.resolve_override(provider, model)?,
usage,
TierMode::Base,
)
}
#[must_use]
pub fn resolve_feed(&self, provider: &str, model: &str) -> Option<ResolvedModel> {
let cache = self.feed_cache.read().ok()?;
cache
.get(&model_key(provider, model))
.map(|entry| resolved_from_entry(entry, ResolvedSource::Feed))
}
#[must_use]
pub fn estimate_feed_cost_usd(
&self,
provider: &str,
model: &str,
usage: &Usage,
) -> Option<f64> {
estimate_resolved(&self.resolve_feed(provider, model)?, usage, TierMode::Apply)
}
#[must_use]
pub fn estimate_feed_base_cost_usd(
&self,
provider: &str,
model: &str,
usage: &Usage,
) -> Option<f64> {
estimate_resolved(&self.resolve_feed(provider, model)?, usage, TierMode::Base)
}
#[must_use]
pub fn estimate_cost_usd(&self, provider: &str, model: &str, usage: &Usage) -> Option<f64> {
estimate_resolved(&self.resolve(provider, model), usage, TierMode::Apply)
}
#[must_use]
pub fn estimate_dynamic_cost_usd(
&self,
provider: &str,
model: &str,
usage: &Usage,
) -> Option<f64> {
estimate_resolved(
&self.resolve_dynamic(provider, model)?,
usage,
TierMode::Apply,
)
}
#[must_use]
pub fn estimate_dynamic_base_cost_usd(
&self,
provider: &str,
model: &str,
usage: &Usage,
) -> Option<f64> {
estimate_resolved(
&self.resolve_dynamic(provider, model)?,
usage,
TierMode::Base,
)
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum TierMode {
Apply,
Base,
}
fn estimate_resolved(resolved: &ResolvedModel, usage: &Usage, tiers: TierMode) -> Option<f64> {
let base = resolved.pricing?;
let pricing = match tiers {
TierMode::Apply => applicable_pricing(base, &resolved.pricing_tiers, usage.input_tokens),
TierMode::Base => base,
};
if !prices_every_billed_component(&pricing, usage) {
return None;
}
pricing.estimate_cost_usd(usage)
}
fn prices_every_billed_component(pricing: &Pricing, usage: &Usage) -> bool {
let cache_read_tokens = usage.cached_input_tokens.min(usage.input_tokens);
let after_cache_read = usage.input_tokens.saturating_sub(cache_read_tokens);
let cache_write_tokens = usage.cache_creation_input_tokens.min(after_cache_read);
let plain_input_tokens = after_cache_read.saturating_sub(cache_write_tokens);
let input_priced = plain_input_tokens == 0 || pricing.input.is_some();
let cache_read_priced =
cache_read_tokens == 0 || pricing.cached_input.is_some() || pricing.input.is_some();
let cache_write_priced =
cache_write_tokens == 0 || pricing.cache_write.is_some() || pricing.input.is_some();
let output_priced = usage.output_tokens == 0 || pricing.output.is_some();
input_priced && cache_read_priced && cache_write_priced && output_priced
}
fn resolved_from_entry(entry: &CatalogEntry, source: ResolvedSource) -> ResolvedModel {
ResolvedModel {
context_window: entry.context_window,
max_output_tokens: entry.max_output_tokens,
pricing: entry.pricing,
pricing_tiers: entry.pricing_tiers.clone(),
supports_thinking: entry.supports_thinking,
source,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model_catalog::modelsdev::parse_modelsdev;
use async_trait::async_trait;
const MODELSDEV_FIXTURE: &str = r#"{
"anthropic": {
"id": "anthropic",
"name": "Anthropic",
"models": {
"claude-sonnet-4-5": {
"id": "claude-sonnet-4-5",
"name": "Claude Sonnet 4.5",
"reasoning": true,
"limit": { "context": 1000000, "output": 64000 },
"cost": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 }
}
}
},
"openai": {
"id": "openai",
"name": "OpenAI",
"models": {
"gpt-5.2": {
"id": "gpt-5.2",
"name": "GPT-5.2",
"reasoning": true,
"limit": { "context": 400000, "output": 128000 },
"cost": { "input": 1.75, "output": 14, "cache_read": 0.175 }
}
}
},
"google": {
"id": "google",
"name": "Google",
"models": {
"gemini-2.5-pro": {
"id": "gemini-2.5-pro",
"name": "Gemini 2.5 Pro",
"reasoning": true,
"limit": { "context": 1048576, "output": 65536 },
"cost": { "input": 1.25, "output": 10, "cache_read": 0.31, "cache_write": 2.375 }
}
}
}
}"#;
struct StaticSource(Vec<CatalogEntry>);
#[async_trait]
impl ModelCatalogSource for StaticSource {
async fn fetch(&self) -> Result<Vec<CatalogEntry>> {
Ok(self.0.clone())
}
}
#[tokio::test]
async fn registry_layered_resolution_prefers_override_then_feed_then_static() -> Result<()> {
let source = StaticSource(parse_modelsdev(MODELSDEV_FIXTURE)?);
let registry = ModelRegistry::new().with_override(
"anthropic",
"claude-sonnet-4-5",
CatalogEntry {
provider: "anthropic".to_owned(),
model_id: "claude-sonnet-4-5".to_owned(),
context_window: Some(123),
max_output_tokens: Some(7),
pricing: Some(Pricing::flat(1.0, 2.0)),
pricing_tiers: Vec::new(),
supports_thinking: Some(false),
},
);
let count = registry.refresh(&source).await?;
assert_eq!(count, 3);
let overridden = registry.resolve("anthropic", "claude-sonnet-4-5");
assert_eq!(overridden.source, ResolvedSource::Override);
assert_eq!(overridden.context_window, Some(123));
let feed = registry.resolve("openai", "gpt-5.2");
assert_eq!(feed.source, ResolvedSource::Feed);
assert_eq!(feed.max_output_tokens, Some(128_000));
let static_hit = registry.resolve("openai", "gpt-4o");
assert_eq!(static_hit.source, ResolvedSource::Static);
assert!(static_hit.pricing.is_some());
let unknown = registry.resolve("openai", "totally-made-up-model");
assert_eq!(unknown.source, ResolvedSource::Unknown);
assert!(unknown.pricing.is_none());
Ok(())
}
#[tokio::test]
async fn estimate_cost_usd_uses_feed_loaded_pricing() -> Result<()> {
let source = StaticSource(parse_modelsdev(MODELSDEV_FIXTURE)?);
let registry = ModelRegistry::new();
registry.refresh(&source).await?;
let usage = Usage {
input_tokens: 2_000,
output_tokens: 1_000,
cached_input_tokens: 1_000,
cache_creation_input_tokens: 0,
};
let cost = registry
.estimate_cost_usd("openai", "gpt-5.2", &usage)
.context("cost estimate missing")?;
assert!((cost - 0.015_925).abs() < 1e-9);
Ok(())
}
#[tokio::test]
async fn dynamic_lookups_never_answer_from_the_static_table() -> Result<()> {
let source = StaticSource(parse_modelsdev(MODELSDEV_FIXTURE)?);
let registry = ModelRegistry::new();
registry.refresh(&source).await?;
let usage = Usage {
input_tokens: 1_000_000,
output_tokens: 1_000_000,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
assert!(registry.resolve("openai", "gpt-4o").pricing.is_some());
assert!(registry.resolve_dynamic("openai", "gpt-4o").is_none());
assert!(
registry
.estimate_dynamic_cost_usd("openai", "gpt-4o", &usage)
.is_none(),
"the dynamic estimate must not fall back to the static table",
);
assert_eq!(
registry
.resolve_dynamic("openai", "gpt-5.2")
.context("the feed carries gpt-5.2")?
.source,
ResolvedSource::Feed
);
assert!(
registry
.estimate_dynamic_cost_usd("openai", "gpt-5.2", &usage)
.is_some()
);
Ok(())
}
#[test]
fn cache_creation_tokens_bill_at_the_cache_write_rate() -> Result<()> {
let registry = ModelRegistry::new().with_override(
"anthropic",
"claude-haiku-4-5",
CatalogEntry {
provider: "anthropic".to_owned(),
model_id: "claude-haiku-4-5".to_owned(),
context_window: None,
max_output_tokens: None,
pricing: Some(Pricing::flat_with_cached(1.0, 5.0, 0.1).with_cache_write(1.25)),
pricing_tiers: Vec::new(),
supports_thinking: None,
},
);
let usage = Usage {
input_tokens: 1_000_000,
output_tokens: 1_000_000,
cached_input_tokens: 300_000,
cache_creation_input_tokens: 500_000,
};
let cost = registry
.estimate_cost_usd("anthropic", "claude-haiku-4-5", &usage)
.context("cost estimate missing")?;
assert!((cost - 5.855).abs() < 1e-9, "unexpected cost: {cost}");
Ok(())
}
#[test]
fn cache_creation_falls_back_to_the_input_rate() -> Result<()> {
let registry = ModelRegistry::new().with_override(
"anthropic",
"no-write-rate",
CatalogEntry {
provider: "anthropic".to_owned(),
model_id: "no-write-rate".to_owned(),
context_window: None,
max_output_tokens: None,
pricing: Some(Pricing::flat(1.0, 5.0)),
pricing_tiers: Vec::new(),
supports_thinking: None,
},
);
let usage = Usage {
input_tokens: 1_000_000,
output_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 500_000,
};
let cost = registry
.estimate_cost_usd("anthropic", "no-write-rate", &usage)
.context("cost estimate missing")?;
assert!((cost - 1.0).abs() < 1e-9, "unexpected cost: {cost}");
Ok(())
}
#[test]
fn long_context_calls_bill_at_the_tier_rate() -> Result<()> {
let registry = ModelRegistry::new().with_override(
"openai",
"gpt-5.4",
CatalogEntry {
provider: "openai".to_owned(),
model_id: "gpt-5.4".to_owned(),
context_window: None,
max_output_tokens: None,
pricing: Some(Pricing::flat(2.5, 15.0)),
pricing_tiers: vec![PricingTier {
min_input_tokens: 272_000,
pricing: Pricing::flat(5.0, 22.5),
}],
supports_thinking: None,
},
);
let short = Usage {
input_tokens: 200_000,
output_tokens: 100_000,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let short_cost = registry
.estimate_cost_usd("openai", "gpt-5.4", &short)
.context("cost estimate missing")?;
assert!(
(short_cost - 2.0).abs() < 1e-9,
"unexpected cost: {short_cost}"
);
let long = Usage {
input_tokens: 300_000,
output_tokens: 100_000,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let long_cost = registry
.estimate_cost_usd("openai", "gpt-5.4", &long)
.context("cost estimate missing")?;
assert!(
(long_cost - 3.75).abs() < 1e-9,
"unexpected cost: {long_cost}"
);
Ok(())
}
#[test]
fn tier_selection_is_inclusive_at_the_threshold() -> Result<()> {
let registry = tiered_registry();
let just_below = Usage {
input_tokens: 271_999,
output_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let base_cost = registry
.estimate_cost_usd("openai", "gpt-5.4", &just_below)
.context("cost estimate missing")?;
assert!(
(base_cost - 0.679_997_5).abs() < 1e-9,
"unexpected cost: {base_cost}"
);
let at_threshold = Usage {
input_tokens: 272_000,
output_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let tier_cost = registry
.estimate_cost_usd("openai", "gpt-5.4", &at_threshold)
.context("cost estimate missing")?;
assert!(
(tier_cost - 1.36).abs() < 1e-9,
"unexpected cost: {tier_cost}"
);
Ok(())
}
#[test]
fn aggregate_repricing_never_selects_a_tier() -> Result<()> {
let registry = tiered_registry();
let aggregate = Usage {
input_tokens: 300_000,
output_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let base = registry
.estimate_dynamic_base_cost_usd("openai", "gpt-5.4", &aggregate)
.context("cost estimate missing")?;
assert!(
(base - 0.75).abs() < 1e-9,
"unexpected aggregate cost: {base}"
);
let per_call = registry
.estimate_dynamic_cost_usd("openai", "gpt-5.4", &aggregate)
.context("cost estimate missing")?;
assert!(
(per_call - 1.5).abs() < 1e-9,
"a single 300K call does pay the tier rate: {per_call}"
);
Ok(())
}
fn tiered_registry() -> ModelRegistry {
ModelRegistry::new().with_override(
"openai",
"gpt-5.4",
CatalogEntry {
provider: "openai".to_owned(),
model_id: "gpt-5.4".to_owned(),
context_window: None,
max_output_tokens: None,
pricing: Some(Pricing::flat(2.5, 15.0)),
pricing_tiers: vec![PricingTier {
min_input_tokens: 272_000,
pricing: Pricing::flat(5.0, 22.5),
}],
supports_thinking: None,
},
)
}
#[test]
fn estimate_cost_usd_declines_a_row_missing_a_billed_rate() -> Result<()> {
let registry = ModelRegistry::new().with_override(
"openai",
"gpt-4o",
CatalogEntry {
provider: "openai".to_owned(),
model_id: "gpt-4o".to_owned(),
context_window: None,
max_output_tokens: None,
pricing: Some(Pricing {
input: Some(crate::model_capabilities::PricePoint::new(1.0)),
output: None,
cached_input: None,
cache_write: None,
reasoning: None,
notes: None,
}),
pricing_tiers: Vec::new(),
supports_thinking: None,
},
);
let with_output = Usage {
input_tokens: 2_000,
output_tokens: 1_000,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
assert!(
registry
.estimate_cost_usd("openai", "gpt-4o", &with_output)
.is_none(),
"a row with no output rate must decline a call that bills output tokens",
);
let input_only = Usage {
input_tokens: 2_000,
output_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
let cost = registry
.estimate_cost_usd("openai", "gpt-4o", &input_only)
.context("input-only usage is fully priced by an input rate")?;
assert!((cost - 0.002).abs() < 1e-9, "unexpected cost: {cost}");
Ok(())
}
}