use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use vtcode_commons::llm::LLMError;
use super::llamacpp::fetch_llamacpp_models;
use super::lmstudio::fetch_lmstudio_models;
use super::local_server::{LocalProvider, probe};
use super::ollama::fetch_ollama_models;
const READINESS_CACHE_TTL: Duration = Duration::from_secs(15);
struct CacheEntry {
verified_at: Instant,
models: Vec<String>,
}
static READINESS_CACHE: LazyLock<Mutex<HashMap<LocalProvider, CacheEntry>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalReadinessError {
ServerDown { provider: LocalProvider },
ModelMissing { provider: LocalProvider, model: String },
}
impl LocalReadinessError {
pub fn code(&self) -> &'static str {
match self {
Self::ServerDown { .. } => "local_server_down",
Self::ModelMissing { .. } => "local_model_missing",
}
}
pub fn fix_command(&self) -> String {
match self {
Self::ServerDown { provider } => format!("/local start {}", provider.key()),
Self::ModelMissing { provider, model } => match provider {
LocalProvider::Ollama => format!("ollama pull {model}"),
LocalProvider::LmStudio => format!("lms load {model}"),
LocalProvider::LlamaCpp => {
format!("load '{model}' in llama.cpp (set LLAMACPP_MODEL_PATH and run /local start llamacpp)")
}
},
}
}
pub fn recovery_hint(&self) -> String {
match self {
Self::ServerDown { provider } => format!(
"{} server is not running. Start it with `/local start {}` (or the app/CLI), \
then retry.",
provider.display_name(),
provider.key()
),
Self::ModelMissing { provider, model } => {
format!("Model '{model}' is not available on {}. Fix: {}", provider.display_name(), self.fix_command())
}
}
}
pub fn to_llm_error(&self, display: &str) -> LLMError {
LLMError::Provider {
message: self.recovery_hint(),
metadata: Some(vtcode_commons::llm::LLMErrorMetadata::new(
display,
None,
Some(self.code().to_string()),
None,
None,
None,
None,
)),
}
}
}
fn is_cloud_ollama_model(model: &str) -> bool {
model.contains(":cloud") || model.contains("-cloud")
}
pub async fn resolve_local_model(
provider: LocalProvider,
requested: &str,
base_url: Option<&str>,
) -> Result<String, LocalReadinessError> {
if provider == LocalProvider::Ollama && is_cloud_ollama_model(requested) {
return Ok(requested.to_string());
}
let status = probe(provider).await;
if !status.running {
return Err(LocalReadinessError::ServerDown { provider });
}
let models = cached_models(provider, base_url).await;
match models {
Some(list) if !list.is_empty() => {
if list.iter().any(|m| m == requested) {
return Ok(requested.to_string());
}
if requested.trim().is_empty() && list.len() == 1 {
return Ok(list[0].clone());
}
Err(LocalReadinessError::ModelMissing { provider, model: requested.to_string() })
}
_ => Ok(requested.to_string()),
}
}
async fn cached_models(provider: LocalProvider, base_url: Option<&str>) -> Option<Vec<String>> {
{
let guard = READINESS_CACHE.lock().ok()?;
if let Some(entry) = guard.get(&provider)
&& entry.verified_at.elapsed() < READINESS_CACHE_TTL
{
return Some(entry.models.clone());
}
}
let fetched = fetch_models(provider, base_url).await;
if let Ok(models) = fetched {
if let Ok(mut guard) = READINESS_CACHE.lock() {
guard.insert(
provider,
CacheEntry {
verified_at: Instant::now(),
models: models.clone(),
},
);
}
Some(models)
} else {
None
}
}
async fn fetch_models(provider: LocalProvider, base_url: Option<&str>) -> anyhow::Result<Vec<String>> {
let base = base_url.map(str::to_string);
match provider {
LocalProvider::Ollama => fetch_ollama_models(base).await,
LocalProvider::LmStudio => fetch_lmstudio_models(base).await,
LocalProvider::LlamaCpp => fetch_llamacpp_models(base).await,
}
}
pub fn invalidate_readiness_cache() {
if let Ok(mut guard) = READINESS_CACHE.lock() {
guard.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fix_command_for_server_down() {
let err = LocalReadinessError::ServerDown { provider: LocalProvider::Ollama };
assert_eq!(err.code(), "local_server_down");
assert_eq!(err.fix_command(), "/local start ollama");
}
#[test]
fn fix_command_for_missing_model() {
let ollama = LocalReadinessError::ModelMissing {
provider: LocalProvider::Ollama,
model: "gpt-oss:20b".to_string(),
};
assert_eq!(ollama.fix_command(), "ollama pull gpt-oss:20b");
assert_eq!(ollama.code(), "local_model_missing");
let lm = LocalReadinessError::ModelMissing {
provider: LocalProvider::LmStudio,
model: "my-model".to_string(),
};
assert_eq!(lm.fix_command(), "lms load my-model");
}
#[test]
fn cloud_ollama_models_bypass_check() {
assert!(is_cloud_ollama_model("deepseek-v4-flash:cloud"));
assert!(is_cloud_ollama_model("glm-5.2-cloud"));
assert!(!is_cloud_ollama_model("gpt-oss:20b"));
}
}