Skip to main content

zeph_llm/
model_cache.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Disk-backed cache for remote model listings with 24-hour TTL.
5
6use 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; // 24 hours
14
15/// Metadata about a single model returned by a provider.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct RemoteModelInfo {
18    /// Provider-unique model identifier.
19    pub id: String,
20    /// Human-readable label (e.g. `"llama3.2:3b Q4_K_M"`).
21    pub display_name: String,
22    /// Context window in tokens, if advertised.
23    pub context_window: Option<usize>,
24    /// Unix timestamp of model creation, if available.
25    pub created_at: Option<i64>,
26}
27
28/// On-disk cache envelope.
29#[derive(Debug, Serialize, Deserialize)]
30struct CacheEnvelope {
31    /// Unix timestamp when this cache was written.
32    fetched_at: u64,
33    models: Vec<RemoteModelInfo>,
34}
35
36/// Filesystem cache for a single provider's model list.
37pub struct ModelCache {
38    path: PathBuf,
39}
40
41impl ModelCache {
42    /// Build a cache handle for `slug` (e.g. `"ollama"`, `"claude"`).
43    ///
44    /// The slug is sanitized to `[a-zA-Z0-9_]` to prevent path traversal.
45    /// Cache file lives at `{cache_dir}/zeph/models/{slug}.json`.
46    #[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    /// Load cached models. Returns `None` if the file does not exist or is unreadable.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error only on JSON parse failure (corrupt file).
71    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    /// Returns `true` if the cache file is missing or older than 24 hours.
80    #[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    /// Load cached models from a tokio async context without blocking the executor.
96    ///
97    /// Offloads the blocking file read to `spawn_blocking`. Prefer this over
98    /// [`Self::load`] when calling from `async fn`.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error only on JSON parse failure (corrupt file).
103    #[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    /// Returns `true` if the cache file is missing or older than 24 hours.
118    ///
119    /// Offloads the blocking file read to `spawn_blocking`. Prefer this over
120    /// [`Self::is_stale`] when calling from `async fn`.
121    #[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    /// Atomically write models to disk. Writes `.tmp` then renames.
143    ///
144    /// The blocking I/O is offloaded to a `spawn_blocking` thread so this
145    /// function is safe to call from an async context.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the directory cannot be created or the file cannot be written.
150    #[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    /// Remove the cache file (for `/model refresh`).
175    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    // Returns the TempDir guard alongside the cache: it must stay alive for the
187    // duration of the test, since dropping it removes the directory the cache
188    // file lives in.
189    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        // Write envelope with timestamp 2 days ago.
242        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}