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