use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::models_dev::{ModelsDevCatalog, ModelsDevCost, ModelsDevLimit, ModelsDevModalities};
use crate::route::{ModelId, ProviderId, ProviderModelOffering, RouteLimits, WireModelId};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CatalogSource {
#[default]
Bundled,
Live {
base_url_fingerprint: String,
fetched_at: u64,
},
UserOverride,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct CatalogOffering {
pub provider: String,
pub wire_model_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_model: Option<String>,
pub endpoint_key: String,
#[serde(default)]
pub default_for_provider: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<ModelsDevLimit>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<ModelsDevCost>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modalities: Option<ModelsDevModalities>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachment: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structured_output: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reasoning_options: Vec<Value>,
pub source: CatalogSource,
}
impl CatalogOffering {
#[must_use]
pub fn provider_id(&self) -> ProviderId {
ProviderId::from(self.provider.clone())
}
#[must_use]
pub fn wire_id(&self) -> WireModelId {
WireModelId::from(self.wire_model_id.clone())
}
#[must_use]
pub fn to_offering(&self) -> ProviderModelOffering {
ProviderModelOffering {
provider: self.provider_id(),
canonical_model: self.canonical_model.clone().map(ModelId::from),
wire_model_id: self.wire_id(),
endpoint_key: self.endpoint_key.clone(),
default_for_provider: self.default_for_provider,
limits: self
.limit
.as_ref()
.map(RouteLimits::from)
.unwrap_or_default(),
capabilities: crate::route::RouteCapabilities {
attachments: crate::route::CapabilityState::from_optional_bool(self.attachment),
image_input: crate::models_dev::image_input_support(self.modalities.as_ref()),
reasoning: crate::route::CapabilityState::from_optional_bool(self.reasoning),
native_tool_calls: crate::route::CapabilityState::from_optional_bool(
self.tool_call,
),
structured_output: crate::route::CapabilityState::from_optional_bool(
self.structured_output,
),
server_side_web_search: crate::route::documented_server_side_web_search(
&self.provider,
&self.wire_model_id,
),
..crate::route::RouteCapabilities::default()
},
pricing: crate::pricing::route_pricing_sku(self),
}
}
fn merge_key(&self) -> (String, String) {
(self.provider.clone(), self.wire_model_id.clone())
}
}
pub const BUNDLED_MODELS_DEV_JSON: &str = include_str!("../assets/models_dev.bundled.json");
#[must_use]
pub fn bundled_models_dev_catalog() -> ModelsDevCatalog {
ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON)
.expect("committed bundled Models.dev asset must be valid JSON")
}
#[must_use]
pub fn bundled_catalog_offerings() -> Vec<CatalogOffering> {
bundled_offerings_from_models_dev(&bundled_models_dev_catalog())
}
#[must_use]
pub fn bundled_offerings_from_models_dev(catalog: &ModelsDevCatalog) -> Vec<CatalogOffering> {
offerings_from_models_dev(catalog, CatalogSource::Bundled, false)
}
#[must_use]
pub fn live_offerings_from_models_dev(
catalog: &ModelsDevCatalog,
base_url_fingerprint: &str,
fetched_at: u64,
) -> Vec<CatalogOffering> {
offerings_from_models_dev(
catalog,
CatalogSource::Live {
base_url_fingerprint: base_url_fingerprint.to_string(),
fetched_at,
},
true,
)
}
fn offerings_from_models_dev(
catalog: &ModelsDevCatalog,
source: CatalogSource,
normalize_provider_ids: bool,
) -> Vec<CatalogOffering> {
let mut out = Vec::new();
for (provider_key, provider) in &catalog.providers {
let raw_id = if provider.id.trim().is_empty() {
provider_key.trim()
} else {
provider.id.trim()
};
if raw_id.is_empty() {
continue;
}
let provider_id = if normalize_provider_ids {
crate::ProviderKind::parse(raw_id)
.map(|kind| kind.as_str().to_string())
.unwrap_or_else(|| raw_id.to_string())
} else {
raw_id.to_string()
};
for model in provider.models.values() {
if !model.supports_text_chat() {
continue;
}
out.push(CatalogOffering {
provider: provider_id.clone(),
wire_model_id: model.id.clone(),
canonical_model: model.base_model.clone(),
endpoint_key: "chat".to_string(),
default_for_provider: model.default_for_provider,
family: model.family.clone(),
limit: model.limit.clone(),
cost: model.cost.clone(),
modalities: model.modalities.clone(),
attachment: model.attachment,
reasoning: model.reasoning,
tool_call: model.tool_call,
structured_output: model.structured_output,
reasoning_options: model.reasoning_options.clone(),
source: source.clone(),
});
}
}
out
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProviderCatalogDelta {
pub provider: String,
pub base_url_fingerprint: String,
pub fetched_at: u64,
pub offerings: Vec<CatalogOffering>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CatalogRefreshError {
Unauthorized,
Forbidden,
NotFound,
RateLimited,
InvalidResponse,
EmptyList,
Network,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum CatalogStatus {
Fresh,
Stale { age_secs: u64 },
Failed { reason: CatalogRefreshError },
Unknown,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CachedProviderCatalog {
pub provider: String,
pub base_url_fingerprint: String,
pub fetched_at: u64,
pub ttl_secs: u64,
pub offerings: Vec<CatalogOffering>,
pub status: CatalogStatus,
}
impl CachedProviderCatalog {
#[must_use]
pub fn age_secs(&self, now_unix: u64) -> u64 {
now_unix.saturating_sub(self.fetched_at)
}
#[must_use]
pub fn is_stale(&self, now_unix: u64) -> bool {
self.age_secs(now_unix) >= self.ttl_secs
}
#[must_use]
pub fn is_fresh(&self, now_unix: u64) -> bool {
!self.is_stale(now_unix) && !matches!(self.status, CatalogStatus::Failed { .. })
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ProviderCatalogCache {
#[serde(default)]
pub entries: BTreeMap<String, CachedProviderCatalog>,
}
impl ProviderCatalogCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn cache_key(provider: &str, base_url_fingerprint: &str) -> String {
format!("{}\u{1f}{}", provider.trim(), base_url_fingerprint.trim())
}
#[must_use]
pub fn get(
&self,
provider: &str,
base_url_fingerprint: &str,
) -> Option<&CachedProviderCatalog> {
self.entries
.get(&Self::cache_key(provider, base_url_fingerprint))
}
pub fn record_success(&mut self, delta: ProviderCatalogDelta, ttl_secs: u64) {
let ProviderCatalogDelta {
provider,
base_url_fingerprint,
fetched_at,
offerings,
} = delta;
let offerings = offerings
.into_iter()
.map(|mut row| {
row.source = CatalogSource::Live {
base_url_fingerprint: base_url_fingerprint.clone(),
fetched_at,
};
row
})
.collect();
let key = Self::cache_key(&provider, &base_url_fingerprint);
self.entries.insert(
key,
CachedProviderCatalog {
provider,
base_url_fingerprint,
fetched_at,
ttl_secs,
offerings,
status: CatalogStatus::Fresh,
},
);
}
pub fn record_failure(
&mut self,
provider: &str,
base_url_fingerprint: &str,
reason: CatalogRefreshError,
) {
let key = Self::cache_key(provider, base_url_fingerprint);
match self.entries.get_mut(&key) {
Some(entry) => entry.status = CatalogStatus::Failed { reason },
None => {
self.entries.insert(
key,
CachedProviderCatalog {
provider: provider.trim().to_string(),
base_url_fingerprint: base_url_fingerprint.trim().to_string(),
fetched_at: 0,
ttl_secs: 0,
offerings: Vec::new(),
status: CatalogStatus::Failed { reason },
},
);
}
}
}
#[must_use]
pub fn status(
&self,
provider: &str,
base_url_fingerprint: &str,
now_unix: u64,
) -> CatalogStatus {
match self.get(provider, base_url_fingerprint) {
None => CatalogStatus::Unknown,
Some(entry) => match &entry.status {
CatalogStatus::Failed { reason } => CatalogStatus::Failed { reason: *reason },
CatalogStatus::Unknown => CatalogStatus::Unknown,
CatalogStatus::Fresh | CatalogStatus::Stale { .. } => {
if entry.is_stale(now_unix) {
CatalogStatus::Stale {
age_secs: entry.age_secs(now_unix),
}
} else {
CatalogStatus::Fresh
}
}
},
}
}
#[must_use]
pub fn fresh_offerings(
&self,
provider: &str,
base_url_fingerprint: &str,
now_unix: u64,
) -> Vec<CatalogOffering> {
match self.get(provider, base_url_fingerprint) {
Some(entry) if entry.is_fresh(now_unix) => entry.offerings.clone(),
_ => Vec::new(),
}
}
#[must_use]
pub fn all_fresh_offerings(&self, now_unix: u64) -> Vec<CatalogOffering> {
self.entries
.values()
.filter(|entry| entry.is_fresh(now_unix))
.flat_map(|entry| entry.offerings.clone())
.collect()
}
#[must_use]
pub fn all_visible_offerings(&self, _now_unix: u64) -> Vec<CatalogOffering> {
self.entries
.values()
.filter(|entry| !entry.offerings.is_empty())
.flat_map(|entry| entry.offerings.clone())
.collect()
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CatalogSnapshot {
pub offerings: Vec<CatalogOffering>,
}
impl CatalogSnapshot {
#[must_use]
pub fn to_offerings(&self) -> Vec<ProviderModelOffering> {
self.offerings
.iter()
.map(CatalogOffering::to_offering)
.collect()
}
#[must_use]
pub fn offerings_for_provider(&self, provider: &str) -> Vec<&CatalogOffering> {
self.offerings
.iter()
.filter(|row| row.provider == provider)
.collect()
}
}
#[derive(Debug, Clone, Default)]
pub struct CatalogCompiler {
bundled: Vec<CatalogOffering>,
live: Vec<CatalogOffering>,
overrides: Vec<CatalogOffering>,
}
impl CatalogCompiler {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_bundled(mut self, rows: Vec<CatalogOffering>) -> Self {
self.bundled.extend(rows);
self
}
#[must_use]
pub fn with_models_dev(mut self, catalog: &ModelsDevCatalog) -> Self {
self.bundled
.extend(bundled_offerings_from_models_dev(catalog));
self
}
#[must_use]
pub fn with_live(mut self, rows: Vec<CatalogOffering>) -> Self {
self.live.extend(rows);
self
}
#[must_use]
pub fn with_overrides(mut self, rows: Vec<CatalogOffering>) -> Self {
self.overrides.extend(rows);
self
}
#[must_use]
pub fn compile(self) -> CatalogSnapshot {
let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new();
for row in self
.bundled
.into_iter()
.chain(self.live)
.chain(self.overrides)
{
merged.insert(row.merge_key(), row);
}
CatalogSnapshot {
offerings: merged.into_values().collect(),
}
}
}
#[must_use]
pub fn base_url_fingerprint(base_url: &str) -> String {
use sha2::Digest as _;
let normalized = secret_free_fingerprint_input(base_url);
let digest = sha2::Sha256::digest(normalized.as_bytes());
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(&mut out, "{byte:02x}");
}
out
}
fn secret_free_fingerprint_input(base_url: &str) -> String {
const REDACTED: &str = "invalid-or-secret-bearing-url";
let trimmed = base_url.trim();
if let Some((scheme, rest)) = trimmed.split_once("://") {
let scheme = scheme.to_ascii_lowercase();
if !matches!(scheme.as_str(), "http" | "https") {
return REDACTED.to_string();
}
let authority_end = rest.find('/').unwrap_or(rest.len());
let authority_with_userinfo = &rest[..authority_end];
if authority_with_userinfo.contains(['?', '#']) {
return REDACTED.to_string();
}
let authority = authority_with_userinfo
.rsplit_once('@')
.map_or(authority_with_userinfo, |(_, host)| host);
if authority.is_empty() {
return REDACTED.to_string();
}
let path = rest[authority_end..]
.split(['?', '#'])
.next()
.unwrap_or_default();
return normalize_base_url(&format!("{scheme}://{authority}{path}"));
}
normalize_base_url(trimmed.split(['?', '#']).next().unwrap_or(REDACTED))
}
fn normalize_base_url(base_url: &str) -> String {
let trimmed = base_url.trim().trim_end_matches('/');
if let Some(idx) = trimmed.find("://") {
let (scheme, rest) = trimmed.split_at(idx);
let scheme = scheme.to_ascii_lowercase();
let rest = &rest[3..];
let (authority, path) = match rest.find('/') {
Some(p) => (&rest[..p], &rest[p..]),
None => (rest, ""),
};
let authority = authority.to_ascii_lowercase();
let default_port = match scheme.as_str() {
"https" => Some(":443"),
"http" => Some(":80"),
_ => None,
};
let authority = default_port
.and_then(|port| authority.strip_suffix(port))
.unwrap_or(&authority);
format!("{scheme}://{authority}{path}")
} else {
trimmed.to_ascii_lowercase()
}
}
#[must_use]
pub fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests;