use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::LlmError;
const TTL_SECS: u64 = 86_400;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RemoteModelInfo {
pub id: String,
pub display_name: String,
pub context_window: Option<usize>,
pub created_at: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
struct CacheEnvelope {
fetched_at: u64,
models: Vec<RemoteModelInfo>,
}
pub struct ModelCache {
path: PathBuf,
}
impl ModelCache {
#[must_use]
pub fn for_slug(slug: &str) -> Self {
let safe: String = slug
.chars()
.map(|c| if c == '-' { '_' } else { c })
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
let safe = if safe.is_empty() {
"unknown".to_string()
} else {
safe
};
let path = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("zeph")
.join("models")
.join(format!("{safe}.json"));
Self { path }
}
pub fn load(&self) -> Result<Option<Vec<RemoteModelInfo>>, LlmError> {
let Ok(data) = std::fs::read(&self.path) else {
return Ok(None);
};
let envelope: CacheEnvelope = serde_json::from_slice(&data).map_err(LlmError::Json)?;
Ok(Some(envelope.models))
}
#[must_use]
pub fn is_stale(&self) -> bool {
let Ok(data) = std::fs::read(&self.path) else {
return true;
};
let Ok(envelope) = serde_json::from_slice::<CacheEnvelope>(&data) else {
return true;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
now.saturating_sub(envelope.fetched_at) > TTL_SECS
}
#[tracing::instrument(name = "llm.model_cache.load_async", skip_all)]
pub async fn load_async(&self) -> Result<Option<Vec<RemoteModelInfo>>, LlmError> {
let path = self.path.clone();
tokio::task::spawn_blocking(move || {
let Ok(data) = std::fs::read(&path) else {
return Ok(None);
};
let envelope: CacheEnvelope = serde_json::from_slice(&data).map_err(LlmError::Json)?;
Ok(Some(envelope.models))
})
.await
.map_err(|e| LlmError::Io(std::io::Error::other(e)))?
}
#[must_use]
#[tracing::instrument(name = "llm.model_cache.is_stale_async", skip_all)]
pub async fn is_stale_async(&self) -> bool {
let path = self.path.clone();
tokio::task::spawn_blocking(move || {
let Ok(data) = std::fs::read(&path) else {
return true;
};
let Ok(envelope) = serde_json::from_slice::<CacheEnvelope>(&data) else {
return true;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
now.saturating_sub(envelope.fetched_at) > TTL_SECS
})
.await
.unwrap_or(true)
}
#[tracing::instrument(name = "llm.model_cache.save", skip_all)]
pub async fn save(&self, models: &[RemoteModelInfo]) -> Result<(), LlmError> {
let path = self.path.clone();
let models = models.to_vec();
tokio::task::spawn_blocking(move || {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(LlmError::Io)?;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let envelope = CacheEnvelope {
fetched_at: now,
models,
};
let json = serde_json::to_vec_pretty(&envelope).map_err(LlmError::Json)?;
zeph_common::fs_secure::atomic_write_private(&path, &json).map_err(LlmError::Io)?;
Ok(())
})
.await
.map_err(|e| LlmError::Io(std::io::Error::other(e)))?
}
pub fn invalidate(&self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
fn tmp_cache() -> (ModelCache, TempDir) {
let dir = TempDir::new().unwrap();
let cache = ModelCache {
path: dir.path().join("test.json"),
};
(cache, dir)
}
#[test]
fn missing_file_is_stale() {
let (c, _dir) = tmp_cache();
assert!(c.is_stale());
}
#[tokio::test]
async fn fresh_cache_is_not_stale() {
let (c, _dir) = tmp_cache();
let models = vec![RemoteModelInfo {
id: "m1".into(),
display_name: "Model 1".into(),
context_window: Some(4096),
created_at: None,
}];
c.save(&models).await.unwrap();
assert!(!c.is_stale());
}
#[tokio::test]
async fn json_round_trip() {
let (c, _dir) = tmp_cache();
let models = vec![
RemoteModelInfo {
id: "a".into(),
display_name: "Alpha".into(),
context_window: Some(8192),
created_at: Some(1_700_000_000),
},
RemoteModelInfo {
id: "b".into(),
display_name: "Beta".into(),
context_window: None,
created_at: None,
},
];
c.save(&models).await.unwrap();
let loaded = c.load().unwrap().unwrap();
assert_eq!(loaded, models);
}
#[test]
fn stale_detection_on_old_timestamp() {
let (c, _dir) = tmp_cache();
let old_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(2 * 86_400 + 1);
let envelope = super::CacheEnvelope {
fetched_at: old_ts,
models: vec![],
};
let json = serde_json::to_vec_pretty(&envelope).unwrap();
std::fs::write(&c.path, &json).unwrap();
assert!(c.is_stale());
}
#[tokio::test]
async fn invalidate_removes_file() {
let (c, _dir) = tmp_cache();
let models = vec![];
c.save(&models).await.unwrap();
assert!(c.path.exists());
c.invalidate();
assert!(!c.path.exists());
}
#[test]
fn cache_save_uses_json_tmp_atomic_suffix() {
let path = std::path::PathBuf::from("/tmp/models.json");
let tmp = path.with_added_extension("tmp");
assert_eq!(tmp.file_name().unwrap(), "models.json.tmp");
}
#[tokio::test]
async fn load_async_missing_file_returns_none() {
let (c, _dir) = tmp_cache();
let result = c.load_async().await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn load_async_matches_sync_load() {
let (c, _dir) = tmp_cache();
let models = vec![RemoteModelInfo {
id: "x1".into(),
display_name: "X One".into(),
context_window: Some(2048),
created_at: None,
}];
c.save(&models).await.unwrap();
let sync_result = c.load().unwrap();
let async_result = c.load_async().await.unwrap();
assert_eq!(sync_result, async_result);
}
#[tokio::test]
async fn is_stale_async_missing_file_returns_true() {
let (c, _dir) = tmp_cache();
assert!(c.is_stale_async().await);
}
#[tokio::test]
async fn is_stale_async_matches_sync_is_stale() {
let (c, _dir) = tmp_cache();
let models = vec![RemoteModelInfo {
id: "y1".into(),
display_name: "Y One".into(),
context_window: None,
created_at: None,
}];
c.save(&models).await.unwrap();
assert_eq!(c.is_stale(), c.is_stale_async().await);
}
}