1use 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct CachedModels {
19 pub models: Vec<Model>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub checked_at: Option<i64>,
24}
25
26#[derive(Debug, thiserror::Error)]
28pub enum CacheError {
29 #[error("model cache {}: {source}", path.display())]
31 Io {
32 path: PathBuf,
34 #[source]
36 source: std::io::Error,
37 },
38 #[error("model cache {}: {source}", path.display())]
40 Parse {
41 path: PathBuf,
43 #[source]
45 source: serde_json::Error,
46 },
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct ModelCache {
52 dir: PathBuf,
53}
54
55impl ModelCache {
56 #[must_use]
58 pub fn new(dir: impl Into<PathBuf>) -> Self {
59 Self { dir: dir.into() }
60 }
61
62 #[must_use]
64 pub fn for_agent_dir(agent_dir: &Path) -> Self {
65 Self::new(agent_dir.join("cache").join("models"))
66 }
67
68 #[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 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 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 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}