use std::collections::HashMap; use std::sync::Arc; use anyhow::{Result, anyhow}; use clap::{Args, Subcommand}; use owlen_core::ProviderConfig; use owlen_core::config::{self as core_config, Config}; use owlen_core::provider::{ AnnotatedModelInfo, ModelProvider, ProviderManager, ProviderStatus, ProviderType, }; use owlen_core::storage::StorageManager; use owlen_providers::ollama::{OllamaCloudProvider, OllamaLocalProvider}; use owlen_tui::config as tui_config; use super::cloud; /// CLI subcommands for provider management. #[derive(Debug, Subcommand)] pub enum ProvidersCommand { /// List configured providers and their metadata. List, /// Run health checks against providers. Status { /// Optional provider identifier to check. #[arg(value_name = "PROVIDER")] provider: Option, }, /// Enable a provider in the configuration. Enable { /// Provider identifier to enable. provider: String, }, /// Disable a provider in the configuration. Disable { /// Provider identifier to disable. provider: String, }, } /// Arguments for the `owlen models` command. #[derive(Debug, Default, Args)] pub struct ModelsArgs { /// Restrict output to a specific provider. #[arg(long)] pub provider: Option, } pub async fn run_providers_command(command: ProvidersCommand) -> Result<()> { match command { ProvidersCommand::List => list_providers(), ProvidersCommand::Status { provider } => status_providers(provider.as_deref()).await, ProvidersCommand::Enable { provider } => toggle_provider(&provider, true), ProvidersCommand::Disable { provider } => toggle_provider(&provider, false), } } pub async fn run_models_command(args: ModelsArgs) -> Result<()> { list_models(args.provider.as_deref()).await } fn list_providers() -> Result<()> { let config = tui_config::try_load_config().unwrap_or_default(); let default_provider = canonical_provider_id(&config.general.default_provider); let mut rows = Vec::new(); for (id, cfg) in &config.providers { let type_label = describe_provider_type(id, cfg); let auth_label = describe_auth(cfg, requires_auth(id, cfg)); let enabled = if cfg.enabled { "yes" } else { "no" }; let default = if id == &default_provider { "*" } else { "" }; let base = cfg .base_url .as_ref() .map(|value| value.trim().to_string()) .unwrap_or_else(|| "-".to_string()); rows.push(ProviderListRow { id: id.to_string(), type_label, enabled: enabled.to_string(), default: default.to_string(), auth: auth_label, base_url: base, }); } rows.sort_by(|a, b| a.id.cmp(&b.id)); let id_width = rows .iter() .map(|row| row.id.len()) .max() .unwrap_or(8) .max("Provider".len()); let enabled_width = rows .iter() .map(|row| row.enabled.len()) .max() .unwrap_or(7) .max("Enabled".len()); let default_width = rows .iter() .map(|row| row.default.len()) .max() .unwrap_or(7) .max("Default".len()); let type_width = rows .iter() .map(|row| row.type_label.len()) .max() .unwrap_or(4) .max("Type".len()); let auth_width = rows .iter() .map(|row| row.auth.len()) .max() .unwrap_or(4) .max("Auth".len()); println!( "{:) -> Result<()> { let mut config = tui_config::try_load_config().unwrap_or_default(); let filter = filter.map(canonical_provider_id); verify_provider_filter(&config, filter.as_deref())?; let storage = Arc::new(StorageManager::new().await?); cloud::load_runtime_credentials(&mut config, storage.clone()).await?; let manager = ProviderManager::new(&config); let records = register_enabled_providers(&manager, &config, filter.as_deref()).await?; let health = manager.refresh_health().await; let mut rows = Vec::new(); for record in records { let status = health.get(&record.id).copied(); rows.push(ProviderStatusRow::from_record(record, status)); } rows.sort_by(|a, b| a.id.cmp(&b.id)); print_status_rows(&rows); Ok(()) } async fn list_models(filter: Option<&str>) -> Result<()> { let mut config = tui_config::try_load_config().unwrap_or_default(); let filter = filter.map(canonical_provider_id); verify_provider_filter(&config, filter.as_deref())?; let storage = Arc::new(StorageManager::new().await?); cloud::load_runtime_credentials(&mut config, storage.clone()).await?; let manager = ProviderManager::new(&config); let records = register_enabled_providers(&manager, &config, filter.as_deref()).await?; let models = manager .list_all_models() .await .map_err(|err| anyhow!(err))?; let statuses = manager.provider_statuses().await; print_models(records, models, statuses); Ok(()) } fn verify_provider_filter(config: &Config, filter: Option<&str>) -> Result<()> { if let Some(filter) = filter && !config.providers.contains_key(filter) { return Err(anyhow!( "Provider '{}' is not defined in configuration.", filter )); } Ok(()) } fn toggle_provider(provider: &str, enable: bool) -> Result<()> { let mut config = tui_config::try_load_config().unwrap_or_default(); let canonical = canonical_provider_id(provider); if canonical.is_empty() { return Err(anyhow!("Provider name cannot be empty.")); } let previous_default = config.general.default_provider.clone(); let previous_fallback_enabled = config.providers.get("ollama_local").map(|cfg| cfg.enabled); let previous_enabled; { let entry = core_config::ensure_provider_config_mut(&mut config, &canonical); previous_enabled = entry.enabled; if previous_enabled == enable { println!( "Provider '{}' is already {}.", canonical, if enable { "enabled" } else { "disabled" } ); return Ok(()); } entry.enabled = enable; } if !enable && config.general.default_provider == canonical { if let Some(candidate) = choose_fallback_provider(&config, &canonical) { config.general.default_provider = candidate.clone(); println!( "Default provider set to '{}' because '{}' was disabled.", candidate, canonical ); } else { let entry = core_config::ensure_provider_config_mut(&mut config, "ollama_local"); entry.enabled = true; config.general.default_provider = "ollama_local".to_string(); println!( "Enabled 'ollama_local' and made it default because no other providers are active." ); } } if let Err(err) = config.validate() { { let entry = core_config::ensure_provider_config_mut(&mut config, &canonical); entry.enabled = previous_enabled; } config.general.default_provider = previous_default; if let Some(enabled) = previous_fallback_enabled && let Some(entry) = config.providers.get_mut("ollama_local") { entry.enabled = enabled; } return Err(anyhow!(err)); } tui_config::save_config(&config).map_err(|err| anyhow!(err))?; println!( "{} provider '{}'.", if enable { "Enabled" } else { "Disabled" }, canonical ); Ok(()) } fn choose_fallback_provider(config: &Config, exclude: &str) -> Option { if exclude != "ollama_local" && let Some(cfg) = config.providers.get("ollama_local") && cfg.enabled { return Some("ollama_local".to_string()); } let mut candidates: Vec = config .providers .iter() .filter(|(id, cfg)| cfg.enabled && id.as_str() != exclude) .map(|(id, _)| id.clone()) .collect(); candidates.sort(); candidates.into_iter().next() } async fn register_enabled_providers( manager: &ProviderManager, config: &Config, filter: Option<&str>, ) -> Result> { let default_provider = canonical_provider_id(&config.general.default_provider); let mut records = Vec::new(); for (id, cfg) in &config.providers { if let Some(filter) = filter && id != filter { continue; } let mut record = ProviderRecord::from_config(id, cfg, id == &default_provider); if !cfg.enabled { records.push(record); continue; } match instantiate_provider(id, cfg) { Ok(provider) => { let metadata = provider.metadata().clone(); record.provider_type_label = provider_type_label(metadata.provider_type); record.requires_auth = metadata.requires_auth; record.metadata = Some(metadata); manager.register_provider(provider).await; } Err(err) => { record.registration_error = Some(err.to_string()); } } records.push(record); } records.sort_by(|a, b| a.id.cmp(&b.id)); Ok(records) } fn instantiate_provider(id: &str, cfg: &ProviderConfig) -> Result> { let kind = cfg.provider_type.trim().to_ascii_lowercase(); if kind == "ollama" || id == "ollama_local" { let provider = OllamaLocalProvider::new(cfg.base_url.clone(), None, None) .map_err(|err| anyhow!(err))?; Ok(Arc::new(provider)) } else if kind == "ollama_cloud" || id == "ollama_cloud" { let provider = OllamaCloudProvider::new(cfg.base_url.clone(), cfg.api_key.clone(), None) .map_err(|err| anyhow!(err))?; Ok(Arc::new(provider)) } else { Err(anyhow!( "Provider '{}' uses unsupported type '{}'.", id, if kind.is_empty() { "unknown" } else { kind.as_str() } )) } } fn describe_provider_type(id: &str, cfg: &ProviderConfig) -> String { if cfg.provider_type.trim().eq_ignore_ascii_case("ollama") || id.ends_with("_local") { "Local".to_string() } else if cfg .provider_type .trim() .eq_ignore_ascii_case("ollama_cloud") || id.contains("cloud") { "Cloud".to_string() } else { "Custom".to_string() } } fn requires_auth(id: &str, cfg: &ProviderConfig) -> bool { cfg.api_key.is_some() || cfg.api_key_env.is_some() || matches!(id, "ollama_cloud" | "openai" | "anthropic") } fn describe_auth(cfg: &ProviderConfig, required: bool) -> String { if let Some(env) = cfg .api_key_env .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) { format!("env:{env}") } else if cfg .api_key .as_ref() .map(|value| !value.trim().is_empty()) .unwrap_or(false) { "config".to_string() } else if required { "required".to_string() } else { "-".to_string() } } fn canonical_provider_id(raw: &str) -> String { let trimmed = raw.trim().to_ascii_lowercase(); if trimmed.is_empty() { return trimmed; } match trimmed.as_str() { "ollama" | "ollama-local" => "ollama_local".to_string(), "ollama_cloud" | "ollama-cloud" => "ollama_cloud".to_string(), other => other.replace('-', "_"), } } fn provider_type_label(provider_type: ProviderType) -> String { match provider_type { ProviderType::Local => "Local".to_string(), ProviderType::Cloud => "Cloud".to_string(), } } fn provider_status_strings(status: ProviderStatus) -> (&'static str, &'static str) { match status { ProviderStatus::Available => ("OK", "available"), ProviderStatus::Unavailable => ("ERR", "unavailable"), ProviderStatus::RequiresSetup => ("SETUP", "requires setup"), } } fn print_status_rows(rows: &[ProviderStatusRow]) { let id_width = rows .iter() .map(|row| row.id.len()) .max() .unwrap_or(8) .max("Provider".len()); let type_width = rows .iter() .map(|row| row.provider_type.len()) .max() .unwrap_or(4) .max("Type".len()); let status_width = rows .iter() .map(|row| row.indicator.len() + 1 + row.status_label.len()) .max() .unwrap_or(6) .max("State".len()); println!( "{:, models: Vec, statuses: HashMap, ) { let mut grouped: HashMap> = HashMap::new(); for info in models { grouped .entry(info.provider_id.clone()) .or_default() .push(info); } for record in records { let status = statuses.get(&record.id).copied().or_else(|| { if record.metadata.is_some() && record.registration_error.is_none() && record.enabled { Some(ProviderStatus::Unavailable) } else { None } }); let (indicator, label, status_value) = if !record.enabled { ("-", "disabled", None) } else if record.registration_error.is_some() { ("ERR", "error", None) } else if let Some(status) = status { let (indicator, label) = provider_status_strings(status); (indicator, label, Some(status)) } else { ("?", "unknown", None) }; let title = if record.default_provider { format!("{} (default)", record.id) } else { record.id.clone() }; println!( "{} {} [{}] {}", indicator, title, record.provider_type_label, label ); if let Some(err) = &record.registration_error { println!(" error: {}", err); println!(); continue; } if !record.enabled { println!(" provider disabled"); println!(); continue; } if let Some(entries) = grouped.get(&record.id) { let mut entries = entries.clone(); entries.sort_by(|a, b| a.model.name.cmp(&b.model.name)); if entries.is_empty() { println!(" (no models reported)"); } else { for entry in entries { let mut line = format!(" - {}", entry.model.name); if let Some(description) = &entry.model.description && !description.trim().is_empty() { line.push_str(&format!(" — {}", description.trim())); } println!("{}", line); } } } else { println!(" (no models reported)"); } if let Some(ProviderStatus::RequiresSetup) = status_value && record.requires_auth { println!(" configure provider credentials or API key"); } println!(); } } struct ProviderListRow { id: String, type_label: String, enabled: String, default: String, auth: String, base_url: String, } struct ProviderRecord { id: String, enabled: bool, default_provider: bool, provider_type_label: String, requires_auth: bool, registration_error: Option, metadata: Option, } impl ProviderRecord { fn from_config(id: &str, cfg: &ProviderConfig, default_provider: bool) -> Self { Self { id: id.to_string(), enabled: cfg.enabled, default_provider, provider_type_label: describe_provider_type(id, cfg), requires_auth: requires_auth(id, cfg), registration_error: None, metadata: None, } } } struct ProviderStatusRow { id: String, provider_type: String, default_provider: bool, indicator: String, status_label: String, detail: Option, } impl ProviderStatusRow { fn from_record(record: ProviderRecord, status: Option) -> Self { if !record.enabled { return Self { id: record.id, provider_type: record.provider_type_label, default_provider: record.default_provider, indicator: "-".to_string(), status_label: "disabled".to_string(), detail: None, }; } if let Some(err) = record.registration_error { return Self { id: record.id, provider_type: record.provider_type_label, default_provider: record.default_provider, indicator: "ERR".to_string(), status_label: "error".to_string(), detail: Some(err), }; } if let Some(status) = status { let (indicator, label) = provider_status_strings(status); return Self { id: record.id, provider_type: record.provider_type_label, default_provider: record.default_provider, indicator: indicator.to_string(), status_label: label.to_string(), detail: if matches!(status, ProviderStatus::RequiresSetup) && record.requires_auth { Some("credentials required".to_string()) } else { None }, }; } Self { id: record.id, provider_type: record.provider_type_label, default_provider: record.default_provider, indicator: "?".to_string(), status_label: "unknown".to_string(), detail: None, } } }