use anyhow::{Result, anyhow};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::types::AgentKind;
use super::Agent;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub(crate) struct ServedModelsCacheEntry {
pub models: Vec<String>,
pub updated_at_secs: u64,
#[serde(default)]
pub fingerprint: Option<String>,
}
#[derive(Clone, Debug)]
struct CachedServedModels {
models: Option<Vec<String>>,
from_live_probe: bool,
fingerprint: Option<String>,
}
#[cfg(not(test))]
static SERVED_CACHE: std::sync::OnceLock<Mutex<HashMap<AgentKind, CachedServedModels>>> = std::sync::OnceLock::new();
pub(crate) const SERVED_MODELS_CACHE_TTL: Duration = Duration::from_secs(24 * 3600);
fn cache() -> &'static Mutex<HashMap<AgentKind, CachedServedModels>> {
#[cfg(test)]
return cache_tests::cache();
#[cfg(not(test))]
SERVED_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn cache_file_path() -> std::path::PathBuf {
crate::paths::aid_dir().join("served_models_cache.json")
}
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
}
pub(crate) fn load_from_disk_cache(kind: AgentKind) -> Option<Vec<String>> {
if kind == AgentKind::Custom {
return load_disk_entry(kind, None).map(|entry| entry.models);
}
let agent = super::get_agent(kind);
let fingerprint = agent.served_models_fingerprint();
if fingerprint.is_some() {
return get_served_models_cached(agent.as_ref());
}
load_disk_entry(kind, None).map(|entry| entry.models)
}
fn load_disk_entry(kind: AgentKind, fingerprint: Option<&str>) -> Option<ServedModelsCacheEntry> {
let content = std::fs::read_to_string(cache_file_path()).ok()?;
let mut map: HashMap<String, ServedModelsCacheEntry> = serde_json::from_str(&content).ok()?;
let entry = map.remove(kind.as_str())?;
let age = now_secs().saturating_sub(entry.updated_at_secs);
if age <= SERVED_MODELS_CACHE_TTL.as_secs()
&& fingerprint.is_none_or(|fp| entry.fingerprint.as_deref() == Some(fp)) {
Some(entry)
} else {
None
}
}
fn atomic_write_cache_file(path: &std::path::Path, content: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
static WRITE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos();
let count = WRITE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp_path = path.with_extension(format!("tmp.{}.{}.{}", std::process::id(), nanos, count));
if std::fs::write(&tmp_path, content).is_ok() && std::fs::rename(&tmp_path, path).is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
}
fn save_to_disk_cache(kind: AgentKind, models: &[String], fingerprint: Option<String>) {
let path = cache_file_path();
let mut map: HashMap<String, ServedModelsCacheEntry> = std::fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_default();
map.insert(
kind.as_str().to_string(),
ServedModelsCacheEntry {
models: models.to_vec(),
updated_at_secs: now_secs(),
fingerprint,
},
);
if let Ok(json) = serde_json::to_string_pretty(&map) {
atomic_write_cache_file(&path, &json);
}
}
pub(crate) fn clear_served_models_cache() {
if let Ok(mut guard) = cache().lock() {
guard.clear();
}
let _ = std::fs::remove_file(cache_file_path());
}
pub(crate) fn clear_served_models_cache_for_agent(kind: AgentKind) {
if let Ok(mut guard) = cache().lock() {
guard.remove(&kind);
}
let path = cache_file_path();
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(mut map) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&content) {
if map.remove(kind.as_str()).is_some() {
if let Ok(json) = serde_json::to_string_pretty(&map) {
atomic_write_cache_file(&path, &json);
}
}
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub(crate) enum ModelSource {
UserSupplied,
AidResolved,
Advised,
}
impl Default for ModelSource {
fn default() -> Self {
Self::UserSupplied
}
}
pub(crate) fn validate_model_for_agent(
agent: &dyn Agent,
model: &str,
source: ModelSource,
) -> Result<bool> {
let model_clean = model.trim();
if model_clean.is_empty() {
return Ok(true);
}
let kind = agent.kind();
let (served, from_live_probe) = get_served_models_cached_with_status(agent);
let Some(served_list) = served else {
aid_info!(
"[aid] Cannot query served models for {}; allowing dispatch with model '{model_clean}'",
kind.as_str()
);
return Ok(true);
};
if served_list.is_empty() {
aid_info!(
"[aid] No served models reported for {}; allowing dispatch with model '{model_clean}'",
kind.as_str()
);
return Ok(true);
}
if served_list.iter().any(|m| m.eq_ignore_ascii_case(model_clean)) {
return Ok(true);
}
let fresh_served_list = if from_live_probe {
served_list
} else {
match refresh_served_models_cached(agent) {
Some(models) => models,
None if source == ModelSource::Advised => return Ok(true),
None => served_list,
}
};
if source == ModelSource::Advised && fresh_served_list.is_empty() {
return Ok(true);
}
if fresh_served_list.iter().any(|m| m.eq_ignore_ascii_case(model_clean)) {
return Ok(true);
}
let list_str = fresh_served_list.join(", ");
if source == ModelSource::Advised {
return Err(anyhow!("advised model '{model_clean}' unavailable for {}; refusing a different default. Served models: {list_str}", kind.as_str()));
}
if source == ModelSource::UserSupplied {
return Err(anyhow!(
"Agent '{}' does not serve model '{model_clean}'. Served models: {list_str}",
kind.as_str()
));
}
aid_warn!(
"[aid] Agent '{}' does not serve aid-selected model '{model_clean}'; dropping it and using the agent's own default model",
kind.as_str()
);
Ok(false)
}
pub(crate) fn refresh_served_models_cached(agent: &dyn Agent) -> Option<Vec<String>> {
let kind = agent.kind();
let fingerprint = agent.served_models_fingerprint();
let result = agent.served_models().ok().flatten();
if let Ok(mut guard) = cache().lock() {
guard.insert(
kind,
CachedServedModels {
models: result.clone(),
from_live_probe: true,
fingerprint: fingerprint.clone(),
},
);
}
if let Some(ref models) = result {
save_to_disk_cache(kind, models, fingerprint);
} else {
clear_served_models_cache_for_agent(kind);
}
result
}
pub(crate) fn get_served_models_cached(agent: &dyn Agent) -> Option<Vec<String>> {
get_served_models_cached_with_status(agent).0
}
pub(crate) fn get_served_models_cached_with_status(agent: &dyn Agent) -> (Option<Vec<String>>, bool) {
let kind = agent.kind();
#[cfg(test)]
if let Some(res) = cache_tests::mock_models(kind) { return (res, true); }
let fingerprint = agent.served_models_fingerprint();
if let Ok(guard) = cache().lock() {
if let Some(cached) = guard.get(&kind).filter(|entry|
fingerprint.as_ref().is_none_or(|fp| entry.fingerprint.as_ref() == Some(fp))) {
return (cached.models.clone(), cached.from_live_probe);
}
}
if let Some(entry) = load_disk_entry(kind, fingerprint.as_deref()) {
let disk_models = entry.models;
if let Ok(mut guard) = cache().lock() {
guard.insert(
kind,
CachedServedModels {
models: Some(disk_models.clone()),
from_live_probe: false,
fingerprint: entry.fingerprint,
},
);
}
return (Some(disk_models), false);
}
let fresh = refresh_served_models_cached(agent);
(fresh, true)
}
pub(crate) fn run_probe_cmd(program: &str, args: &[&str]) -> Option<super::env::CliCommandOutput> {
super::env_identity::run_bounded(program, args, super::env_identity::DEFAULT_PROBE_TIMEOUT)
.ok().filter(|output| output.success)
}
#[cfg(test)]
#[path = "model_validation_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "model_validation_cache_tests.rs"]
mod cache_tests;
#[cfg(test)]
pub(crate) use cache_tests::MockServedModelsGuard;