use std::path::{Path, PathBuf};
use oino_ai::Model;
use serde::{Deserialize, Serialize};
use crate::auth::oauth::now_ms;
use crate::auth::storage::write_atomic;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedModels {
pub models: Vec<Model>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checked_at: Option<i64>,
}
#[derive(Debug, thiserror::Error)]
pub enum CacheError {
#[error("model cache {}: {source}", path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("model cache {}: {source}", path.display())]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ModelCache {
dir: PathBuf,
}
impl ModelCache {
#[must_use]
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
#[must_use]
pub fn for_agent_dir(agent_dir: &Path) -> Self {
Self::new(agent_dir.join("cache").join("models"))
}
#[must_use]
pub fn path(&self, provider: &str) -> PathBuf {
let mut name: String = provider
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
c
} else {
'_'
}
})
.collect();
if name.starts_with('.') || name.is_empty() {
name.insert(0, '_');
}
self.dir.join(format!("{name}.json"))
}
pub fn read(&self, provider: &str) -> Result<Option<CachedModels>, CacheError> {
let path = self.path(provider);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(CacheError::Io { path, source }),
};
serde_json::from_str(&text)
.map(Some)
.map_err(|source| CacheError::Parse { path, source })
}
pub fn write(&self, provider: &str, models: &[Model]) -> Result<(), CacheError> {
let path = self.path(provider);
let entry = CachedModels {
models: models.to_vec(),
checked_at: Some(now_ms()),
};
let text = serde_json::to_string_pretty(&entry).map_err(|source| CacheError::Parse {
path: path.clone(),
source,
})?;
write_atomic(&path, &text).map_err(|source| CacheError::Io { path, source })
}
pub fn remove(&self, provider: &str) -> Result<(), CacheError> {
let path = self.path(provider);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(CacheError::Io { path, source }),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_are_sanitized() {
let cache = ModelCache::new("/c");
assert_eq!(
cache.path("openrouter"),
PathBuf::from("/c/openrouter.json")
);
assert_eq!(cache.path("../x"), PathBuf::from("/c/_.._x.json"));
}
}