Skip to main content

oino_models/
cache.rs

1//! Per-provider model list cache: `<agent>/cache/models/<provider>.json`.
2//!
3//! Dynamic provider registrations (`models: "dynamic"`) start from the cached list until the
4//! host has called the component's `list-models` (refreshed catalogs live in
5//! `models-store.json` with the same `{models, checkedAt}` entry shape).
6
7use std::path::{Path, PathBuf};
8
9use oino_ai::Model;
10use serde::{Deserialize, Serialize};
11
12use crate::auth::oauth::now_ms;
13use crate::auth::storage::write_atomic;
14
15/// A cached model list.
16#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct CachedModels {
19    /// Models of the provider.
20    pub models: Vec<Model>,
21    /// When the list was fetched (Unix ms).
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub checked_at: Option<i64>,
24}
25
26/// Cache read/write failures.
27#[derive(Debug, thiserror::Error)]
28pub enum CacheError {
29    /// The file could not be read or written.
30    #[error("model cache {}: {source}", path.display())]
31    Io {
32        /// Cache file.
33        path: PathBuf,
34        /// Underlying error.
35        #[source]
36        source: std::io::Error,
37    },
38    /// The file is not a valid cache entry.
39    #[error("model cache {}: {source}", path.display())]
40    Parse {
41        /// Cache file.
42        path: PathBuf,
43        /// Underlying error.
44        #[source]
45        source: serde_json::Error,
46    },
47}
48
49/// Directory of per-provider cache files.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct ModelCache {
52    dir: PathBuf,
53}
54
55impl ModelCache {
56    /// Cache in `dir`.
57    #[must_use]
58    pub fn new(dir: impl Into<PathBuf>) -> Self {
59        Self { dir: dir.into() }
60    }
61
62    /// `<agent_dir>/cache/models`.
63    #[must_use]
64    pub fn for_agent_dir(agent_dir: &Path) -> Self {
65        Self::new(agent_dir.join("cache").join("models"))
66    }
67
68    /// Cache file of `provider` (unsafe characters replaced by `_`).
69    #[must_use]
70    pub fn path(&self, provider: &str) -> PathBuf {
71        let mut name: String = provider
72            .chars()
73            .map(|c| {
74                if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
75                    c
76                } else {
77                    '_'
78                }
79            })
80            .collect();
81        if name.starts_with('.') || name.is_empty() {
82            name.insert(0, '_');
83        }
84        self.dir.join(format!("{name}.json"))
85    }
86
87    /// Reads the cached list; `None` when there is none.
88    ///
89    /// # Errors
90    ///
91    /// Unreadable or invalid cache files.
92    pub fn read(&self, provider: &str) -> Result<Option<CachedModels>, CacheError> {
93        let path = self.path(provider);
94        let text = match std::fs::read_to_string(&path) {
95            Ok(text) => text,
96            Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
97            Err(source) => return Err(CacheError::Io { path, source }),
98        };
99        serde_json::from_str(&text)
100            .map(Some)
101            .map_err(|source| CacheError::Parse { path, source })
102    }
103
104    /// Writes `models` with `checkedAt = now`.
105    ///
106    /// # Errors
107    ///
108    /// Write failures.
109    pub fn write(&self, provider: &str, models: &[Model]) -> Result<(), CacheError> {
110        let path = self.path(provider);
111        let entry = CachedModels {
112            models: models.to_vec(),
113            checked_at: Some(now_ms()),
114        };
115        let text = serde_json::to_string_pretty(&entry).map_err(|source| CacheError::Parse {
116            path: path.clone(),
117            source,
118        })?;
119        write_atomic(&path, &text).map_err(|source| CacheError::Io { path, source })
120    }
121
122    /// Deletes the cached list (missing files are fine).
123    ///
124    /// # Errors
125    ///
126    /// Delete failures other than "not found".
127    pub fn remove(&self, provider: &str) -> Result<(), CacheError> {
128        let path = self.path(provider);
129        match std::fs::remove_file(&path) {
130            Ok(()) => Ok(()),
131            Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
132            Err(source) => Err(CacheError::Io { path, source }),
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn paths_are_sanitized() {
143        let cache = ModelCache::new("/c");
144        assert_eq!(
145            cache.path("openrouter"),
146            PathBuf::from("/c/openrouter.json")
147        );
148        assert_eq!(cache.path("../x"), PathBuf::from("/c/_.._x.json"));
149    }
150}