use chrono::DateTime; use chrono::Utc; use codex_protocol::openai_models::ModelInfo; use serde::Deserialize; use serde::Serialize; use std::io; use std::io::ErrorKind; use std::path::PathBuf; use std::time::Duration; use tokio::fs; use tracing::error; use tracing::info; /// Manages loading and saving of models cache to disk. #[derive(Debug)] pub(crate) struct ModelsCacheManager { cache_path: PathBuf, cache_ttl: Duration, } impl ModelsCacheManager { /// Create a new cache manager with the given path and TTL. pub(crate) fn new(cache_path: PathBuf, cache_ttl: Duration) -> Self { Self { cache_path, cache_ttl, } } /// Attempt to load a fresh cache entry. Returns `None` if the cache doesn't exist or is stale. pub(crate) async fn load_fresh(&self, expected_version: &str) -> Option { info!( cache_path = %self.cache_path.display(), expected_version, "models cache: attempting load_fresh" ); let cache = match self.load().await { Ok(cache) => cache?, Err(err) => { error!("failed to load models cache: {err}"); return None; } }; info!( cache_path = %self.cache_path.display(), cached_version = ?cache.client_version, fetched_at = %cache.fetched_at, "models cache: loaded cache file" ); if cache.client_version.as_deref() != Some(expected_version) { info!( cache_path = %self.cache_path.display(), expected_version, cached_version = ?cache.client_version, "models cache: cache version mismatch" ); return None; } if !cache.is_fresh(self.cache_ttl) { info!( cache_path = %self.cache_path.display(), cache_ttl_secs = self.cache_ttl.as_secs(), fetched_at = %cache.fetched_at, "models cache: cache is stale" ); return None; } info!( cache_path = %self.cache_path.display(), cache_ttl_secs = self.cache_ttl.as_secs(), "models cache: cache hit" ); Some(cache) } /// Persist the cache to disk, creating parent directories as needed. pub(crate) async fn persist_cache( &self, models: &[ModelInfo], etag: Option, client_version: String, ) { let cache = ModelsCache { fetched_at: Utc::now(), etag, client_version: Some(client_version), models: models.to_vec(), }; if let Err(err) = self.save_internal(&cache).await { error!("failed to write models cache: {err}"); } } /// Renew the cache TTL by updating the fetched_at timestamp to now. pub(crate) async fn renew_cache_ttl(&self) -> io::Result<()> { let mut cache = match self.load().await? { Some(cache) => cache, None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")), }; cache.fetched_at = Utc::now(); self.save_internal(&cache).await } async fn load(&self) -> io::Result> { match fs::read(&self.cache_path).await { Ok(contents) => { let cache = serde_json::from_slice(&contents) .map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?; Ok(Some(cache)) } Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), Err(err) => Err(err), } } async fn save_internal(&self, cache: &ModelsCache) -> io::Result<()> { if let Some(parent) = self.cache_path.parent() { fs::create_dir_all(parent).await?; } let json = serde_json::to_vec_pretty(cache) .map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?; fs::write(&self.cache_path, json).await } #[cfg(test)] /// Set the cache TTL. pub(crate) fn set_ttl(&mut self, ttl: Duration) { self.cache_ttl = ttl; } #[cfg(test)] /// Manipulate cache file for testing. Allows setting a custom fetched_at timestamp. pub(crate) async fn manipulate_cache_for_test(&self, f: F) -> io::Result<()> where F: FnOnce(&mut DateTime), { let mut cache = match self.load().await? { Some(cache) => cache, None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")), }; f(&mut cache.fetched_at); self.save_internal(&cache).await } #[cfg(test)] /// Mutate the full cache contents for testing. pub(crate) async fn mutate_cache_for_test(&self, f: F) -> io::Result<()> where F: FnOnce(&mut ModelsCache), { let mut cache = match self.load().await? { Some(cache) => cache, None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")), }; f(&mut cache); self.save_internal(&cache).await } } /// Serialized snapshot of models and metadata cached on disk. #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct ModelsCache { pub(crate) fetched_at: DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) etag: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) client_version: Option, pub(crate) models: Vec, } impl ModelsCache { /// Returns `true` when the cache entry has not exceeded the configured TTL. fn is_fresh(&self, ttl: Duration) -> bool { if ttl.is_zero() { return false; } let Ok(ttl_duration) = chrono::Duration::from_std(ttl) else { return false; }; let age = Utc::now().signed_duration_since(self.fetched_at); age <= ttl_duration } }