1use std::path::PathBuf;
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use serde::{Deserialize, Serialize};
10
11use crate::LlmError;
12
13const TTL_SECS: u64 = 86_400; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct RemoteModelInfo {
18 pub id: String,
20 pub display_name: String,
22 pub context_window: Option<usize>,
24 pub created_at: Option<i64>,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
30struct CacheEnvelope {
31 fetched_at: u64,
33 models: Vec<RemoteModelInfo>,
34}
35
36pub struct ModelCache {
38 path: PathBuf,
39}
40
41impl ModelCache {
42 #[must_use]
47 pub fn for_slug(slug: &str) -> Self {
48 let safe: String = slug
49 .chars()
50 .map(|c| if c == '-' { '_' } else { c })
51 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
52 .collect();
53 let safe = if safe.is_empty() {
54 "unknown".to_string()
55 } else {
56 safe
57 };
58 let path = dirs::cache_dir()
59 .unwrap_or_else(|| PathBuf::from(".cache"))
60 .join("zeph")
61 .join("models")
62 .join(format!("{safe}.json"));
63 Self { path }
64 }
65
66 pub fn load(&self) -> Result<Option<Vec<RemoteModelInfo>>, LlmError> {
72 let Ok(data) = std::fs::read(&self.path) else {
73 return Ok(None);
74 };
75 let envelope: CacheEnvelope = serde_json::from_slice(&data).map_err(LlmError::Json)?;
76 Ok(Some(envelope.models))
77 }
78
79 #[must_use]
81 pub fn is_stale(&self) -> bool {
82 let Ok(data) = std::fs::read(&self.path) else {
83 return true;
84 };
85 let Ok(envelope) = serde_json::from_slice::<CacheEnvelope>(&data) else {
86 return true;
87 };
88 let now = SystemTime::now()
89 .duration_since(UNIX_EPOCH)
90 .unwrap_or(Duration::ZERO)
91 .as_secs();
92 now.saturating_sub(envelope.fetched_at) > TTL_SECS
93 }
94
95 #[tracing::instrument(name = "llm.model_cache.load_async", skip_all)]
104 pub async fn load_async(&self) -> Result<Option<Vec<RemoteModelInfo>>, LlmError> {
105 let path = self.path.clone();
106 tokio::task::spawn_blocking(move || {
107 let Ok(data) = std::fs::read(&path) else {
108 return Ok(None);
109 };
110 let envelope: CacheEnvelope = serde_json::from_slice(&data).map_err(LlmError::Json)?;
111 Ok(Some(envelope.models))
112 })
113 .await
114 .map_err(|e| LlmError::Io(std::io::Error::other(e)))?
115 }
116
117 #[must_use]
122 #[tracing::instrument(name = "llm.model_cache.is_stale_async", skip_all)]
123 pub async fn is_stale_async(&self) -> bool {
124 let path = self.path.clone();
125 tokio::task::spawn_blocking(move || {
126 let Ok(data) = std::fs::read(&path) else {
127 return true;
128 };
129 let Ok(envelope) = serde_json::from_slice::<CacheEnvelope>(&data) else {
130 return true;
131 };
132 let now = SystemTime::now()
133 .duration_since(UNIX_EPOCH)
134 .unwrap_or(Duration::ZERO)
135 .as_secs();
136 now.saturating_sub(envelope.fetched_at) > TTL_SECS
137 })
138 .await
139 .unwrap_or(true)
140 }
141
142 #[tracing::instrument(name = "llm.model_cache.save", skip_all)]
151 pub async fn save(&self, models: &[RemoteModelInfo]) -> Result<(), LlmError> {
152 let path = self.path.clone();
153 let models = models.to_vec();
154 tokio::task::spawn_blocking(move || {
155 if let Some(parent) = path.parent() {
156 std::fs::create_dir_all(parent).map_err(LlmError::Io)?;
157 }
158 let now = SystemTime::now()
159 .duration_since(UNIX_EPOCH)
160 .unwrap_or(Duration::ZERO)
161 .as_secs();
162 let envelope = CacheEnvelope {
163 fetched_at: now,
164 models,
165 };
166 let json = serde_json::to_vec_pretty(&envelope).map_err(LlmError::Json)?;
167 zeph_common::fs_secure::atomic_write_private(&path, &json).map_err(LlmError::Io)?;
168 Ok(())
169 })
170 .await
171 .map_err(|e| LlmError::Io(std::io::Error::other(e)))?
172 }
173
174 pub fn invalidate(&self) {
176 let _ = std::fs::remove_file(&self.path);
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use tempfile::TempDir;
183
184 use super::*;
185
186 fn tmp_cache() -> (ModelCache, TempDir) {
190 let dir = TempDir::new().unwrap();
191 let cache = ModelCache {
192 path: dir.path().join("test.json"),
193 };
194 (cache, dir)
195 }
196
197 #[test]
198 fn missing_file_is_stale() {
199 let (c, _dir) = tmp_cache();
200 assert!(c.is_stale());
201 }
202
203 #[tokio::test]
204 async fn fresh_cache_is_not_stale() {
205 let (c, _dir) = tmp_cache();
206 let models = vec![RemoteModelInfo {
207 id: "m1".into(),
208 display_name: "Model 1".into(),
209 context_window: Some(4096),
210 created_at: None,
211 }];
212 c.save(&models).await.unwrap();
213 assert!(!c.is_stale());
214 }
215
216 #[tokio::test]
217 async fn json_round_trip() {
218 let (c, _dir) = tmp_cache();
219 let models = vec![
220 RemoteModelInfo {
221 id: "a".into(),
222 display_name: "Alpha".into(),
223 context_window: Some(8192),
224 created_at: Some(1_700_000_000),
225 },
226 RemoteModelInfo {
227 id: "b".into(),
228 display_name: "Beta".into(),
229 context_window: None,
230 created_at: None,
231 },
232 ];
233 c.save(&models).await.unwrap();
234 let loaded = c.load().unwrap().unwrap();
235 assert_eq!(loaded, models);
236 }
237
238 #[test]
239 fn stale_detection_on_old_timestamp() {
240 let (c, _dir) = tmp_cache();
241 let old_ts = SystemTime::now()
243 .duration_since(UNIX_EPOCH)
244 .unwrap()
245 .as_secs()
246 .saturating_sub(2 * 86_400 + 1);
247 let envelope = super::CacheEnvelope {
248 fetched_at: old_ts,
249 models: vec![],
250 };
251 let json = serde_json::to_vec_pretty(&envelope).unwrap();
252 std::fs::write(&c.path, &json).unwrap();
253 assert!(c.is_stale());
254 }
255
256 #[tokio::test]
257 async fn invalidate_removes_file() {
258 let (c, _dir) = tmp_cache();
259 let models = vec![];
260 c.save(&models).await.unwrap();
261 assert!(c.path.exists());
262 c.invalidate();
263 assert!(!c.path.exists());
264 }
265
266 #[test]
267 fn cache_save_uses_json_tmp_atomic_suffix() {
268 let path = std::path::PathBuf::from("/tmp/models.json");
269 let tmp = path.with_added_extension("tmp");
270 assert_eq!(tmp.file_name().unwrap(), "models.json.tmp");
271 }
272
273 #[tokio::test]
274 async fn load_async_missing_file_returns_none() {
275 let (c, _dir) = tmp_cache();
276 let result = c.load_async().await.unwrap();
277 assert!(result.is_none());
278 }
279
280 #[tokio::test]
281 async fn load_async_matches_sync_load() {
282 let (c, _dir) = tmp_cache();
283 let models = vec![RemoteModelInfo {
284 id: "x1".into(),
285 display_name: "X One".into(),
286 context_window: Some(2048),
287 created_at: None,
288 }];
289 c.save(&models).await.unwrap();
290 let sync_result = c.load().unwrap();
291 let async_result = c.load_async().await.unwrap();
292 assert_eq!(sync_result, async_result);
293 }
294
295 #[tokio::test]
296 async fn is_stale_async_missing_file_returns_true() {
297 let (c, _dir) = tmp_cache();
298 assert!(c.is_stale_async().await);
299 }
300
301 #[tokio::test]
302 async fn is_stale_async_matches_sync_is_stale() {
303 let (c, _dir) = tmp_cache();
304 let models = vec![RemoteModelInfo {
305 id: "y1".into(),
306 display_name: "Y One".into(),
307 context_window: None,
308 created_at: None,
309 }];
310 c.save(&models).await.unwrap();
311 assert_eq!(c.is_stale(), c.is_stale_async().await);
312 }
313}