diff --git a/README.md b/README.md index af90ee0..e76033b 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The OWLEN interface features a clean, multi-panel layout with vim-inspired navig - **Code Side Panel**: Switch to code mode (`:mode code`) and open files inline with `:open ` for LLM-assisted coding. - **Theming System**: 10 built-in themes and support for custom themes. - **Modular Architecture**: Extensible provider system (Ollama today, additional providers on the roadmap). +- **Dual-Source Model Picker**: Merge local and cloud Ollama models with live availability indicators so you can see at a glance which catalogues are reachable. - **Guided Setup**: `owlen config doctor` upgrades legacy configs and verifies your environment in seconds. ## Security & Privacy @@ -95,6 +96,12 @@ OWLEN uses a modal, vim-inspired interface. Press `F1` (available from any mode) - **Tutorial Command**: Type `:tutorial` any time for a quick summary of the most important keybindings. - **MCP Slash Commands**: Owlen auto-registers zero-argument MCP tools as slash commands—type `/mcp__github__list_prs` (for example) to pull remote context directly into the chat log. +Model discovery commands worth remembering: + +- `:models --local` or `:models --cloud` jump directly to the corresponding section in the picker. +- `:cloud setup [--force-cloud-base-url]` stores your cloud API key without clobbering an existing local base URL (unless you opt in with the flag). +When a catalogue is unreachable, Owlen now tags the picker with `Local unavailable` / `Cloud unavailable` so you can recover without guessing. + ## Documentation For more detailed information, please refer to the following documents: diff --git a/crates/owlen-cli/src/cloud.rs b/crates/owlen-cli/src/cloud.rs index a1f00e9..a7cfd64 100644 --- a/crates/owlen-cli/src/cloud.rs +++ b/crates/owlen-cli/src/cloud.rs @@ -6,14 +6,17 @@ use anyhow::{Context, Result, anyhow, bail}; use clap::Subcommand; use owlen_core::LlmProvider; use owlen_core::ProviderConfig; -use owlen_core::config as core_config; -use owlen_core::config::Config; +use owlen_core::config::{ + self as core_config, Config, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY, +}; use owlen_core::credentials::{ApiCredentials, CredentialManager, OLLAMA_CLOUD_CREDENTIAL_ID}; use owlen_core::encryption; use owlen_core::providers::OllamaProvider; use owlen_core::storage::StorageManager; +use serde_json::Value; -const DEFAULT_CLOUD_ENDPOINT: &str = "https://ollama.com"; +const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL; +const CLOUD_ENDPOINT_KEY: &str = OLLAMA_CLOUD_ENDPOINT_KEY; #[derive(Debug, Subcommand)] pub enum CloudCommand { @@ -28,6 +31,9 @@ pub enum CloudCommand { /// Provider name to configure (default: ollama) #[arg(long, default_value = "ollama")] provider: String, + /// Overwrite the provider base URL with the cloud endpoint + #[arg(long)] + force_cloud_base_url: bool, }, /// Check connectivity to Ollama Cloud Status { @@ -55,19 +61,29 @@ pub async fn run_cloud_command(command: CloudCommand) -> Result<()> { api_key, endpoint, provider, - } => setup(provider, api_key, endpoint).await, + force_cloud_base_url, + } => setup(provider, api_key, endpoint, force_cloud_base_url).await, CloudCommand::Status { provider } => status(provider).await, CloudCommand::Models { provider } => models(provider).await, CloudCommand::Logout { provider } => logout(provider).await, } } -async fn setup(provider: String, api_key: Option, endpoint: Option) -> Result<()> { +async fn setup( + provider: String, + api_key: Option, + endpoint: Option, + force_cloud_base_url: bool, +) -> Result<()> { let provider = canonical_provider_name(&provider); let mut config = crate::config::try_load_config().unwrap_or_default(); - let endpoint = endpoint.unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string()); + let endpoint = + normalize_endpoint(&endpoint.unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string())); - ensure_provider_entry(&mut config, &provider, &endpoint); + let base_changed = { + let entry = ensure_provider_entry(&mut config, &provider); + configure_cloud_endpoint(entry, &endpoint, force_cloud_base_url) + }; let key = match api_key { Some(value) if !value.trim().is_empty() => value, @@ -95,10 +111,6 @@ async fn setup(provider: String, api_key: Option, endpoint: Option, endpoint: Option Result<()> { }; let api_key = hydrate_api_key(&mut config, manager.as_ref()).await?; - ensure_provider_entry(&mut config, &provider, DEFAULT_CLOUD_ENDPOINT); + { + let entry = ensure_provider_entry(&mut config, &provider); + configure_cloud_endpoint(entry, DEFAULT_CLOUD_ENDPOINT, false); + } let provider_cfg = config .provider(&provider) .cloned() .ok_or_else(|| anyhow!("Provider '{provider}' is not configured"))?; - let ollama = OllamaProvider::from_config(&provider_cfg, Some(&config.general)) + let endpoint = + resolve_cloud_endpoint(&provider_cfg).unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string()); + let mut runtime_cfg = provider_cfg.clone(); + runtime_cfg.base_url = Some(endpoint.clone()); + runtime_cfg.extra.insert( + OLLAMA_MODE_KEY.to_string(), + Value::String("cloud".to_string()), + ); + + let ollama = OllamaProvider::from_config(&runtime_cfg, Some(&config.general)) .with_context(|| "Failed to construct Ollama provider. Run `owlen cloud setup` first.")?; match ollama.health_check().await { Ok(_) => { - println!( - "✓ Connected to {provider} ({})", - provider_cfg - .base_url - .as_deref() - .unwrap_or(DEFAULT_CLOUD_ENDPOINT) - ); + println!("✓ Connected to {provider} ({})", endpoint); if api_key.is_none() && config.privacy.encrypt_local_data { println!( "Warning: No API key stored; connection succeeded via environment variables." @@ -164,13 +188,26 @@ async fn models(provider: String) -> Result<()> { }; hydrate_api_key(&mut config, manager.as_ref()).await?; - ensure_provider_entry(&mut config, &provider, DEFAULT_CLOUD_ENDPOINT); + { + let entry = ensure_provider_entry(&mut config, &provider); + configure_cloud_endpoint(entry, DEFAULT_CLOUD_ENDPOINT, false); + } + let provider_cfg = config .provider(&provider) .cloned() .ok_or_else(|| anyhow!("Provider '{provider}' is not configured"))?; - let ollama = OllamaProvider::from_config(&provider_cfg, Some(&config.general)) + let endpoint = + resolve_cloud_endpoint(&provider_cfg).unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string()); + let mut runtime_cfg = provider_cfg.clone(); + runtime_cfg.base_url = Some(endpoint); + runtime_cfg.extra.insert( + OLLAMA_MODE_KEY.to_string(), + Value::String("cloud".to_string()), + ); + + let ollama = OllamaProvider::from_config(&runtime_cfg, Some(&config.general)) .with_context(|| "Failed to construct Ollama provider. Run `owlen cloud setup` first.")?; match ollama.list_models().await { @@ -217,7 +254,7 @@ async fn logout(provider: String) -> Result<()> { Ok(()) } -fn ensure_provider_entry(config: &mut Config, provider: &str, endpoint: &str) { +fn ensure_provider_entry<'a>(config: &'a mut Config, provider: &str) -> &'a mut ProviderConfig { if provider == "ollama" && config.providers.contains_key("ollama-cloud") && !config.providers.contains_key("ollama") @@ -230,13 +267,68 @@ fn ensure_provider_entry(config: &mut Config, provider: &str, endpoint: &str) { core_config::ensure_provider_config(config, provider); - if let Some(cfg) = config.providers.get_mut(provider) { - if cfg.provider_type != "ollama" { - cfg.provider_type = "ollama".to_string(); - } - if cfg.base_url.is_none() { - cfg.base_url = Some(endpoint.to_string()); - } + let entry = config + .providers + .get_mut(provider) + .expect("provider entry must exist"); + + if entry.provider_type != "ollama" { + entry.provider_type = "ollama".to_string(); + } + + entry +} + +fn configure_cloud_endpoint(entry: &mut ProviderConfig, endpoint: &str, force: bool) -> bool { + let normalized = normalize_endpoint(endpoint); + let previous_base = entry.base_url.clone(); + entry.extra.insert( + CLOUD_ENDPOINT_KEY.to_string(), + Value::String(normalized.clone()), + ); + + if force + || entry + .base_url + .as_ref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + entry.base_url = Some(normalized.clone()); + } + + if force { + entry.extra.insert( + OLLAMA_MODE_KEY.to_string(), + Value::String("cloud".to_string()), + ); + } + + entry.base_url != previous_base +} + +fn resolve_cloud_endpoint(cfg: &ProviderConfig) -> Option { + if let Some(value) = cfg + .extra + .get(CLOUD_ENDPOINT_KEY) + .and_then(|value| value.as_str()) + .map(normalize_endpoint) + { + return Some(value); + } + + cfg.base_url + .as_ref() + .map(|value| value.trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) +} + +fn normalize_endpoint(endpoint: &str) -> String { + let trimmed = endpoint.trim().trim_end_matches('/'); + if trimmed.is_empty() { + DEFAULT_CLOUD_ENDPOINT.to_string() + } else { + trimmed.to_string() } } @@ -374,9 +466,7 @@ async fn hydrate_api_key( let Some(cfg) = provider_entry_mut(config) else { return Ok(Some(key)); }; - if cfg.base_url.is_none() && !credentials.endpoint.trim().is_empty() { - cfg.base_url = Some(credentials.endpoint.clone()); - } + configure_cloud_endpoint(cfg, &credentials.endpoint, false); return Ok(Some(key)); } diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index eb8d835..b4bfd1d 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -16,7 +16,14 @@ use std::time::Duration; pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml"; /// Current schema version written to `config.toml`. -pub const CONFIG_SCHEMA_VERSION: &str = "1.4.0"; +pub const CONFIG_SCHEMA_VERSION: &str = "1.5.0"; + +/// Provider config key for forcing Ollama provider mode. +pub const OLLAMA_MODE_KEY: &str = "ollama_mode"; +/// Extra config key storing the preferred Ollama Cloud endpoint. +pub const OLLAMA_CLOUD_ENDPOINT_KEY: &str = "cloud_endpoint"; +/// Canonical Ollama Cloud base URL. +pub const OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com"; /// Core configuration shared by all OWLEN clients #[derive(Debug, Clone, Serialize, Deserialize)] @@ -574,6 +581,23 @@ impl Config { self.merge_legacy_ollama_provider(legacy_cloud); } + if let Some(ollama) = self.providers.get_mut("ollama") { + let previous_mode = ollama + .extra + .get(OLLAMA_MODE_KEY) + .and_then(|value| value.as_str()) + .map(|value| value.to_ascii_lowercase()); + ensure_ollama_mode_extra(ollama); + if previous_mode.as_deref().unwrap_or("auto") == "auto" + && is_cloud_base_url(ollama.base_url.as_ref()) + { + ollama.extra.insert( + OLLAMA_MODE_KEY.to_string(), + serde_json::Value::String("cloud".to_string()), + ); + } + } + self.schema_version = CONFIG_SCHEMA_VERSION.to_string(); } @@ -594,9 +618,12 @@ impl Config { if target.extra.is_empty() && !legacy_cloud.extra.is_empty() { target.extra = legacy_cloud.extra; } + ensure_ollama_mode_extra(target); } Entry::Vacant(entry) => { - entry.insert(legacy_cloud); + let mut inserted = legacy_cloud; + ensure_ollama_mode_extra(&mut inserted); + entry.insert(inserted); } } } @@ -669,12 +696,47 @@ impl Config { } fn default_ollama_provider_config() -> ProviderConfig { - ProviderConfig { + let mut config = ProviderConfig { provider_type: "ollama".to_string(), base_url: Some("http://localhost:11434".to_string()), api_key: None, extra: HashMap::new(), + }; + ensure_ollama_mode_extra(&mut config); + config +} + +fn ensure_ollama_mode_extra(provider: &mut ProviderConfig) { + if provider.provider_type != "ollama" { + return; } + + let entry = provider + .extra + .entry(OLLAMA_MODE_KEY.to_string()) + .or_insert_with(|| serde_json::Value::String("auto".to_string())); + + if let Some(value) = entry.as_str() { + let normalized = value.trim().to_ascii_lowercase(); + if matches!(normalized.as_str(), "auto" | "local" | "cloud") { + if normalized != value { + *entry = serde_json::Value::String(normalized); + } + } else { + *entry = serde_json::Value::String("auto".to_string()); + } + } else { + *entry = serde_json::Value::String("auto".to_string()); + } +} + +fn is_cloud_base_url(base_url: Option<&String>) -> bool { + base_url + .map(|url| { + let trimmed = url.trim_end_matches('/'); + trimmed == OLLAMA_CLOUD_BASE_URL || trimmed.starts_with("https://ollama.com/") + }) + .unwrap_or(false) } fn validate_mcp_server_entry(server: &McpServerConfig, scope: McpConfigScope) -> Result<()> { @@ -1603,9 +1665,11 @@ pub fn ensure_provider_config<'a>( } match config.providers.entry(provider_name.to_string()) { - Entry::Occupied(entry) => entry.into_mut(), + Entry::Occupied(mut entry) => { + ensure_ollama_mode_extra(entry.get_mut()); + } Entry::Vacant(entry) => { - let default = match provider_name { + let mut default = match provider_name { "ollama" => default_ollama_provider_config(), other => ProviderConfig { provider_type: other.to_string(), @@ -1614,9 +1678,15 @@ pub fn ensure_provider_config<'a>( extra: HashMap::new(), }, }; - entry.insert(default) + ensure_ollama_mode_extra(&mut default); + entry.insert(default); } } + + config + .providers + .get(provider_name) + .expect("provider entry must exist") } /// Calculate absolute timeout for session data based on configuration @@ -1723,6 +1793,14 @@ mod tests { fn default_config_contains_local_provider() { let config = Config::default(); assert!(config.providers.contains_key("ollama")); + let provider = config.providers.get("ollama").unwrap(); + assert_eq!( + provider + .extra + .get(OLLAMA_MODE_KEY) + .and_then(|value| value.as_str()), + Some("auto") + ); } #[test] @@ -1732,6 +1810,13 @@ mod tests { let cloud = ensure_provider_config(&mut config, "ollama-cloud"); assert_eq!(cloud.provider_type, "ollama"); assert_eq!(cloud.base_url.as_deref(), Some("http://localhost:11434")); + assert_eq!( + cloud + .extra + .get(OLLAMA_MODE_KEY) + .and_then(|value| value.as_str()), + Some("auto") + ); assert!(config.providers.contains_key("ollama")); assert!(!config.providers.contains_key("ollama-cloud")); } @@ -1758,6 +1843,33 @@ mod tests { assert_eq!(cloud.provider_type, "ollama"); assert_eq!(cloud.base_url.as_deref(), Some("https://api.ollama.com")); assert_eq!(cloud.api_key.as_deref(), Some("secret")); + assert_eq!( + cloud + .extra + .get(OLLAMA_MODE_KEY) + .and_then(|value| value.as_str()), + Some("auto") + ); + } + + #[test] + fn migration_sets_cloud_mode_for_cloud_base() { + let mut config = Config::default(); + if let Some(ollama) = config.providers.get_mut("ollama") { + ollama.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); + ollama.extra.remove(OLLAMA_MODE_KEY); + } + + config.apply_schema_migrations("1.4.0"); + + let provider = config.providers.get("ollama").expect("ollama provider"); + assert_eq!( + provider + .extra + .get(OLLAMA_MODE_KEY) + .and_then(|value| value.as_str()), + Some("cloud") + ); } #[test] diff --git a/crates/owlen-core/src/providers/ollama.rs b/crates/owlen-core/src/providers/ollama.rs index 564cdc6..2d8d2cf 100644 --- a/crates/owlen-core/src/providers/ollama.rs +++ b/crates/owlen-core/src/providers/ollama.rs @@ -1,9 +1,11 @@ //! Ollama provider built on top of the `ollama-rs` crate. use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, env, + net::{SocketAddr, TcpStream}, pin::Pin, - time::{Duration, SystemTime}, + sync::Arc, + time::{Duration, Instant, SystemTime}, }; use anyhow::anyhow; @@ -22,11 +24,17 @@ use ollama_rs::{ }; use reqwest::{Client, StatusCode, Url}; use serde_json::{Map as JsonMap, Value, json}; +use tokio::{sync::RwLock, time::timeout}; + +#[cfg(test)] +use std::sync::{Mutex, OnceLock}; +#[cfg(test)] +use tokio_test::block_on; use uuid::Uuid; use crate::{ Error, Result, - config::GeneralSettings, + config::{GeneralSettings, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY}, llm::{LlmProvider, ProviderConfig}, mcp::McpToolDescriptor, model::{DetailedModelInfo, ModelDetailsCache, ModelManager}, @@ -37,9 +45,11 @@ use crate::{ const DEFAULT_TIMEOUT_SECS: u64 = 120; const DEFAULT_MODEL_CACHE_TTL_SECS: u64 = 60; -const CLOUD_BASE_URL: &str = "https://ollama.com"; +pub(crate) const CLOUD_BASE_URL: &str = OLLAMA_CLOUD_BASE_URL; +const LOCAL_PROBE_TIMEOUT_MS: u64 = 200; +const LOCAL_PROBE_TARGETS: &[&str] = &["127.0.0.1:11434", "[::1]:11434"]; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum OllamaMode { Local, Cloud, @@ -54,6 +64,44 @@ impl OllamaMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScopeAvailability { + Unknown, + Available, + Unavailable, +} + +impl ScopeAvailability { + fn as_str(self) -> &'static str { + match self { + ScopeAvailability::Unknown => "unknown", + ScopeAvailability::Available => "available", + ScopeAvailability::Unavailable => "unavailable", + } + } +} + +#[derive(Debug, Clone)] +struct ScopeSnapshot { + models: Vec, + fetched_at: Option, + availability: ScopeAvailability, + last_error: Option, + last_checked: Option, +} + +impl Default for ScopeSnapshot { + fn default() -> Self { + Self { + models: Vec::new(), + fetched_at: None, + availability: ScopeAvailability::Unknown, + last_error: None, + last_checked: None, + } + } +} + #[derive(Debug)] struct OllamaOptions { mode: OllamaMode, @@ -61,6 +109,7 @@ struct OllamaOptions { request_timeout: Duration, model_cache_ttl: Duration, api_key: Option, + cloud_endpoint: Option, } impl OllamaOptions { @@ -71,6 +120,7 @@ impl OllamaOptions { request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), model_cache_ttl: Duration::from_secs(DEFAULT_MODEL_CACHE_TTL_SECS), api_key: None, + cloud_endpoint: None, } } @@ -87,8 +137,78 @@ pub struct OllamaProvider { client: Ollama, http_client: Client, base_url: String, + request_timeout: Duration, + api_key: Option, + cloud_endpoint: Option, model_manager: ModelManager, model_details_cache: ModelDetailsCache, + model_cache_ttl: Duration, + scope_cache: Arc>>, +} + +fn configured_mode_from_extra(config: &ProviderConfig) -> Option { + config + .extra + .get(OLLAMA_MODE_KEY) + .and_then(|value| value.as_str()) + .and_then(|value| match value.trim().to_ascii_lowercase().as_str() { + "local" => Some(OllamaMode::Local), + "cloud" => Some(OllamaMode::Cloud), + _ => None, + }) +} + +fn is_explicit_local_base(base_url: Option<&str>) -> bool { + base_url + .and_then(|raw| Url::parse(raw).ok()) + .and_then(|parsed| parsed.host_str().map(|host| host.to_ascii_lowercase())) + .map(|host| host == "localhost" || host == "127.0.0.1" || host == "::1") + .unwrap_or(false) +} + +fn is_explicit_cloud_base(base_url: Option<&str>) -> bool { + base_url + .map(|raw| { + let trimmed = raw.trim_end_matches('/'); + trimmed == CLOUD_BASE_URL || trimmed.starts_with("https://ollama.com/") + }) + .unwrap_or(false) +} + +#[cfg(test)] +static PROBE_OVERRIDE: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +fn set_probe_override(value: Option) { + let guard = PROBE_OVERRIDE.get_or_init(|| Mutex::new(None)); + *guard.lock().expect("probe override mutex poisoned") = value; +} + +#[cfg(test)] +fn probe_override_value() -> Option { + PROBE_OVERRIDE + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("probe override mutex poisoned") + .to_owned() +} + +fn probe_default_local_daemon(timeout: Duration) -> bool { + #[cfg(test)] + { + if let Some(value) = probe_override_value() { + return value; + } + } + + for target in LOCAL_PROBE_TARGETS { + if let Ok(address) = target.parse::() { + if TcpStream::connect_timeout(&address, timeout).is_ok() { + return true; + } + } + } + false } impl OllamaProvider { @@ -105,23 +225,64 @@ impl OllamaProvider { let mut api_key = resolve_api_key(config.api_key.clone()) .or_else(|| env_var_non_empty("OLLAMA_API_KEY")) .or_else(|| env_var_non_empty("OLLAMA_CLOUD_API_KEY")); + let configured_mode = configured_mode_from_extra(config); + let configured_mode_label = config + .extra + .get(OLLAMA_MODE_KEY) + .and_then(|value| value.as_str()) + .unwrap_or("auto"); + let base_url = config.base_url.as_deref(); + let base_is_local = is_explicit_local_base(base_url); + let base_is_cloud = is_explicit_cloud_base(base_url); + let base_is_other = base_url.is_some() && !base_is_local && !base_is_cloud; - let mode = if api_key.is_some() { - OllamaMode::Cloud - } else { - OllamaMode::Local + let mut local_probe_result = None; + let cloud_endpoint = config + .extra + .get(OLLAMA_CLOUD_ENDPOINT_KEY) + .and_then(Value::as_str) + .map(normalize_cloud_endpoint) + .transpose() + .map_err(Error::Config)?; + + let mode = match configured_mode { + Some(mode) => mode, + None => { + if base_is_local || base_is_other { + OllamaMode::Local + } else if base_is_cloud && api_key.is_some() { + OllamaMode::Cloud + } else { + let probe = + probe_default_local_daemon(Duration::from_millis(LOCAL_PROBE_TIMEOUT_MS)); + local_probe_result = Some(probe); + if probe { + OllamaMode::Local + } else if api_key.is_some() { + OllamaMode::Cloud + } else { + OllamaMode::Local + } + } + } }; - let base_candidate = if mode == OllamaMode::Cloud { - Some(CLOUD_BASE_URL) - } else { - config.base_url.as_deref() + let base_candidate = match mode { + OllamaMode::Local => base_url, + OllamaMode::Cloud => { + if base_is_cloud { + base_url + } else { + Some(CLOUD_BASE_URL) + } + } }; let normalized_base_url = normalize_base_url(base_candidate, mode).map_err(Error::Config)?; - let mut options = OllamaOptions::new(mode, normalized_base_url); + let mut options = OllamaOptions::new(mode, normalized_base_url.clone()); + options.cloud_endpoint = cloud_endpoint.clone(); if let Some(timeout) = config .extra @@ -145,6 +306,23 @@ impl OllamaProvider { options = options.with_general(general); } + debug!( + "Resolved Ollama provider: mode={:?}, base_url={}, configured_mode={}, api_key_present={}, local_probe={}", + mode, + normalized_base_url, + configured_mode_label, + if options.api_key.is_some() { + "yes" + } else { + "no" + }, + match local_probe_result { + Some(true) => "success", + Some(false) => "failed", + None => "skipped", + } + ); + Self::with_options(options) } @@ -155,44 +333,32 @@ impl OllamaProvider { request_timeout, model_cache_ttl, api_key, + cloud_endpoint, } = options; - let url = Url::parse(&base_url) - .map_err(|err| Error::Config(format!("Invalid Ollama base URL '{base_url}': {err}")))?; + let api_key_ref = api_key.as_deref(); + let (ollama_client, http_client) = + build_client_for_base(&base_url, request_timeout, api_key_ref)?; - let mut headers = HeaderMap::new(); - if let Some(ref key) = api_key { - let value = HeaderValue::from_str(&format!("Bearer {key}")).map_err(|_| { - Error::Config("OLLAMA API key contains invalid characters".to_string()) - })?; - headers.insert(AUTHORIZATION, value); - } - - let mut client_builder = Client::builder().timeout(request_timeout); - if !headers.is_empty() { - client_builder = client_builder.default_headers(headers.clone()); - } - - let http_client = client_builder - .build() - .map_err(|err| Error::Config(format!("Failed to build HTTP client: {err}")))?; - - let port = url.port_or_known_default().ok_or_else(|| { - Error::Config(format!("Unable to determine port for Ollama URL '{}'", url)) - })?; - - let mut ollama_client = Ollama::new_with_client(url.clone(), port, http_client.clone()); - if !headers.is_empty() { - ollama_client.set_headers(Some(headers.clone())); - } + let scope_cache = { + let mut initial = HashMap::new(); + initial.insert(OllamaMode::Local, ScopeSnapshot::default()); + initial.insert(OllamaMode::Cloud, ScopeSnapshot::default()); + Arc::new(RwLock::new(initial)) + }; Ok(Self { mode, client: ollama_client, http_client, base_url: base_url.trim_end_matches('/').to_string(), + request_timeout, + api_key, + cloud_endpoint, model_manager: ModelManager::new(model_cache_ttl), model_details_cache: ModelDetailsCache::new(model_cache_ttl), + model_cache_ttl, + scope_cache, }) } @@ -200,6 +366,121 @@ impl OllamaProvider { build_api_endpoint(&self.base_url, endpoint) } + fn local_base_url() -> &'static str { + OllamaMode::Local.default_base_url() + } + + fn scope_key(scope: OllamaMode) -> &'static str { + match scope { + OllamaMode::Local => "local", + OllamaMode::Cloud => "cloud", + } + } + + fn build_local_client(&self) -> Result> { + if matches!(self.mode, OllamaMode::Local) { + return Ok(Some(self.client.clone())); + } + + let (client, _) = + build_client_for_base(Self::local_base_url(), self.request_timeout, None)?; + Ok(Some(client)) + } + + fn build_cloud_client(&self) -> Result> { + if matches!(self.mode, OllamaMode::Cloud) { + return Ok(Some(self.client.clone())); + } + + let api_key = match self.api_key.as_deref() { + Some(key) if !key.trim().is_empty() => key, + _ => return Ok(None), + }; + + let endpoint = self.cloud_endpoint.as_deref().unwrap_or(CLOUD_BASE_URL); + + let (client, _) = build_client_for_base(endpoint, self.request_timeout, Some(api_key))?; + Ok(Some(client)) + } + + async fn cached_scope_models(&self, scope: OllamaMode) -> Option> { + let cache = self.scope_cache.read().await; + cache.get(&scope).and_then(|entry| { + if entry.availability == ScopeAvailability::Unknown { + return None; + } + + entry.fetched_at.and_then(|ts| { + if ts.elapsed() < self.model_cache_ttl { + Some(entry.models.clone()) + } else { + None + } + }) + }) + } + + async fn update_scope_success(&self, scope: OllamaMode, models: &[ModelInfo]) { + let mut cache = self.scope_cache.write().await; + let entry = cache.entry(scope).or_default(); + entry.models = models.to_vec(); + entry.fetched_at = Some(Instant::now()); + entry.last_checked = Some(Instant::now()); + entry.availability = ScopeAvailability::Available; + entry.last_error = None; + } + + async fn mark_scope_failure(&self, scope: OllamaMode, message: String) { + let mut cache = self.scope_cache.write().await; + let entry = cache.entry(scope).or_default(); + entry.availability = ScopeAvailability::Unavailable; + entry.last_error = Some(message); + entry.last_checked = Some(Instant::now()); + } + + async fn annotate_scope_status(&self, models: &mut [ModelInfo]) { + if models.is_empty() { + return; + } + + let cache = self.scope_cache.read().await; + for (scope, snapshot) in cache.iter() { + if snapshot.availability == ScopeAvailability::Unknown { + continue; + } + let scope_key = Self::scope_key(*scope); + let capability = format!( + "scope-status:{}:{}", + scope_key, + snapshot.availability.as_str() + ); + + for model in models.iter_mut() { + if !model.capabilities.iter().any(|cap| cap == &capability) { + model.capabilities.push(capability.clone()); + } + } + + if let Some(raw_reason) = snapshot.last_error.as_ref() { + let cleaned = raw_reason.replace('\n', " ").trim().to_string(); + if !cleaned.is_empty() { + let truncated: String = cleaned.chars().take(160).collect(); + let message_capability = + format!("scope-status-message:{}:{}", scope_key, truncated); + for model in models.iter_mut() { + if !model + .capabilities + .iter() + .any(|cap| cap == &message_capability) + { + model.capabilities.push(message_capability.clone()); + } + } + } + } + } + } + /// Attempt to resolve detailed model information for the given model, using the local cache when possible. pub async fn get_model_info(&self, model_name: &str) -> Result { if let Some(info) = self.model_details_cache.get(model_name).await { @@ -312,15 +593,92 @@ impl OllamaProvider { } async fn fetch_models(&self) -> Result> { - let models = self - .client - .list_local_models() - .await - .map_err(|err| self.map_ollama_error("list models", err, None))?; + let mut combined = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut errors: Vec = Vec::new(); + + if let Some(local_client) = self.build_local_client()? { + match self + .fetch_models_for_scope(OllamaMode::Local, local_client.clone()) + .await + { + Ok(models) => { + for model in models { + let key = format!("local::{}", model.id); + if seen.insert(key) { + combined.push(model); + } + } + } + Err(err) => errors.push(err), + } + } + + if let Some(cloud_client) = self.build_cloud_client()? { + match self + .fetch_models_for_scope(OllamaMode::Cloud, cloud_client.clone()) + .await + { + Ok(models) => { + for model in models { + let key = format!("cloud::{}", model.id); + if seen.insert(key) { + combined.push(model); + } + } + } + Err(err) => errors.push(err), + } + } + + if combined.is_empty() { + if let Some(err) = errors.pop() { + return Err(err); + } + } + + self.annotate_scope_status(&mut combined).await; + combined.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(combined) + } + + async fn fetch_models_for_scope( + &self, + scope: OllamaMode, + client: Ollama, + ) -> Result> { + let list_result = if matches!(scope, OllamaMode::Local) { + match timeout( + Duration::from_millis(LOCAL_PROBE_TIMEOUT_MS), + client.list_local_models(), + ) + .await + { + Ok(result) => result.map_err(|err| self.map_ollama_error("list models", err, None)), + Err(_) => Err(Error::Timeout( + "Timed out while contacting the local Ollama daemon".to_string(), + )), + } + } else { + client + .list_local_models() + .await + .map_err(|err| self.map_ollama_error("list models", err, None)) + }; + + let models = match list_result { + Ok(models) => models, + Err(err) => { + let message = err.to_string(); + self.mark_scope_failure(scope, message).await; + if let Some(cached) = self.cached_scope_models(scope).await { + return Ok(cached); + } + return Err(err); + } + }; - let client = self.client.clone(); let cache = self.model_details_cache.clone(); - let mode = self.mode; let fetched = join_all(models.into_iter().map(|local| { let client = client.clone(); let cache = cache.clone(); @@ -329,7 +687,7 @@ impl OllamaProvider { let detail = match client.show_model_info(name.clone()).await { Ok(info) => { let detailed = OllamaProvider::convert_detailed_model_info( - mode, + scope, &name, Some(&local), &info, @@ -347,10 +705,13 @@ impl OllamaProvider { })) .await; - Ok(fetched + let converted: Vec = fetched .into_iter() - .map(|(local, detail)| self.convert_model(local, detail)) - .collect()) + .map(|(local, detail)| self.convert_model(scope, local, detail)) + .collect(); + + self.update_scope_success(scope, &converted).await; + Ok(converted) } fn convert_detailed_model_info( @@ -430,8 +791,13 @@ impl OllamaProvider { info.with_normalised_strings() } - fn convert_model(&self, model: LocalModel, detail: Option) -> ModelInfo { - let scope = match self.mode { + fn convert_model( + &self, + scope: OllamaMode, + model: LocalModel, + detail: Option, + ) -> ModelInfo { + let scope_tag = match scope { OllamaMode::Local => "local", OllamaMode::Cloud => "cloud", }; @@ -453,7 +819,9 @@ impl OllamaProvider { push_capability(&mut capabilities, &heuristic); } - let description = build_model_description(scope, detail.as_ref()); + push_capability(&mut capabilities, &format!("scope:{scope_tag}")); + + let description = build_model_description(scope_tag, detail.as_ref()); ModelInfo { id: name.clone(), @@ -1004,6 +1372,10 @@ fn normalize_base_url( Ok(url.to_string().trim_end_matches('/').to_string()) } +fn normalize_cloud_endpoint(input: &str) -> std::result::Result { + normalize_base_url(Some(input), OllamaMode::Cloud) +} + fn build_api_endpoint(base_url: &str, endpoint: &str) -> String { let trimmed_base = base_url.trim_end_matches('/'); let trimmed_endpoint = endpoint.trim_start_matches('/'); @@ -1015,9 +1387,48 @@ fn build_api_endpoint(base_url: &str, endpoint: &str) -> String { } } +fn build_client_for_base( + base_url: &str, + timeout: Duration, + api_key: Option<&str>, +) -> Result<(Ollama, Client)> { + let url = Url::parse(base_url) + .map_err(|err| Error::Config(format!("Invalid Ollama base URL '{base_url}': {err}")))?; + + let mut headers = HeaderMap::new(); + if let Some(key) = api_key { + let value = HeaderValue::from_str(&format!("Bearer {key}")) + .map_err(|_| Error::Config("OLLAMA API key contains invalid characters".to_string()))?; + headers.insert(AUTHORIZATION, value); + } + + let mut client_builder = Client::builder().timeout(timeout); + if !headers.is_empty() { + client_builder = client_builder.default_headers(headers.clone()); + } + + let http_client = client_builder.build().map_err(|err| { + Error::Config(format!( + "Failed to build HTTP client for '{base_url}': {err}" + )) + })?; + + let port = url.port_or_known_default().ok_or_else(|| { + Error::Config(format!("Unable to determine port for Ollama URL '{}'", url)) + })?; + + let mut ollama_client = Ollama::new_with_client(url.clone(), port, http_client.clone()); + if !headers.is_empty() { + ollama_client.set_headers(Some(headers)); + } + + Ok((ollama_client, http_client)) +} + #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; #[test] fn resolve_api_key_prefers_literal_value() { @@ -1053,6 +1464,60 @@ mod tests { assert!(err.contains("https")); } + #[test] + fn explicit_local_mode_overrides_api_key() { + let mut config = ProviderConfig { + provider_type: "ollama".to_string(), + base_url: Some("http://localhost:11434".to_string()), + api_key: Some("secret-key".to_string()), + extra: HashMap::new(), + }; + config.extra.insert( + OLLAMA_MODE_KEY.to_string(), + Value::String("local".to_string()), + ); + + let provider = OllamaProvider::from_config(&config, None).expect("provider constructed"); + + assert_eq!(provider.mode, OllamaMode::Local); + assert_eq!(provider.base_url, "http://localhost:11434"); + } + + #[test] + fn auto_mode_prefers_explicit_local_base() { + let config = ProviderConfig { + provider_type: "ollama".to_string(), + base_url: Some("http://localhost:11434".to_string()), + api_key: Some("secret-key".to_string()), + extra: HashMap::new(), + }; + // simulate missing explicit mode; defaults to auto + + let provider = OllamaProvider::from_config(&config, None).expect("provider constructed"); + + assert_eq!(provider.mode, OllamaMode::Local); + assert_eq!(provider.base_url, "http://localhost:11434"); + } + + #[test] + fn auto_mode_with_api_key_and_no_local_probe_switches_to_cloud() { + let mut config = ProviderConfig { + provider_type: "ollama".to_string(), + base_url: None, + api_key: Some("secret-key".to_string()), + extra: HashMap::new(), + }; + config.extra.insert( + OLLAMA_MODE_KEY.to_string(), + Value::String("auto".to_string()), + ); + + let provider = OllamaProvider::from_config(&config, None).expect("provider constructed"); + + assert_eq!(provider.mode, OllamaMode::Cloud); + assert_eq!(provider.base_url, CLOUD_BASE_URL); + } + #[test] fn build_model_options_merges_parameters() { let mut parameters = ChatParameters::default(); @@ -1091,3 +1556,110 @@ mod tests { assert!(caps.iter().any(|cap| cap == "vision")); } } + +#[cfg(test)] +struct ProbeOverrideGuard; + +#[cfg(test)] +impl ProbeOverrideGuard { + fn set(value: Option) -> Self { + set_probe_override(value); + ProbeOverrideGuard + } +} + +#[cfg(test)] +impl Drop for ProbeOverrideGuard { + fn drop(&mut self) { + set_probe_override(None); + } +} + +#[test] +fn auto_mode_with_api_key_and_successful_probe_prefers_local() { + let _guard = ProbeOverrideGuard::set(Some(true)); + + let mut config = ProviderConfig { + provider_type: "ollama".to_string(), + base_url: None, + api_key: Some("secret-key".to_string()), + extra: HashMap::new(), + }; + config.extra.insert( + OLLAMA_MODE_KEY.to_string(), + Value::String("auto".to_string()), + ); + + assert!(probe_default_local_daemon(Duration::from_millis(1))); + + let provider = OllamaProvider::from_config(&config, None).expect("provider constructed"); + + assert_eq!(provider.mode, OllamaMode::Local); + assert_eq!(provider.base_url, "http://localhost:11434"); +} + +#[test] +fn auto_mode_with_api_key_and_failed_probe_prefers_cloud() { + let _guard = ProbeOverrideGuard::set(Some(false)); + + let mut config = ProviderConfig { + provider_type: "ollama".to_string(), + base_url: None, + api_key: Some("secret-key".to_string()), + extra: HashMap::new(), + }; + config.extra.insert( + OLLAMA_MODE_KEY.to_string(), + Value::String("auto".to_string()), + ); + + let provider = OllamaProvider::from_config(&config, None).expect("provider constructed"); + + assert_eq!(provider.mode, OllamaMode::Cloud); + assert_eq!(provider.base_url, CLOUD_BASE_URL); +} + +#[test] +fn annotate_scope_status_adds_capabilities_for_unavailable_scopes() { + let config = ProviderConfig { + provider_type: "ollama".to_string(), + base_url: Some("http://localhost:11434".to_string()), + api_key: None, + extra: HashMap::new(), + }; + + let provider = OllamaProvider::from_config(&config, None).expect("provider constructed"); + + let mut models = vec![ModelInfo { + id: "llama3".to_string(), + name: "Llama 3".to_string(), + description: None, + provider: "ollama".to_string(), + context_window: None, + capabilities: vec!["scope:local".to_string()], + supports_tools: false, + }]; + + block_on(async { + { + let mut cache = provider.scope_cache.write().await; + let entry = cache.entry(OllamaMode::Cloud).or_default(); + entry.availability = ScopeAvailability::Unavailable; + entry.last_error = Some("Cloud endpoint unreachable".to_string()); + } + + provider.annotate_scope_status(&mut models).await; + }); + + let capabilities = &models[0].capabilities; + assert!( + capabilities + .iter() + .any(|cap| cap == "scope-status:cloud:unavailable") + ); + assert!( + capabilities + .iter() + .any(|cap| cap.starts_with("scope-status-message:cloud:")) + ); +} diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index c4f5a96..43fd497 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -41,10 +41,12 @@ use crate::state::{ use crate::toast::{Toast, ToastLevel, ToastManager}; use crate::ui::format_tool_output; use crate::{commands, highlight}; +use owlen_core::config::{OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY}; +use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID}; // Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly // imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`. use std::collections::hash_map::DefaultHasher; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::env; use std::fs; use std::fs::OpenOptions; @@ -65,6 +67,8 @@ const TUTORIAL_STATUS: &str = "Tutorial loaded. Review quick tips in the footer. const TUTORIAL_SYSTEM_STATUS: &str = "Normal ▸ h/j/k/l • Insert ▸ i,a • Visual ▸ v • Command ▸ : • Help ▸ F1/? • Send ▸ Enter"; +const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL; + const FOCUS_CHORD_TIMEOUT: Duration = Duration::from_millis(1200); const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450); const RESIZE_STEP: f32 = 0.05; @@ -97,12 +101,18 @@ pub(crate) enum ModelSelectorItemKind { provider: String, expanded: bool, }, + Scope { + provider: String, + label: String, + scope: ModelScope, + }, Model { provider: String, model_index: usize, }, Empty { provider: String, + message: Option, }, } @@ -116,6 +126,16 @@ impl ModelSelectorItem { } } + fn scope(provider: impl Into, label: impl Into, scope: ModelScope) -> Self { + Self { + kind: ModelSelectorItemKind::Scope { + provider: provider.into(), + label: label.into(), + scope, + }, + } + } + fn model(provider: impl Into, model_index: usize) -> Self { Self { kind: ModelSelectorItemKind::Model { @@ -125,10 +145,11 @@ impl ModelSelectorItem { } } - fn empty(provider: impl Into) -> Self { + fn empty(provider: impl Into, message: Option) -> Self { Self { kind: ModelSelectorItemKind::Empty { provider: provider.into(), + message, }, } } @@ -146,7 +167,8 @@ impl ModelSelectorItem { fn provider_if_header(&self) -> Option<&str> { match &self.kind { - ModelSelectorItemKind::Header { provider, .. } => Some(provider), + ModelSelectorItemKind::Header { provider, .. } + | ModelSelectorItemKind::Scope { provider, .. } => Some(provider), _ => None, } } @@ -156,6 +178,34 @@ impl ModelSelectorItem { } } +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(crate) enum ModelScope { + Local, + Cloud, + Other(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum ModelAvailabilityState { + Unknown, + Available, + Unavailable, +} + +impl Default for ModelAvailabilityState { + fn default() -> Self { + Self::Unknown + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ScopeStatusEntry { + pub state: ModelAvailabilityState, + pub message: Option, +} + +pub(crate) type ProviderScopeStatus = BTreeMap; + /// Messages emitted by asynchronous streaming tasks #[derive(Debug)] pub enum SessionEvent { @@ -198,28 +248,29 @@ pub struct ChatApp { mode_flash_until: Option, pub status: String, pub error: Option, - models: Vec, // All models fetched - pub available_providers: Vec, // Unique providers from models - pub selected_provider: String, // The currently selected provider - pub selected_provider_index: usize, // Index into the available_providers list - pub selected_model_item: Option, // Index into the flattened model selector list + models: Vec, // All models fetched + provider_scope_status: HashMap, + pub available_providers: Vec, // Unique providers from models + pub selected_provider: String, // The currently selected provider + pub selected_provider_index: usize, // Index into the available_providers list + pub selected_model_item: Option, // Index into the flattened model selector list model_selector_items: Vec, // Flattened provider/model list for selector - model_info_panel: ModelInfoPanel, // Dedicated model information viewer + model_info_panel: ModelInfoPanel, // Dedicated model information viewer model_details_cache: HashMap, // Cached detailed metadata per model - show_model_info: bool, // Whether the model info panel is visible - model_info_viewport_height: usize, // Cached viewport height for the info panel - expanded_provider: Option, // Which provider group is currently expanded - current_provider: String, // Provider backing the active session + show_model_info: bool, // Whether the model info panel is visible + model_info_viewport_height: usize, // Cached viewport height for the info panel + expanded_provider: Option, // Which provider group is currently expanded + current_provider: String, // Provider backing the active session message_line_cache: HashMap, // Cached rendered lines per message - show_cursor_outside_insert: bool, // Configurable cursor visibility flag - syntax_highlighting: bool, // Whether syntax highlighting is enabled - render_markdown: bool, // Whether markdown rendering is enabled - show_message_timestamps: bool, // Whether to render timestamps in chat headers - auto_scroll: AutoScroll, // Auto-scroll state for message rendering - thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel - viewport_height: usize, // Track the height of the messages viewport - thinking_viewport_height: usize, // Track the height of the thinking viewport - content_width: usize, // Track the content width for line wrapping calculations + show_cursor_outside_insert: bool, // Configurable cursor visibility flag + syntax_highlighting: bool, // Whether syntax highlighting is enabled + render_markdown: bool, // Whether markdown rendering is enabled + show_message_timestamps: bool, // Whether to render timestamps in chat headers + auto_scroll: AutoScroll, // Auto-scroll state for message rendering + thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel + viewport_height: usize, // Track the height of the messages viewport + thinking_viewport_height: usize, // Track the height of the thinking viewport + content_width: usize, // Track the content width for line wrapping calculations session_tx: mpsc::UnboundedSender, streaming: HashSet, stream_tasks: HashMap>, @@ -447,6 +498,7 @@ impl ChatApp { }, error: None, models: Vec::new(), + provider_scope_status: HashMap::new(), available_providers: Vec::new(), selected_provider: "ollama".to_string(), // Default, will be updated in initialize_models selected_provider_index: 0, @@ -4235,8 +4287,9 @@ impl ChatApp { let config_model_name = self.controller.config().general.default_model.clone(); let config_model_provider = self.controller.config().general.default_provider.clone(); - let (all_models, errors) = self.collect_models_from_all_providers().await; + let (all_models, errors, scope_status) = self.collect_models_from_all_providers().await; self.models = all_models; + self.provider_scope_status = scope_status; self.model_details_cache.clear(); self.model_info_panel.clear(); self.show_model_info = false; @@ -6091,66 +6144,109 @@ impl ChatApp { self.error = Some("Usage: :provider ".to_string()); self.status = "Usage: :provider ".to_string(); } else { - let filter = args.join(" "); - if self.available_providers.is_empty() { - if let Err(err) = self.refresh_models().await { - self.error = Some(format!( - "Failed to refresh providers: {}", - err - )); - self.status = - "Unable to refresh providers".to_string(); - } - } + let provider_query = args[0].to_string(); + let mode_arg = args.get(1).map(|value| value.to_string()); - if let Some(provider) = self.best_provider_match(&filter) { - match self.switch_to_provider(&provider).await { - Ok(_) => { - self.selected_provider = provider.clone(); - self.update_selected_provider_index(); - self.controller - .config_mut() - .general - .default_provider = provider.clone(); - match config::save_config( - &self.controller.config(), - ) { - Ok(_) => self.error = None, - Err(err) => { - self.error = Some(format!( - "Provider switched but config save failed: {}", - err - )); - self.status = "Provider switch saved with warnings" - .to_string(); - } + if let Some(mode_value) = mode_arg { + if let Some(provider) = + self.best_provider_match(&provider_query) + { + match self + .apply_provider_mode(&provider, &mode_value) + .await + { + Ok(_) => { + self.selected_provider = provider.clone(); + self.update_selected_provider_index(); } - self.status = - format!("Active provider: {}", provider); - if let Err(err) = self.refresh_models().await { - self.error = Some(format!( - "Provider switched but refreshing models failed: {}", - err - )); - self.status = - "Provider switched; failed to refresh models" - .to_string(); + Err(err) => { + self.error = Some(err.to_string()); + self.status = err.to_string(); } } - Err(err) => { + } else { + self.error = Some(format!( + "No provider matching '{}'", + provider_query + )); + self.status = format!( + "No provider matching '{}'", + provider_query.trim() + ); + } + } else { + if self.available_providers.is_empty() { + if let Err(err) = self.refresh_models().await { self.error = Some(format!( - "Failed to switch provider: {}", + "Failed to refresh providers: {}", err )); self.status = - "Provider switch failed".to_string(); + "Unable to refresh providers".to_string(); } } - } else { - self.error = - Some(format!("No provider matching '{}'", filter)); - self.status = - format!("No provider matching '{}'", filter.trim()); + + let filter = provider_query; + if let Some(provider) = + self.best_provider_match(&filter) + { + match self.switch_to_provider(&provider).await { + Ok(_) => { + self.selected_provider = provider.clone(); + self.update_selected_provider_index(); + self.controller + .config_mut() + .general + .default_provider = provider.clone(); + match config::save_config( + &self.controller.config(), + ) { + Ok(_) => self.error = None, + Err(err) => { + self.error = Some(format!( + "Provider switched but config save failed: {}", + err + )); + self.status = + "Provider switch saved with warnings" + .to_string(); + } + } + self.status = format!( + "Active provider: {}", + provider + ); + if let Err(err) = + self.refresh_models().await + { + self.error = Some(format!( + "Provider switched but refreshing models failed: {}", + err + )); + self.status = + "Provider switched; failed to refresh models" + .to_string(); + } + } + Err(err) => { + self.error = Some(format!( + "Failed to switch provider: {}", + err + )); + self.status = + "Provider switch failed".to_string(); + } + } + } else { + self.error = Some(format!( + "No provider matching '{}'", + filter + )); + self.status = format!( + "No provider matching '{}'", + filter.trim() + ); + } } } self.set_input_mode(InputMode::Normal); @@ -6158,21 +6254,72 @@ impl ChatApp { return Ok(AppState::Running); } "models" => { - let outcome = if let Some(&"info") = args.first() { - let force_refresh = args - .get(1) - .map(|flag| { - matches!(*flag, "refresh" | "-r" | "--refresh") - }) - .unwrap_or(false); - self.prefetch_all_model_details(force_refresh).await - } else { - Err(anyhow!("Usage: :models info [refresh]")) - }; + if args.is_empty() { + if let Err(err) = self.show_model_picker().await { + self.error = Some(err.to_string()); + } + self.command_palette.clear(); + return Ok(AppState::Running); + } - match outcome { - Ok(_) => self.error = None, - Err(err) => self.error = Some(err.to_string()), + match args[0] { + "--local" => { + if let Err(err) = self.show_model_picker().await { + self.error = Some(err.to_string()); + } else if !self + .focus_first_model_in_scope(&ModelScope::Local) + { + self.status = + "No local models available".to_string(); + } else { + self.status = "Showing local models".to_string(); + self.error = None; + } + self.command_palette.clear(); + return Ok(AppState::Running); + } + "--cloud" => { + if let Err(err) = self.show_model_picker().await { + self.error = Some(err.to_string()); + } else if !self + .focus_first_model_in_scope(&ModelScope::Cloud) + { + self.status = + "No cloud models available".to_string(); + } else { + self.status = "Showing cloud models".to_string(); + self.error = None; + } + self.command_palette.clear(); + return Ok(AppState::Running); + } + "info" => { + let force_refresh = args + .get(1) + .map(|flag| { + matches!(*flag, "refresh" | "-r" | "--refresh") + }) + .unwrap_or(false); + let outcome = self + .prefetch_all_model_details(force_refresh) + .await; + + match outcome { + Ok(_) => self.error = None, + Err(err) => self.error = Some(err.to_string()), + } + + self.set_input_mode(InputMode::Normal); + self.command_palette.clear(); + return Ok(AppState::Running); + } + _ => { + self.error = Some( + "Usage: :models [--local|--cloud|info]".to_string(), + ); + self.status = + "Usage: :models [--local|--cloud|info]".to_string(); + } } self.set_input_mode(InputMode::Normal); @@ -6455,6 +6602,27 @@ impl ChatApp { } } } + "cloud" => { + match self.handle_cloud_command(args).await { + Ok(_) => { + if self.error.is_some() { + // leave existing error + } else { + self.error = None; + } + } + Err(err) => { + let message = err.to_string(); + if self.status.trim().is_empty() { + self.status = message.clone(); + } + self.error = Some(message); + } + } + self.command_palette.clear(); + self.set_input_mode(InputMode::Normal); + return Ok(AppState::Running); + } "privacy-enable" => { if let Some(tool) = args.first() { match self.controller.set_tool_enabled(tool, true).await { @@ -6587,6 +6755,13 @@ impl ChatApp { } self.error = None; } + ModelSelectorItemKind::Scope { provider, .. } => { + let provider_name = provider.clone(); + self.expand_provider(&provider_name, false); + self.status = + format!("Expanded provider: {}", provider_name); + self.error = None; + } ModelSelectorItemKind::Model { .. } => { if let Some(model) = self.selected_model_info().cloned() { if self.apply_model_selection(model).await.is_err() { @@ -6599,7 +6774,7 @@ impl ChatApp { ); } } - ModelSelectorItemKind::Empty { provider } => { + ModelSelectorItemKind::Empty { provider, .. } => { let provider_name = provider.clone(); self.collapse_provider(&provider_name); self.status = @@ -6671,12 +6846,19 @@ impl ChatApp { self.error = None; } } + ModelSelectorItemKind::Scope { provider, .. } => { + let provider_name = provider.clone(); + self.collapse_provider(&provider_name); + self.status = + format!("Collapsed provider: {}", provider_name); + self.error = None; + } ModelSelectorItemKind::Model { provider, .. } => { if let Some(idx) = self.index_of_header(provider) { self.set_selected_model_item(idx); } } - ModelSelectorItemKind::Empty { provider } => { + ModelSelectorItemKind::Empty { provider, .. } => { let provider_name = provider.clone(); self.collapse_provider(&provider_name); self.status = @@ -6698,7 +6880,7 @@ impl ChatApp { self.error = None; } } - ModelSelectorItemKind::Empty { provider } => { + ModelSelectorItemKind::Empty { provider, .. } => { let provider_name = provider.clone(); self.expand_provider(&provider_name, false); self.status = @@ -7198,7 +7380,13 @@ impl ChatApp { self.error = None; } - async fn collect_models_from_all_providers(&self) -> (Vec, Vec) { + async fn collect_models_from_all_providers( + &self, + ) -> ( + Vec, + Vec, + HashMap, + ) { let provider_entries = { let config = self.controller.config(); let entries: Vec<(String, ProviderConfig)> = config @@ -7211,6 +7399,7 @@ impl ChatApp { let mut models = Vec::new(); let mut errors = Vec::new(); + let mut scope_status_map: HashMap = HashMap::new(); let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") @@ -7268,17 +7457,187 @@ impl ChatApp { for model in &mut provider_models { model.provider = canonical_name.clone(); } + let statuses = Self::extract_scope_status(&provider_models); + Self::accumulate_scope_errors(&mut errors, &canonical_name, &statuses); + scope_status_map.insert(canonical_name.clone(), statuses); models.extend(provider_models); } - Err(err) => errors.push(format!("{}: {}", name, err)), + Err(err) => { + scope_status_map + .insert(canonical_name.clone(), ProviderScopeStatus::default()); + errors.push(format!("{}: {}", name, err)) + } }, - Err(err) => errors.push(format!("{}: {}", canonical_name, err)), + Err(err) => { + scope_status_map.insert(canonical_name.clone(), ProviderScopeStatus::default()); + errors.push(format!("{}: {}", canonical_name, err)); + } } } // Sort models alphabetically by name for a predictable UI order models.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - (models, errors) + (models, errors, scope_status_map) + } + + fn scope_from_keyword(value: &str) -> ModelScope { + match value { + "local" => ModelScope::Local, + "cloud" => ModelScope::Cloud, + other => ModelScope::Other(other.to_string()), + } + } + + fn extract_scope_status(models: &[ModelInfo]) -> ProviderScopeStatus { + let mut statuses: ProviderScopeStatus = BTreeMap::new(); + + for model in models { + for capability in &model.capabilities { + if let Some(rest) = capability.strip_prefix("scope-status:") { + let mut parts = rest.split(':'); + let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); + let state_key = parts.next().unwrap_or_default().to_ascii_lowercase(); + + let scope = Self::scope_from_keyword(&scope_key); + let state = match state_key.as_str() { + "available" => ModelAvailabilityState::Available, + "unavailable" => ModelAvailabilityState::Unavailable, + _ => ModelAvailabilityState::Unknown, + }; + + let entry = statuses.entry(scope).or_default(); + if state > entry.state || entry.state == ModelAvailabilityState::Unknown { + entry.state = state; + } + } else if let Some(rest) = capability.strip_prefix("scope-status-message:") { + let mut parts = rest.split(':'); + let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); + let message = parts.collect::>().join(":"); + let scope = Self::scope_from_keyword(&scope_key); + let entry = statuses.entry(scope).or_default(); + if entry.message.is_none() && !message.trim().is_empty() { + entry.message = Some(message.trim().to_string()); + } + } + } + } + + statuses + } + + fn accumulate_scope_errors( + errors: &mut Vec, + provider: &str, + statuses: &ProviderScopeStatus, + ) { + for (scope, entry) in statuses { + if entry.state == ModelAvailabilityState::Unavailable { + let scope_name = Self::scope_display_name(scope); + if let Some(reason) = entry.message.as_ref() { + errors.push(format!("{provider}: {scope_name} unavailable ({reason})")); + } else { + errors.push(format!("{provider}: {scope_name} unavailable")); + } + } + } + } + + pub(crate) fn model_scope_from_capabilities(model: &ModelInfo) -> ModelScope { + for capability in &model.capabilities { + if let Some(tag) = capability.strip_prefix("scope:") { + return match tag { + "local" => ModelScope::Local, + "cloud" => ModelScope::Cloud, + other => ModelScope::Other(other.to_string()), + }; + } + } + + ModelScope::Other("unknown".to_string()) + } + + pub(crate) fn scope_icon(scope: &ModelScope) -> &'static str { + match scope { + ModelScope::Local => "🖥️", + ModelScope::Cloud => "☁", + ModelScope::Other(_) => "◇", + } + } + + pub(crate) fn scope_display_name(scope: &ModelScope) -> String { + match scope { + ModelScope::Local => "Local".to_string(), + ModelScope::Cloud => "Cloud".to_string(), + ModelScope::Other(other) => capitalize_first(other), + } + } + + fn scope_header_label( + provider: &str, + scope: &ModelScope, + status: Option, + ) -> String { + let icon = Self::scope_icon(scope); + let scope_name = Self::scope_display_name(scope); + let provider_name = capitalize_first(provider); + let mut label = format!("{icon} {scope_name} · {provider_name}"); + + if let Some(ModelAvailabilityState::Unavailable) = status { + label.push_str(" (Unavailable)"); + } + + label + } + + fn deduplicate_models_for_scope<'a>( + entries: Vec<(usize, &'a ModelInfo)>, + provider_lower: &str, + scope: &ModelScope, + ) -> Vec<(usize, &'a ModelInfo)> { + let mut best_by_canonical: HashMap = HashMap::new(); + + for (idx, model) in entries { + let canonical = model.id.to_string(); + let is_cloud_id = model.id.ends_with("-cloud"); + let priority = if matches!(provider_lower, "ollama" | "ollama-cloud") { + match scope { + ModelScope::Local => { + if is_cloud_id { + 1 + } else { + 2 + } + } + ModelScope::Cloud => { + if is_cloud_id { + 2 + } else { + 1 + } + } + ModelScope::Other(_) => 1, + } + } else { + 1 + }; + + best_by_canonical + .entry(canonical) + .and_modify(|entry| { + if priority > entry.0 || (priority == entry.0 && model.id < entry.1.1.id) { + *entry = (priority, (idx, model)); + } + }) + .or_insert((priority, (idx, model))); + } + + let mut matches: Vec<(usize, &'a ModelInfo)> = best_by_canonical + .into_values() + .map(|entry| entry.1) + .collect(); + + matches.sort_by(|(_, a), (_, b)| a.id.cmp(&b.id)); + matches } fn recompute_available_providers(&mut self) { @@ -7353,52 +7712,71 @@ impl ChatApp { .filter(|(_, model)| &model.provider == provider) .collect(); - let mut best_by_canonical: HashMap = - HashMap::new(); - - let provider_lower = provider.to_ascii_lowercase(); - + let mut scoped: BTreeMap> = BTreeMap::new(); for (idx, model) in relevant { - let canonical = model.id.to_string(); - - let is_cloud_id = model.id.ends_with("-cloud"); - let priority = match provider_lower.as_str() { - "ollama" | "ollama-cloud" => { - if is_cloud_id { - 1 - } else { - 2 - } - } - _ => 1, - }; - - best_by_canonical - .entry(canonical) - .and_modify(|entry| { - if priority > entry.0 - || (priority == entry.0 && model.id < entry.1.1.id) - { - *entry = (priority, (idx, model)); - } - }) - .or_insert((priority, (idx, model))); + let scope = Self::model_scope_from_capabilities(model); + scoped.entry(scope).or_default().push((idx, model)); } - let mut matches: Vec<(usize, &ModelInfo)> = best_by_canonical - .into_values() - .map(|entry| entry.1) - .collect(); + let provider_lower = provider.to_ascii_lowercase(); + let status_map = self.provider_scope_status.get(provider); - matches.sort_by(|(_, a), (_, b)| a.id.cmp(&b.id)); + let mut scopes_to_render: BTreeSet = BTreeSet::new(); + scopes_to_render.extend(scoped.keys().cloned()); + if let Some(statuses) = status_map { + scopes_to_render.extend(statuses.keys().cloned()); + } - if matches.is_empty() { - items.push(ModelSelectorItem::empty(provider.clone())); - } else { - for (idx, _) in matches { + let mut rendered_scope = false; + let mut rendered_body = false; + + for scope in scopes_to_render { + rendered_scope = true; + let entries = scoped.get(&scope).cloned().unwrap_or_default(); + let deduped = + Self::deduplicate_models_for_scope(entries, &provider_lower, &scope); + + let status_entry = status_map + .and_then(|map| map.get(&scope)) + .cloned() + .unwrap_or_default(); + let label = + Self::scope_header_label(provider, &scope, Some(status_entry.state)); + + items.push(ModelSelectorItem::scope( + provider.clone(), + label, + scope.clone(), + )); + + if deduped.is_empty() { + let fallback_message = match status_entry.state { + ModelAvailabilityState::Unavailable => { + Some(format!("{} unavailable", Self::scope_display_name(&scope))) + } + ModelAvailabilityState::Available => Some(format!( + "No {} models found", + Self::scope_display_name(&scope) + )), + ModelAvailabilityState::Unknown => None, + }; + + if let Some(message) = fallback_message { + rendered_body = true; + items.push(ModelSelectorItem::empty(provider.clone(), Some(message))); + } + continue; + } + + rendered_body = true; + for (idx, _) in deduped { items.push(ModelSelectorItem::model(provider.clone(), idx)); } } + + if !rendered_scope || !rendered_body { + items.push(ModelSelectorItem::empty(provider.clone(), None)); + } } } @@ -7472,8 +7850,9 @@ impl ChatApp { if let Some(item) = self.model_selector_items.get(clamped) { match item.kind() { ModelSelectorItemKind::Header { provider, .. } + | ModelSelectorItemKind::Scope { provider, .. } | ModelSelectorItemKind::Model { provider, .. } - | ModelSelectorItemKind::Empty { provider } => { + | ModelSelectorItemKind::Empty { provider, .. } => { self.selected_provider = provider.clone(); self.update_selected_provider_index(); } @@ -7481,6 +7860,41 @@ impl ChatApp { } } + fn focus_first_model_in_scope(&mut self, scope: &ModelScope) -> bool { + if self.model_selector_items.is_empty() { + return false; + } + + let scope_index = self + .model_selector_items + .iter() + .enumerate() + .find(|(_, item)| matches!(item.kind(), ModelSelectorItemKind::Scope { scope: s, .. } if s == scope)) + .map(|(idx, _)| idx); + + let Some(scope_idx) = scope_index else { + return false; + }; + + self.set_selected_model_item(scope_idx); + + let len = self.model_selector_items.len(); + let mut cursor = scope_idx + 1; + while cursor < len { + match self.model_selector_items[cursor].kind() { + ModelSelectorItemKind::Model { .. } => { + self.set_selected_model_item(cursor); + return true; + } + ModelSelectorItemKind::Scope { .. } | ModelSelectorItemKind::Header { .. } => break, + _ => {} + } + cursor += 1; + } + + true + } + fn ensure_valid_model_selection(&mut self) { if self.model_selector_items.is_empty() { self.selected_model_item = None; @@ -7645,7 +8059,7 @@ impl ChatApp { let config_model_name = self.controller.config().general.default_model.clone(); let config_model_provider = self.controller.config().general.default_provider.clone(); - let (all_models, errors) = self.collect_models_from_all_providers().await; + let (all_models, errors, scope_status) = self.collect_models_from_all_providers().await; if all_models.is_empty() { self.error = if errors.is_empty() { @@ -7654,6 +8068,7 @@ impl ChatApp { Some(errors.join("; ")) }; self.models.clear(); + self.provider_scope_status.clear(); self.model_details_cache.clear(); self.model_info_panel.clear(); self.set_model_info_visible(false); @@ -7670,6 +8085,7 @@ impl ChatApp { } self.models = all_models; + self.provider_scope_status = scope_status; self.model_info_panel.clear(); self.set_model_info_visible(false); self.populate_model_details_cache_from_session().await; @@ -7753,6 +8169,237 @@ impl ChatApp { Ok(()) } + async fn apply_provider_mode(&mut self, provider: &str, mode: &str) -> Result<()> { + let normalized = match mode.trim().to_ascii_lowercase().as_str() { + "local" | "cloud" | "auto" => mode.trim().to_ascii_lowercase(), + other => { + return Err(anyhow!( + "Unknown provider mode '{other}'. Expected local, cloud, or auto" + )); + } + }; + + { + let mut config = self.controller.config_mut(); + config::ensure_provider_config(&mut config, provider); + } + + { + let mut config = self.controller.config_mut(); + if let Some(entry) = config.providers.get_mut(provider) { + entry.extra.insert( + OLLAMA_MODE_KEY.to_string(), + serde_json::Value::String(normalized.clone()), + ); + } else { + return Err(anyhow!("Provider '{provider}' is not configured")); + } + } + + if let Err(err) = config::save_config(&self.controller.config()) { + self.error = Some(format!("Failed to save provider mode: {err}")); + return Err(err); + } + + if provider.eq_ignore_ascii_case(&self.selected_provider) { + if let Err(err) = self.refresh_models().await { + self.error = Some(format!( + "Provider mode updated but refreshing models failed: {}", + err + )); + return Err(err); + } + } + + self.error = None; + self.status = format!( + "Provider {} mode set to {}", + provider, + normalized.to_ascii_uppercase() + ); + Ok(()) + } + + async fn handle_cloud_command(&mut self, args: &[&str]) -> Result<()> { + if args.is_empty() { + return Err(anyhow!( + "Usage: :cloud [options]" + )); + } + + match args[0] { + "setup" => self.cloud_setup(&args[1..]).await, + "status" | "models" | "logout" => Err(anyhow!( + ":cloud {} is not implemented in the TUI. Run `owlen cloud {}` from the shell.", + args[0], + args[0] + )), + other => Err(anyhow!("Unknown :cloud subcommand: {other}")), + } + } + + async fn cloud_setup(&mut self, args: &[&str]) -> Result<()> { + let options = CloudSetupOptions::parse(args)?; + let mut stored_securely = false; + + let (existing_plain_api_key, normalized_endpoint, encryption_enabled, base_was_overridden) = { + let mut config = self.controller.config_mut(); + config::ensure_provider_config(&mut config, &options.provider); + let (existing_plain_api_key, normalized_endpoint_local, base_overridden_local) = + if let Some(entry) = config.providers.get_mut(&options.provider) { + let existing = entry.api_key.clone(); + if entry.provider_type != "ollama" { + entry.provider_type = "ollama".to_string(); + } + let requested = options + .endpoint + .clone() + .unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string()); + let normalized_endpoint_local = normalize_cloud_endpoint(&requested); + entry.extra.insert( + OLLAMA_CLOUD_ENDPOINT_KEY.to_string(), + Value::String(normalized_endpoint_local.clone()), + ); + let should_override = options.force_cloud_base_url + || entry + .base_url + .as_ref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true); + let mut base_overridden_local = false; + if should_override { + entry.base_url = Some(normalized_endpoint_local.clone()); + base_overridden_local = true; + } + if options.force_cloud_base_url { + entry.extra.insert( + OLLAMA_MODE_KEY.to_string(), + Value::String("cloud".to_string()), + ); + } + (existing, normalized_endpoint_local, base_overridden_local) + } else { + return Err(anyhow!("Provider '{}' is not configured", options.provider)); + }; + let encryption_enabled = config.privacy.encrypt_local_data; + ( + existing_plain_api_key, + normalized_endpoint_local, + encryption_enabled, + base_overridden_local, + ) + }; + let base_overridden = base_was_overridden; + + let credential_manager = self.controller.credential_manager(); + + let mut resolved_api_key = options + .api_key + .clone() + .filter(|value| !value.trim().is_empty()); + if resolved_api_key.is_none() { + if let Some(existing) = existing_plain_api_key.as_ref() { + if !existing.trim().is_empty() { + resolved_api_key = Some(existing.clone()); + } + } + } + + if resolved_api_key.is_none() && credential_manager.is_some() { + if let Some(manager) = credential_manager.clone() { + if let Some(credentials) = manager + .get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID) + .await + .with_context(|| "Failed to load stored Ollama Cloud credentials")? + { + if !credentials.api_key.trim().is_empty() { + resolved_api_key = Some(credentials.api_key); + } + } + } + } + + if resolved_api_key.is_none() { + if let Ok(env_key) = std::env::var("OLLAMA_API_KEY") { + if !env_key.trim().is_empty() { + resolved_api_key = Some(env_key); + } + } + } + + if resolved_api_key.is_none() { + return Err(anyhow!( + "No API key provided. Pass `--api-key ` or export OLLAMA_API_KEY." + )); + } + + let api_key = resolved_api_key + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("Ollama Cloud API key cannot be blank"))?; + + if encryption_enabled { + if let Some(manager) = credential_manager.clone() { + let credentials = ApiCredentials { + api_key: api_key.clone(), + endpoint: normalized_endpoint.clone(), + }; + manager + .store_credentials(OLLAMA_CLOUD_CREDENTIAL_ID, &credentials) + .await + .with_context(|| "Failed to store Ollama Cloud credentials securely")?; + stored_securely = true; + let mut config = self.controller.config_mut(); + if let Some(entry) = config.providers.get_mut(&options.provider) { + entry.api_key = None; + } + } else { + self.push_toast( + ToastLevel::Warning, + "Secure credential vault unavailable; storing API key in configuration.", + ); + let mut config = self.controller.config_mut(); + if let Some(entry) = config.providers.get_mut(&options.provider) { + entry.api_key = Some(api_key.clone()); + } + } + } else { + let mut config = self.controller.config_mut(); + if let Some(entry) = config.providers.get_mut(&options.provider) { + entry.api_key = Some(api_key.clone()); + } + } + + if let Err(err) = config::save_config(&self.controller.config()) { + return Err(anyhow!("Failed to save configuration: {}", err)); + } + + if let Err(err) = self.refresh_models().await { + self.push_toast( + ToastLevel::Warning, + format!("Cloud setup saved, but refreshing models failed: {err}"), + ); + } + + let mut status_parts = Vec::new(); + status_parts.push(format!( + "Configured {} for Ollama Cloud ({})", + options.provider, normalized_endpoint + )); + if stored_securely { + status_parts.push("API key stored securely".to_string()); + } else { + status_parts.push("API key stored in configuration".to_string()); + } + if !base_overridden && !options.force_cloud_base_url { + status_parts.push("Local base URL preserved".to_string()); + } + + self.status = status_parts.join(" · "); + self.error = None; + Ok(()) + } + async fn show_model_picker(&mut self) -> Result<()> { self.refresh_models().await?; @@ -8677,6 +9324,17 @@ impl ChatApp { } } +fn capitalize_first(input: &str) -> String { + let mut chars = input.chars(); + if let Some(first) = chars.next() { + let mut result = first.to_uppercase().collect::(); + result.push_str(chars.as_str()); + result + } else { + String::new() + } +} + pub(crate) fn role_label_parts(role: &Role) -> (&'static str, &'static str) { match role { Role::User => ("👤", "You"), @@ -9997,6 +10655,90 @@ pub(crate) fn wrap_unicode(text: &str, width: usize) -> Vec { .collect() } +#[derive(Debug, Clone)] +struct CloudSetupOptions { + provider: String, + endpoint: Option, + api_key: Option, + force_cloud_base_url: bool, +} + +impl CloudSetupOptions { + fn parse(args: &[&str]) -> Result { + let mut options = CloudSetupOptions { + provider: "ollama".to_string(), + endpoint: None, + api_key: None, + force_cloud_base_url: false, + }; + + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.trim() { + "--provider" => { + let value = iter.next().ok_or_else(|| { + anyhow!("--provider expects a value (e.g. --provider ollama)") + })?; + options.provider = canonical_provider_name(value); + } + "--endpoint" => { + let value = iter.next().ok_or_else(|| { + anyhow!("--endpoint expects a URL (e.g. --endpoint https://ollama.com)") + })?; + options.endpoint = Some(value.trim().to_string()); + } + "--api-key" => { + let value = iter.next().ok_or_else(|| { + anyhow!("--api-key expects a value (e.g. --api-key sk-...)") + })?; + options.api_key = Some(value.trim().to_string()); + } + "--force-cloud-base-url" => { + options.force_cloud_base_url = true; + } + flag if flag.starts_with("--") => { + return Err(anyhow!("Unknown flag '{flag}' for :cloud setup")); + } + value => { + if options.api_key.is_none() { + options.api_key = Some(value.trim().to_string()); + } else { + return Err(anyhow!( + "Unexpected argument '{value}'. Provide a single API key or use --api-key." + )); + } + } + } + } + + if options.provider.trim().is_empty() { + options.provider = "ollama".to_string(); + } + + options.provider = canonical_provider_name(&options.provider); + + Ok(options) + } +} + +fn canonical_provider_name(provider: &str) -> String { + let normalized = provider.trim().replace('_', "-").to_ascii_lowercase(); + match normalized.as_str() { + "" => "ollama".to_string(), + "ollama-cloud" => "ollama".to_string(), + value => value.to_string(), + } +} + +fn normalize_cloud_endpoint(endpoint: &str) -> String { + let trimmed = endpoint.trim().trim_end_matches('/'); + if trimmed.is_empty() { + DEFAULT_CLOUD_ENDPOINT.to_string() + } else { + trimmed.to_string() + } +} + #[cfg(test)] mod tests { use super::{render_markdown_lines, wrap_unicode}; diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index 064b073..e6fc6cc 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -102,7 +102,23 @@ const COMMANDS: &[CommandSpec] = &[ }, CommandSpec { keyword: "provider", - description: "Switch active provider", + description: "Switch provider or set its mode", + }, + CommandSpec { + keyword: "cloud setup", + description: "Configure Ollama Cloud credentials", + }, + CommandSpec { + keyword: "cloud status", + description: "Check Ollama Cloud connectivity", + }, + CommandSpec { + keyword: "cloud models", + description: "List models available in Ollama Cloud", + }, + CommandSpec { + keyword: "cloud logout", + description: "Remove stored Ollama Cloud credentials", }, CommandSpec { keyword: "model info", @@ -124,6 +140,14 @@ const COMMANDS: &[CommandSpec] = &[ keyword: "models info", description: "Prefetch detailed information for all models", }, + CommandSpec { + keyword: "models --local", + description: "Open model picker focused on local models", + }, + CommandSpec { + keyword: "models --cloud", + description: "Open model picker focused on cloud models", + }, CommandSpec { keyword: "new", description: "Start a new conversation", diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 81fcb4b..7bde330 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -12,7 +12,8 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::chat_app::{ - ChatApp, HELP_TAB_COUNT, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext, ModelSelectorItemKind, + ChatApp, HELP_TAB_COUNT, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext, ModelScope, + ModelSelectorItemKind, }; use crate::highlight; use crate::state::{ @@ -2785,6 +2786,19 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { ); items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); } + ModelSelectorItemKind::Scope { label, scope, .. } => { + let (fg, modifier) = match scope { + ModelScope::Local => (theme.mode_normal, Modifier::BOLD), + ModelScope::Cloud => (theme.mode_help, Modifier::BOLD), + ModelScope::Other(_) => (theme.placeholder, Modifier::ITALIC), + }; + let style = Style::default().fg(fg).add_modifier(modifier); + let line = clip_line_to_width( + Line::from(Span::styled(format!(" {label}"), style)), + max_line_width, + ); + items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); + } ModelSelectorItemKind::Model { model_index, .. } => { let mut lines: Vec> = Vec::new(); if let Some(model) = app.model_info_by_index(*model_index) { @@ -2822,16 +2836,28 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { } items.push(ListItem::new(lines).style(Style::default().bg(theme.background))); } - ModelSelectorItemKind::Empty { provider } => { - let line = clip_line_to_width( - Line::from(Span::styled( - format!(" (no models configured for {provider})"), - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::DIM | Modifier::ITALIC), - )), - max_line_width, - ); + ModelSelectorItemKind::Empty { provider, message } => { + let text = message + .as_ref() + .map(|msg| format!(" {msg}")) + .unwrap_or_else(|| format!(" (no models configured for {provider})")); + let is_unavailable = message + .as_ref() + .map(|msg| msg.to_ascii_lowercase().contains("unavailable")) + .unwrap_or(false); + + let style = if is_unavailable { + Style::default() + .fg(theme.error) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM | Modifier::ITALIC) + }; + + let line = + clip_line_to_width(Line::from(Span::styled(text, style)), max_line_width); items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); } } @@ -2910,6 +2936,9 @@ fn build_model_selector_label( badges: &[&'static str], is_current: bool, ) -> (String, Option) { + let scope = ChatApp::model_scope_from_capabilities(model); + let scope_icon = ChatApp::scope_icon(&scope); + let scope_label = ChatApp::scope_display_name(&scope); let mut display_name = if model.name.trim().is_empty() { model.id.clone() } else { @@ -2920,7 +2949,7 @@ fn build_model_selector_label( display_name.push_str(&format!(" · {}", model.id)); } - let mut title = format!(" {}", display_name); + let mut title = format!(" {} {}", scope_icon, display_name); if !badges.is_empty() { title.push(' '); title.push_str(&badges.join(" ")); @@ -2942,6 +2971,10 @@ fn build_model_selector_label( } }; + if !scope_label.eq_ignore_ascii_case("unknown") { + push_meta(scope_label.clone()); + } + if let Some(detail) = detail { if let Some(ctx) = detail.context_length { push_meta(format!("max tokens {}", ctx)); @@ -3567,6 +3600,9 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" :m, :model → open model selector"), Line::from(" :themes → open theme selector"), Line::from(" :theme → switch to a specific theme"), + Line::from(" :provider [auto|local|cloud] → switch provider or set mode"), + Line::from(" :models --local | --cloud → focus models by scope"), + Line::from(" :cloud setup [--force-cloud-base-url] → configure Ollama Cloud"), Line::from(""), Line::from(vec![Span::styled( "SESSION MANAGEMENT", diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 57a6076..6578c84 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -158,6 +158,16 @@ After updating your config: - Remove the `-cloud` suffix from model names when using cloud provider - Ensure `api_key` is set in `[providers.ollama-cloud]` config +### 0.1.9 – Explicit Ollama Modes & Cloud Endpoint Storage + +Owlen 0.1.9 introduces targeted quality-of-life fixes for users who switch between local Ollama models and Ollama Cloud: + +- `providers..extra.ollama_mode` now accepts `"auto"`, `"local"`, or `"cloud"`. Migrations default existing entries to `auto`, while preserving any explicit local base URLs you set previously. +- `owlen cloud setup` writes the hosted endpoint to `providers..extra.cloud_endpoint` rather than overwriting `base_url`, so local catalogues keep working after you import an API key. Pass `--force-cloud-base-url` if you truly want the provider to point at the hosted service. +- The model picker surfaces `Local unavailable` / `Cloud unavailable` badges when a source probe fails, highlighting what to fix instead of presenting an empty list. + +Run `owlen config doctor` after upgrading to ensure these migration tweaks are applied automatically. + ### Rollback to v0.x If you encounter issues and need to rollback: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5bea3e9..4d4006b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -21,6 +21,17 @@ Owlen surfaces this as `InvalidInput: Model '' was not found`. Fix the name in your configuration file or choose a model from the UI (`:model`). +## Local Models Missing After Cloud Setup + +Owlen now queries both the local daemon and Ollama Cloud and shows them side-by-side in the picker. If you only see the cloud section (or a red `Local unavailable` banner): + +1. **Confirm the daemon is reachable.** Run `ollama list` locally. If the command times out, restart the service (`ollama serve` or your systemd unit). +2. **Refresh the picker.** In the TUI press `:models --local` to focus the local section. The footer will explain if Owlen skipped the source because it was unreachable. +3. **Inspect the status line.** When the quick health probe fails, Owlen adds a `Local unavailable` / `Cloud unavailable` message instead of leaving the list blank. Use that hint to decide whether to restart Ollama or re-run `owlen cloud setup`. +4. **Keep the base URL local.** The cloud setup command no longer overrides `providers.ollama.base_url` unless `--force-cloud-base-url` is passed. If you changed it manually, edit `config.toml` or run `owlen config doctor` to restore the default `http://localhost:11434` value. + +Once the daemon responds again, the picker will automatically merge the updated local list with the cloud catalogue. + ## Terminal Compatibility Issues Owlen is built with `ratatui`, which supports most modern terminals. However, if you are experiencing rendering issues, please check the following: