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, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub(super) const CACHE_SCHEMA_VERSION: u32 = 8;
pub(super) const ENRICHMENT_VERSION: u32 = 3;
pub(super) const CATALOG_TTL_HOURS: i64 = 24;
#[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) expires_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(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") {
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()?;
if cache.schema_version != CACHE_SCHEMA_VERSION
|| cache.provider != provider
|| cache.cache_fingerprint != *expected_fingerprint
|| cache.entries.is_empty()
{
return None;
}
if 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)
}
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,
)
}
#[cfg(test)]
pub(crate) fn write_catalog_cache_for_configured_provider(
paths: &McPaths,
provider: &str,
entries: &[ModelCatalogEntry],
) -> anyhow::Result<()> {
if provider == "openai-codex" {
return write_catalog_cache(paths, provider, entries);
}
let settings = read_settings(paths)?;
let Some(custom) = settings.custom_providers.get(provider) else {
return write_catalog_cache(paths, provider, entries);
};
write_custom_provider_catalog_cache(paths, provider, custom, 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 now = Utc::now();
let cache = CachedCatalog {
schema_version: CACHE_SCHEMA_VERSION,
provider: provider.to_string(),
cache_fingerprint: cache_fingerprint.clone(),
fetched_at: now,
expires_at: now + Duration::hours(CATALOG_TTL_HOURS),
entries: entries.to_vec(),
};
crate::persistence::atomic_write(&path, serde_json::to_string_pretty(&cache)?.as_bytes())
}