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; #[derive(Debug)] pub struct ModelsCacheManager { cache_path: PathBuf, cache_ttl: Duration, } impl ModelsCacheManager { pub fn new(cache_path: PathBuf, cache_ttl: Duration) -> Self { Self { cache_path, cache_ttl, } } pub 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) } pub 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}"); } } pub 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)] pub fn set_ttl(&mut self, ttl: Duration) { self.cache_ttl = ttl; } #[cfg(test)] pub 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)] pub 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 } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelsCache { pub fetched_at: DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] pub etag: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub client_version: Option, pub models: Vec, } impl ModelsCache { 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 } }