use super::{
models_dev::effective_custom_provider_models_dev_namespace,
types::{ModelCatalogEntry, ModelId},
};
use crate::{
config::{CustomProviderConfig, McPaths, read_settings},
http_body::{DEFAULT_BOUNDED_BODY_MAX_BYTES, read_bounded_file_to_string},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub(super) const CACHE_SCHEMA_VERSION: u32 = 9;
pub(super) const ENRICHMENT_VERSION: u32 = 3;
const ANTHROPIC_ENRICHMENT_VERSION: u32 = 4;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub(super) struct CatalogCacheFingerprint {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) enrichment_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) models_dev_provider: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(super) extra_models: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) codex_client_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct CachedCatalog {
pub(super) schema_version: u32,
pub(super) provider: String,
#[serde(default)]
pub(super) cache_fingerprint: CatalogCacheFingerprint,
pub(super) fetched_at: DateTime<Utc>,
pub(super) entries: Vec<ModelCatalogEntry>,
}
pub(crate) fn catalog_cache_path(paths: &McPaths, provider: &str) -> Option<PathBuf> {
if provider
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
{
Some(
paths
.cache
.join("model-catalog")
.join(format!("{provider}.json")),
)
} else {
None
}
}
pub(super) fn built_in_catalog_cache_fingerprint(provider: &str) -> CatalogCacheFingerprint {
CatalogCacheFingerprint {
enrichment_version: Some(if provider == crate::providers::ANTHROPIC_PROVIDER {
ANTHROPIC_ENRICHMENT_VERSION
} else {
ENRICHMENT_VERSION
}),
codex_client_version: (provider == crate::providers::OPENAI_CODEX_PROVIDER)
.then(|| crate::providers::CODEX_MODEL_CATALOG_CLIENT_VERSION.to_string()),
..Default::default()
}
}
pub(super) fn read_catalog_cache(paths: &McPaths, provider: &str) -> Option<CachedCatalog> {
read_catalog_cache_with_fingerprint(
paths,
provider,
&built_in_catalog_cache_fingerprint(provider),
)
}
pub(super) fn read_custom_provider_catalog_cache(
paths: &McPaths,
provider: &str,
custom: &CustomProviderConfig,
) -> Option<CachedCatalog> {
let fingerprint = custom_provider_catalog_cache_fingerprint(provider, custom).ok()?;
read_catalog_cache_with_fingerprint(paths, provider, &fingerprint)
}
pub(super) fn read_catalog_cache_for_configured_provider(
paths: &McPaths,
provider: &str,
) -> Option<CachedCatalog> {
if matches!(
provider,
"openai-codex" | "anthropic" | "claude-subscription"
) {
return read_catalog_cache(paths, provider);
}
let settings = read_settings(paths).ok()?;
let custom = settings.custom_providers.get(provider)?;
read_custom_provider_catalog_cache(paths, provider, custom)
}
pub(super) fn metadata_entries_for_provider(
paths: &McPaths,
provider: &str,
) -> Option<Vec<ModelCatalogEntry>> {
read_catalog_cache_for_configured_provider(paths, provider).map(|cache| cache.entries)
}
pub(super) fn read_catalog_cache_with_fingerprint(
paths: &McPaths,
provider: &str,
expected_fingerprint: &CatalogCacheFingerprint,
) -> Option<CachedCatalog> {
let path = catalog_cache_path(paths, provider)?;
let text = read_bounded_file_to_string(path, DEFAULT_BOUNDED_BODY_MAX_BYTES)?;
let cache: CachedCatalog = serde_json::from_str(&text).ok()?;
validate_catalog_cache(cache, provider, expected_fingerprint)
}
pub(super) fn validate_catalog_cache(
cache: CachedCatalog,
provider: &str,
expected_fingerprint: &CatalogCacheFingerprint,
) -> Option<CachedCatalog> {
if cache.schema_version != CACHE_SCHEMA_VERSION
|| cache.provider != provider
|| cache.cache_fingerprint != *expected_fingerprint
|| cache.entries.is_empty()
|| cache.entries.iter().any(|entry| {
entry.provider != provider
|| ModelId::from_parts(provider, &entry.model)
.map(|model_id| entry.id != model_id.to_string())
.unwrap_or(true)
})
{
return None;
}
Some(cache)
}
#[cfg(unix)]
pub(crate) struct SelectionCatalog {
pub(crate) entries: Option<Vec<ModelCatalogEntry>>,
pub(crate) status: &'static str,
pub(crate) fingerprint: [u8; 32],
pub(crate) bytes_read: usize,
}
#[cfg(unix)]
pub(crate) fn read_selection_catalog(
paths: &McPaths,
provider: &str,
settings: &crate::config::Settings,
max_bytes: usize,
) -> Result<SelectionCatalog, &'static str> {
use sha2::{Digest, Sha256};
let expected = match settings.custom_providers.get(provider) {
Some(custom) => custom_provider_catalog_cache_fingerprint(provider, custom)
.map_err(|_| "catalog_unavailable")?,
None => built_in_catalog_cache_fingerprint(provider),
};
let path = catalog_cache_path(paths, provider).ok_or("catalog_unavailable")?;
let file = match crate::persistence::open_regular_file(&path, false) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(SelectionCatalog {
entries: None,
status: "missing_cache",
fingerprint: Sha256::digest([]).into(),
bytes_read: 0,
});
}
Err(_) => return Err("catalog_unavailable"),
};
if file.metadata().map_err(|_| "catalog_unavailable")?.len() > max_bytes as u64 {
return Err("catalog_limit");
}
let bytes = crate::persistence::read_regular_file_bounded(&file, max_bytes as u64)
.map_err(|_| "catalog_unavailable")?;
let (entries, status) = match serde_json::from_slice::<CachedCatalog>(&bytes) {
Err(_) => (None, "invalid_format"),
Ok(cache) if cache.schema_version != CACHE_SCHEMA_VERSION || cache.provider != provider => {
(None, "invalid_schema")
}
Ok(cache) if cache.cache_fingerprint != expected => (None, "incompatible_fingerprint"),
Ok(cache) if cache.entries.is_empty() => (None, "empty_cache"),
Ok(cache) => match validate_catalog_cache(cache, provider, &expected) {
Some(cache) => (Some(cache.entries), "available"),
None => (None, "invalid_format"),
},
};
Ok(SelectionCatalog {
entries,
status,
fingerprint: Sha256::digest(&bytes).into(),
bytes_read: bytes.len(),
})
}
pub(super) fn custom_provider_catalog_cache_fingerprint(
provider: &str,
custom: &CustomProviderConfig,
) -> anyhow::Result<CatalogCacheFingerprint> {
Ok(CatalogCacheFingerprint {
enrichment_version: Some(ENRICHMENT_VERSION),
models_dev_provider: Some(
effective_custom_provider_models_dev_namespace(provider, custom).to_string(),
),
extra_models: crate::config::normalized_extra_models(&custom.extra_models)?,
..Default::default()
})
}
pub(crate) fn write_catalog_cache(
paths: &McPaths,
provider: &str,
entries: &[ModelCatalogEntry],
) -> anyhow::Result<()> {
write_catalog_cache_with_fingerprint(
paths,
provider,
&built_in_catalog_cache_fingerprint(provider),
entries,
)
}
pub(super) fn write_custom_provider_catalog_cache(
paths: &McPaths,
provider: &str,
custom: &CustomProviderConfig,
entries: &[ModelCatalogEntry],
) -> anyhow::Result<()> {
let fingerprint = custom_provider_catalog_cache_fingerprint(provider, custom)?;
write_catalog_cache_with_fingerprint(paths, provider, &fingerprint, entries)
}
pub(super) fn write_catalog_cache_with_fingerprint(
paths: &McPaths,
provider: &str,
cache_fingerprint: &CatalogCacheFingerprint,
entries: &[ModelCatalogEntry],
) -> anyhow::Result<()> {
let path = catalog_cache_path(paths, provider)
.ok_or_else(|| anyhow::anyhow!("invalid model catalog provider cache name"))?;
let cache = CachedCatalog {
schema_version: CACHE_SCHEMA_VERSION,
provider: provider.to_string(),
cache_fingerprint: cache_fingerprint.clone(),
fetched_at: Utc::now(),
entries: entries.to_vec(),
};
crate::persistence::atomic_write(&path, serde_json::to_string_pretty(&cache)?.as_bytes())
}