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    /// Atomically write models to disk. Writes `.tmp` then renames.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if the directory cannot be created or the file cannot be written.
100    pub fn save(&self, models: &[RemoteModelInfo]) -> Result<(), LlmError> {
101        if let Some(parent) = self.path.parent() {
102            std::fs::create_dir_all(parent).map_err(LlmError::Io)?;
103        }
104        let now = SystemTime::now()
105            .duration_since(UNIX_EPOCH)
106            .unwrap_or(Duration::ZERO)
107            .as_secs();
108        let envelope = CacheEnvelope {
109            fetched_at: now,
110            models: models.to_vec(),
111        };
112        let json = serde_json::to_vec_pretty(&envelope).map_err(LlmError::Json)?;
113        zeph_common::fs_secure::atomic_write_private(&self.path, &json).map_err(LlmError::Io)?;
114        Ok(())
115    }
116
117    /// Remove the cache file (for `/model refresh`).
118    pub fn invalidate(&self) {
119        let _ = std::fs::remove_file(&self.path);
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn tmp_cache() -> ModelCache {
128        use std::sync::atomic::{AtomicU64, Ordering};
129        static COUNTER: AtomicU64 = AtomicU64::new(0);
130        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
131        let dir = std::env::temp_dir()
132            .join("zeph-test-model-cache")
133            .join(format!("{}-{id}", std::process::id()));
134        std::fs::create_dir_all(&dir).unwrap();
135        ModelCache {
136            path: dir.join("test.json"),
137        }
138    }
139
140    #[test]
141    fn missing_file_is_stale() {
142        let c = tmp_cache();
143        assert!(c.is_stale());
144    }
145
146    #[test]
147    fn fresh_cache_is_not_stale() {
148        let c = tmp_cache();
149        let models = vec![RemoteModelInfo {
150            id: "m1".into(),
151            display_name: "Model 1".into(),
152            context_window: Some(4096),
153            created_at: None,
154        }];
155        c.save(&models).unwrap();
156        assert!(!c.is_stale());
157    }
158
159    #[test]
160    fn json_round_trip() {
161        let c = tmp_cache();
162        let models = vec![
163            RemoteModelInfo {
164                id: "a".into(),
165                display_name: "Alpha".into(),
166                context_window: Some(8192),
167                created_at: Some(1_700_000_000),
168            },
169            RemoteModelInfo {
170                id: "b".into(),
171                display_name: "Beta".into(),
172                context_window: None,
173                created_at: None,
174            },
175        ];
176        c.save(&models).unwrap();
177        let loaded = c.load().unwrap().unwrap();
178        assert_eq!(loaded, models);
179    }
180
181    #[test]
182    fn stale_detection_on_old_timestamp() {
183        let c = tmp_cache();
184        // Write envelope with timestamp 2 days ago.
185        let old_ts = SystemTime::now()
186            .duration_since(UNIX_EPOCH)
187            .unwrap()
188            .as_secs()
189            .saturating_sub(2 * 86_400 + 1);
190        let envelope = super::CacheEnvelope {
191            fetched_at: old_ts,
192            models: vec![],
193        };
194        let json = serde_json::to_vec_pretty(&envelope).unwrap();
195        std::fs::write(&c.path, &json).unwrap();
196        assert!(c.is_stale());
197    }
198
199    #[test]
200    fn invalidate_removes_file() {
201        let c = tmp_cache();
202        let models = vec![];
203        c.save(&models).unwrap();
204        assert!(c.path.exists());
205        c.invalidate();
206        assert!(!c.path.exists());
207    }
208
209    #[test]
210    fn cache_save_uses_json_tmp_atomic_suffix() {
211        let path = std::path::PathBuf::from("/tmp/models.json");
212        let tmp = path.with_added_extension("tmp");
213        assert_eq!(tmp.file_name().unwrap(), "models.json.tmp");
214    }
215}