diff --git a/CHANGELOG.md b/CHANGELOG.md index 32baea0..0c16f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Global F1 keybinding for the in-app help overlay and a clearer status hint on launch. - Automatic fallback to the new `ansi_basic` theme when the active terminal only advertises 16-color support. - Offline provider shim that keeps the TUI usable while primary providers are unreachable and communicates recovery steps inline. +- `owlen cloud` subcommands (`setup`, `status`, `models`, `logout`) for managing Ollama Cloud credentials without hand-editing config files. +- Tabbed model selector that separates local and cloud providers, including cloud indicators in the UI. +- Footer status line includes provider connectivity/credential summaries (e.g., cloud auth failures, missing API keys). +- Secure credential vault integration for Ollama Cloud API keys when `privacy.encrypt_local_data = true`. ### Changed - The main `README.md` has been updated to be more concise and link to the new documentation. @@ -28,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ollama provider error handling now distinguishes timeouts, missing models, and authentication failures. - `owlen` warns when the active terminal likely lacks 256-color support. - `config.toml` now carries a schema version (`1.1.0`) and is migrated automatically; deprecated keys such as `agent.max_tool_calls` trigger warnings instead of hard failures. +- Model selector navigation (Tab/Shift-Tab) now switches between local and cloud tabs while preserving selection state. --- diff --git a/Cargo.toml b/Cargo.toml index 4359747..4f51d25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ members = [ "crates/owlen-core", "crates/owlen-tui", "crates/owlen-cli", - "crates/owlen-ollama", "crates/owlen-mcp-server", "crates/owlen-mcp-llm-server", "crates/owlen-mcp-client", diff --git a/crates/owlen-cli/Cargo.toml b/crates/owlen-cli/Cargo.toml index 128e6cf..ca3f449 100644 --- a/crates/owlen-cli/Cargo.toml +++ b/crates/owlen-cli/Cargo.toml @@ -26,7 +26,6 @@ required-features = ["chat-client"] owlen-core = { path = "../owlen-core" } # Optional TUI dependency, enabled by the "chat-client" feature. owlen-tui = { path = "../owlen-tui", optional = true } -owlen-ollama = { path = "../owlen-ollama" } log = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } diff --git a/crates/owlen-cli/src/cloud.rs b/crates/owlen-cli/src/cloud.rs new file mode 100644 index 0000000..49a7b98 --- /dev/null +++ b/crates/owlen-cli/src/cloud.rs @@ -0,0 +1,401 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::Subcommand; +use owlen_core::config as core_config; +use owlen_core::config::Config; +use owlen_core::credentials::{ApiCredentials, CredentialManager, OLLAMA_CLOUD_CREDENTIAL_ID}; +use owlen_core::encryption; +use owlen_core::provider::{LLMProvider, ProviderConfig}; +use owlen_core::providers::OllamaProvider; +use owlen_core::storage::StorageManager; + +const DEFAULT_CLOUD_ENDPOINT: &str = "https://ollama.com"; + +#[derive(Debug, Subcommand)] +pub enum CloudCommand { + /// Configure Ollama Cloud credentials + Setup { + /// API key passed directly on the command line (prompted when omitted) + #[arg(long)] + api_key: Option, + /// Override the cloud endpoint (default: https://ollama.com) + #[arg(long)] + endpoint: Option, + /// Provider name to configure (default: ollama) + #[arg(long, default_value = "ollama")] + provider: String, + }, + /// Check connectivity to Ollama Cloud + Status { + /// Provider name to check (default: ollama) + #[arg(long, default_value = "ollama")] + provider: String, + }, + /// List available cloud-hosted models + Models { + /// Provider name to query (default: ollama) + #[arg(long, default_value = "ollama")] + provider: String, + }, + /// Remove stored Ollama Cloud credentials + Logout { + /// Provider name to clear (default: ollama) + #[arg(long, default_value = "ollama")] + provider: String, + }, +} + +pub async fn run_cloud_command(command: CloudCommand) -> Result<()> { + match command { + CloudCommand::Setup { + api_key, + endpoint, + provider, + } => setup(provider, api_key, endpoint).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<()> { + 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()); + + ensure_provider_entry(&mut config, &provider, &endpoint); + + let key = match api_key { + Some(value) if !value.trim().is_empty() => value, + _ => { + let prompt = format!("Enter API key for {provider}: "); + encryption::prompt_password(&prompt)? + } + }; + + if config.privacy.encrypt_local_data { + let storage = Arc::new(StorageManager::new().await?); + let manager = unlock_credential_manager(&config, storage.clone())?; + let credentials = ApiCredentials { + api_key: key.clone(), + endpoint: endpoint.clone(), + }; + manager + .store_credentials(OLLAMA_CLOUD_CREDENTIAL_ID, &credentials) + .await?; + // Ensure plaintext key is not persisted to disk. + if let Some(entry) = config.providers.get_mut(&provider) { + entry.api_key = None; + } + } else if let Some(entry) = config.providers.get_mut(&provider) { + entry.api_key = Some(key.clone()); + } + + if let Some(entry) = config.providers.get_mut(&provider) { + entry.base_url = Some(endpoint.clone()); + } + + crate::config::save_config(&config)?; + println!("Saved Ollama configuration for provider '{provider}'."); + if config.privacy.encrypt_local_data { + println!("API key stored securely in the encrypted credential vault."); + } else { + println!("API key stored in plaintext configuration (encryption disabled)."); + } + Ok(()) +} + +async fn status(provider: String) -> Result<()> { + let provider = canonical_provider_name(&provider); + let mut config = crate::config::try_load_config().unwrap_or_default(); + let storage = Arc::new(StorageManager::new().await?); + let manager = if config.privacy.encrypt_local_data { + Some(unlock_credential_manager(&config, storage.clone())?) + } else { + None + }; + + let api_key = hydrate_api_key(&mut config, manager.as_ref()).await?; + ensure_provider_entry(&mut config, &provider, DEFAULT_CLOUD_ENDPOINT); + + 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)) + .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) + ); + if api_key.is_none() && config.privacy.encrypt_local_data { + println!( + "Warning: No API key stored; connection succeeded via environment variables." + ); + } + } + Err(err) => { + println!("✗ Failed to reach {provider}: {err}"); + } + } + + Ok(()) +} + +async fn models(provider: String) -> Result<()> { + let provider = canonical_provider_name(&provider); + let mut config = crate::config::try_load_config().unwrap_or_default(); + let storage = Arc::new(StorageManager::new().await?); + let manager = if config.privacy.encrypt_local_data { + Some(unlock_credential_manager(&config, storage.clone())?) + } else { + None + }; + hydrate_api_key(&mut config, manager.as_ref()).await?; + + ensure_provider_entry(&mut config, &provider, DEFAULT_CLOUD_ENDPOINT); + 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)) + .with_context(|| "Failed to construct Ollama provider. Run `owlen cloud setup` first.")?; + + match ollama.list_models().await { + Ok(models) => { + if models.is_empty() { + println!("No cloud models reported by '{}'.", provider); + } else { + println!("Models available via '{}':", provider); + for model in models { + if let Some(description) = &model.description { + println!(" - {} ({})", model.id, description); + } else { + println!(" - {}", model.id); + } + } + } + } + Err(err) => { + bail!("Failed to list models: {err}"); + } + } + + Ok(()) +} + +async fn logout(provider: String) -> Result<()> { + let provider = canonical_provider_name(&provider); + let mut config = crate::config::try_load_config().unwrap_or_default(); + let storage = Arc::new(StorageManager::new().await?); + + if config.privacy.encrypt_local_data { + let manager = unlock_credential_manager(&config, storage.clone())?; + manager + .delete_credentials(OLLAMA_CLOUD_CREDENTIAL_ID) + .await?; + } + + if let Some(entry) = provider_entry_mut(&mut config) { + entry.api_key = None; + } + + crate::config::save_config(&config)?; + println!("Cleared credentials for provider '{provider}'."); + Ok(()) +} + +fn ensure_provider_entry(config: &mut Config, provider: &str, endpoint: &str) { + if provider == "ollama" + && config.providers.contains_key("ollama-cloud") + && !config.providers.contains_key("ollama") + { + if let Some(mut legacy) = config.providers.remove("ollama-cloud") { + legacy.provider_type = "ollama".to_string(); + config.providers.insert("ollama".to_string(), legacy); + } + } + + 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()); + } + } +} + +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 set_env_if_missing(var: &str, value: &str) { + if std::env::var(var) + .map(|v| v.trim().is_empty()) + .unwrap_or(true) + { + std::env::set_var(var, value); + } +} + +fn provider_entry_mut(config: &mut Config) -> Option<&mut ProviderConfig> { + if config.providers.contains_key("ollama") { + config.providers.get_mut("ollama") + } else { + config.providers.get_mut("ollama-cloud") + } +} + +fn provider_entry(config: &Config) -> Option<&ProviderConfig> { + if let Some(entry) = config.providers.get("ollama") { + return Some(entry); + } + config.providers.get("ollama-cloud") +} + +fn unlock_credential_manager( + config: &Config, + storage: Arc, +) -> Result> { + if !config.privacy.encrypt_local_data { + bail!("Credential manager requested but encryption is disabled"); + } + + let secure_path = vault_path(&storage)?; + let handle = unlock_vault(&secure_path)?; + let master_key = Arc::new(handle.data.master_key.clone()); + Ok(Arc::new(CredentialManager::new( + storage, + master_key.clone(), + ))) +} + +fn vault_path(storage: &StorageManager) -> Result { + let base_dir = storage + .database_path() + .parent() + .map(|p| p.to_path_buf()) + .or_else(dirs::data_local_dir) + .unwrap_or_else(|| PathBuf::from(".")); + Ok(base_dir.join("encrypted_data.json")) +} + +fn unlock_vault(path: &Path) -> Result { + use std::env; + + if path.exists() { + if let Ok(password) = env::var("OWLEN_MASTER_PASSWORD") { + if !password.trim().is_empty() { + return encryption::unlock_with_password(path.to_path_buf(), &password) + .context("Failed to unlock vault with OWLEN_MASTER_PASSWORD"); + } + } + + for attempt in 0..3 { + let password = encryption::prompt_password("Enter master password: ")?; + match encryption::unlock_with_password(path.to_path_buf(), &password) { + Ok(handle) => { + env::set_var("OWLEN_MASTER_PASSWORD", password); + return Ok(handle); + } + Err(err) => { + eprintln!("Failed to unlock vault: {err}"); + if attempt == 2 { + return Err(err); + } + } + } + } + + bail!("Unable to unlock encrypted credential vault"); + } + + let handle = encryption::unlock_interactive(path.to_path_buf())?; + if env::var("OWLEN_MASTER_PASSWORD") + .map(|v| v.trim().is_empty()) + .unwrap_or(true) + { + let password = encryption::prompt_password("Cache master password for this session: ")?; + env::set_var("OWLEN_MASTER_PASSWORD", password); + } + Ok(handle) +} + +async fn hydrate_api_key( + config: &mut Config, + manager: Option<&Arc>, +) -> Result> { + if let Some(manager) = manager { + if let Some(credentials) = manager.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID).await? { + let key = credentials.api_key.trim().to_string(); + if !key.is_empty() { + set_env_if_missing("OLLAMA_API_KEY", &key); + set_env_if_missing("OLLAMA_CLOUD_API_KEY", &key); + } + + if let Some(cfg) = provider_entry_mut(config) { + if cfg.base_url.is_none() && !credentials.endpoint.trim().is_empty() { + cfg.base_url = Some(credentials.endpoint); + } + } + return Ok(Some(key)); + } + } + + if let Some(cfg) = provider_entry(config) { + if let Some(key) = cfg + .api_key + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + set_env_if_missing("OLLAMA_API_KEY", key); + set_env_if_missing("OLLAMA_CLOUD_API_KEY", key); + return Ok(Some(key.to_string())); + } + } + Ok(None) +} + +pub async fn load_runtime_credentials( + config: &mut Config, + storage: Arc, +) -> Result<()> { + if config.privacy.encrypt_local_data { + let manager = unlock_credential_manager(config, storage.clone())?; + hydrate_api_key(config, Some(&manager)).await?; + } else { + hydrate_api_key(config, None).await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonicalises_provider_names() { + assert_eq!(canonical_provider_name("OLLAMA_CLOUD"), "ollama"); + assert_eq!(canonical_provider_name(" ollama-cloud"), "ollama"); + assert_eq!(canonical_provider_name(""), "ollama"); + } +} diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs index 445ff18..ef11cfc 100644 --- a/crates/owlen-cli/src/main.rs +++ b/crates/owlen-cli/src/main.rs @@ -1,20 +1,23 @@ //! OWLEN CLI - Chat TUI client +mod cloud; + use anyhow::{anyhow, Result}; use async_trait::async_trait; use clap::{Parser, Subcommand}; +use cloud::{load_runtime_credentials, CloudCommand}; use owlen_core::config as core_config; use owlen_core::{ config::{Config, McpMode}, mcp::remote_client::RemoteMcpClient, mode::Mode, provider::ChatStream, + providers::OllamaProvider, session::SessionController, storage::StorageManager, types::{ChatRequest, ChatResponse, Message, ModelInfo}, Error, Provider, }; -use owlen_ollama::OllamaProvider; use owlen_tui::tui_controller::{TuiController, TuiRequest}; use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent}; use std::borrow::Cow; @@ -48,6 +51,9 @@ enum OwlenCommand { /// Inspect or upgrade configuration files #[command(subcommand)] Config(ConfigCommand), + /// Manage Ollama Cloud credentials + #[command(subcommand)] + Cloud(CloudCommand), /// Show manual steps for updating Owlen to the latest revision Upgrade, } @@ -112,8 +118,7 @@ fn build_local_provider(cfg: &Config) -> anyhow::Result> { match provider_cfg.provider_type.as_str() { "ollama" | "ollama-cloud" => { let provider = OllamaProvider::from_config(provider_cfg, Some(&cfg.general))?; - let provider: Arc = Arc::new(provider); - Ok(provider) + Ok(Arc::new(provider) as Arc) } other => Err(anyhow::anyhow!(format!( "Provider type '{other}' is not supported in legacy/local MCP mode" @@ -121,9 +126,10 @@ fn build_local_provider(cfg: &Config) -> anyhow::Result> { } } -fn run_command(command: OwlenCommand) -> Result<()> { +async fn run_command(command: OwlenCommand) -> Result<()> { match command { OwlenCommand::Config(config_cmd) => run_config_command(config_cmd), + OwlenCommand::Cloud(cloud_cmd) => cloud::run_cloud_command(cloud_cmd).await, OwlenCommand::Upgrade => { println!("To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force"); println!( @@ -163,16 +169,34 @@ fn run_config_doctor() -> Result<()> { changes.push("default provider missing; reset to 'ollama'".to_string()); } + if let Some(mut legacy) = config.providers.remove("ollama-cloud") { + legacy.provider_type = "ollama".to_string(); + use std::collections::hash_map::Entry; + match config.providers.entry("ollama".to_string()) { + Entry::Occupied(mut existing) => { + let entry = existing.get_mut(); + if entry.api_key.is_none() { + entry.api_key = legacy.api_key.take(); + } + if entry.base_url.is_none() && legacy.base_url.is_some() { + entry.base_url = legacy.base_url.take(); + } + entry.extra.extend(legacy.extra); + } + Entry::Vacant(slot) => { + slot.insert(legacy); + } + } + changes.push( + "migrated legacy 'ollama-cloud' provider into unified 'ollama' entry".to_string(), + ); + } + if !config.providers.contains_key("ollama") { core_config::ensure_provider_config(&mut config, "ollama"); changes.push("added default ollama provider configuration".to_string()); } - if !config.providers.contains_key("ollama-cloud") { - core_config::ensure_provider_config(&mut config, "ollama-cloud"); - changes.push("added default ollama-cloud provider configuration".to_string()); - } - match config.mcp.mode { McpMode::Legacy => { config.mcp.mode = McpMode::LocalOnly; @@ -329,7 +353,7 @@ async fn main() -> Result<()> { // Parse command-line arguments let Args { code, command } = Args::parse(); if let Some(command) = command { - return run_command(command); + return run_command(command).await; } let initial_mode = if code { Mode::Code } else { Mode::Chat }; @@ -339,8 +363,6 @@ async fn main() -> Result<()> { let color_support = detect_terminal_color_support(); // Load configuration (or fall back to defaults) for the session controller. let mut cfg = config::try_load_config().unwrap_or_default(); - // Disable encryption for CLI to avoid password prompts in this environment. - cfg.privacy.encrypt_local_data = false; if let Some(previous_theme) = apply_terminal_theme(&mut cfg, &color_support) { let term_label = match &color_support { TerminalColorSupport::Limited { term } => Cow::from(term.as_str()), @@ -357,6 +379,8 @@ async fn main() -> Result<()> { ); } cfg.validate()?; + let storage = Arc::new(StorageManager::new().await?); + load_runtime_credentials(&mut cfg, storage.clone()).await?; let (tui_tx, _tui_rx) = mpsc::unbounded_channel::(); let tui_controller = Arc::new(TuiController::new(tui_tx)); @@ -387,7 +411,6 @@ async fn main() -> Result<()> { } }; - let storage = Arc::new(StorageManager::new().await?); let controller = SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?; let (mut app, mut session_rx) = ChatApp::new(controller).await?; diff --git a/crates/owlen-core/Cargo.toml b/crates/owlen-core/Cargo.toml index 70a092f..c1ab5d1 100644 --- a/crates/owlen-core/Cargo.toml +++ b/crates/owlen-core/Cargo.toml @@ -21,6 +21,7 @@ unicode-width = "0.1" uuid = { workspace = true } textwrap = { workspace = true } futures = { workspace = true } +futures-util = { workspace = true } async-trait = { workspace = true } toml = { workspace = true } shellexpand = { workspace = true } diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index e1271d0..1af0bb0 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -57,10 +57,6 @@ impl Default for Config { fn default() -> Self { let mut providers = HashMap::new(); providers.insert("ollama".to_string(), default_ollama_provider_config()); - providers.insert( - "ollama-cloud".to_string(), - default_ollama_cloud_provider_config(), - ); Self { schema_version: Self::default_schema_version(), @@ -200,7 +196,6 @@ impl Config { } ensure_provider_config(self, "ollama"); - ensure_provider_config(self, "ollama-cloud"); if self.schema_version.is_empty() { self.schema_version = Self::default_schema_version(); } @@ -222,9 +217,42 @@ impl Config { CONFIG_SCHEMA_VERSION ); } + + if let Some(legacy_cloud) = self.providers.remove("ollama_cloud") { + self.merge_legacy_ollama_provider(legacy_cloud); + } + + if let Some(legacy_cloud) = self.providers.remove("ollama-cloud") { + self.merge_legacy_ollama_provider(legacy_cloud); + } + self.schema_version = CONFIG_SCHEMA_VERSION.to_string(); } + fn merge_legacy_ollama_provider(&mut self, mut legacy_cloud: ProviderConfig) { + use std::collections::hash_map::Entry; + + legacy_cloud.provider_type = "ollama".to_string(); + + match self.providers.entry("ollama".to_string()) { + Entry::Occupied(mut entry) => { + let target = entry.get_mut(); + if target.base_url.is_none() { + target.base_url = legacy_cloud.base_url.take(); + } + if target.api_key.is_none() { + target.api_key = legacy_cloud.api_key.take(); + } + if target.extra.is_empty() && !legacy_cloud.extra.is_empty() { + target.extra = legacy_cloud.extra; + } + } + Entry::Vacant(entry) => { + entry.insert(legacy_cloud); + } + } + } + fn validate_default_provider(&self) -> Result<()> { if self.general.default_provider.trim().is_empty() { return Err(crate::Error::Config( @@ -308,15 +336,6 @@ fn default_ollama_provider_config() -> ProviderConfig { } } -fn default_ollama_cloud_provider_config() -> ProviderConfig { - ProviderConfig { - provider_type: "ollama-cloud".to_string(), - base_url: Some("https://ollama.com".to_string()), - api_key: None, - extra: HashMap::new(), - } -} - /// Default configuration path with user home expansion pub fn default_config_path() -> PathBuf { if let Some(config_dir) = dirs::config_dir() { @@ -787,11 +806,14 @@ pub fn ensure_provider_config<'a>( ) -> &'a ProviderConfig { use std::collections::hash_map::Entry; + if matches!(provider_name, "ollama_cloud" | "ollama-cloud") { + return ensure_provider_config(config, "ollama"); + } + match config.providers.entry(provider_name.to_string()) { Entry::Occupied(entry) => entry.into_mut(), Entry::Vacant(entry) => { let default = match provider_name { - "ollama-cloud" => default_ollama_cloud_provider_config(), "ollama" => default_ollama_provider_config(), other => ProviderConfig { provider_type: other.to_string(), @@ -857,20 +879,44 @@ mod tests { } #[test] - fn default_config_contains_local_and_cloud_providers() { + fn default_config_contains_local_provider() { let config = Config::default(); assert!(config.providers.contains_key("ollama")); - assert!(config.providers.contains_key("ollama-cloud")); } #[test] - fn ensure_provider_config_backfills_cloud_defaults() { + fn ensure_provider_config_aliases_cloud_defaults() { let mut config = Config::default(); - config.providers.remove("ollama-cloud"); - + config.providers.clear(); let cloud = ensure_provider_config(&mut config, "ollama-cloud"); - assert_eq!(cloud.provider_type, "ollama-cloud"); - assert_eq!(cloud.base_url.as_deref(), Some("https://ollama.com")); + assert_eq!(cloud.provider_type, "ollama"); + assert_eq!(cloud.base_url.as_deref(), Some("http://localhost:11434")); + assert!(config.providers.contains_key("ollama")); + assert!(!config.providers.contains_key("ollama-cloud")); + } + + #[test] + fn migrate_ollama_cloud_underscore_key() { + let mut config = Config::default(); + config.providers.clear(); + config.providers.insert( + "ollama_cloud".to_string(), + ProviderConfig { + provider_type: "ollama_cloud".to_string(), + base_url: Some("https://api.ollama.com".to_string()), + api_key: Some("secret".to_string()), + extra: HashMap::new(), + }, + ); + + config.apply_schema_migrations("1.0.0"); + + assert!(config.providers.get("ollama_cloud").is_none()); + assert!(config.providers.get("ollama-cloud").is_none()); + let cloud = config.providers.get("ollama").expect("migrated config"); + 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")); } #[test] diff --git a/crates/owlen-core/src/credentials.rs b/crates/owlen-core/src/credentials.rs index c2f27cd..2ae327e 100644 --- a/crates/owlen-core/src/credentials.rs +++ b/crates/owlen-core/src/credentials.rs @@ -10,6 +10,8 @@ pub struct ApiCredentials { pub endpoint: String, } +pub const OLLAMA_CLOUD_CREDENTIAL_ID: &str = "provider_ollama_cloud"; + pub struct CredentialManager { storage: Arc, master_key: Arc>, diff --git a/crates/owlen-core/src/lib.rs b/crates/owlen-core/src/lib.rs index 31277c1..f49b1ea 100644 --- a/crates/owlen-core/src/lib.rs +++ b/crates/owlen-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod mcp; pub mod mode; pub mod model; pub mod provider; +pub mod providers; pub mod router; pub mod sandbox; pub mod session; @@ -43,6 +44,7 @@ pub use mode::*; pub use model::*; // Export provider types but exclude test_utils to avoid ambiguity pub use provider::{ChatStream, LLMProvider, Provider, ProviderConfig, ProviderRegistry}; +pub use providers::*; pub use router::*; pub use sandbox::*; pub use session::*; diff --git a/crates/owlen-core/src/providers/mod.rs b/crates/owlen-core/src/providers/mod.rs new file mode 100644 index 0000000..b6a49fe --- /dev/null +++ b/crates/owlen-core/src/providers/mod.rs @@ -0,0 +1,8 @@ +//! Built-in LLM provider implementations. +//! +//! Each provider integration lives in its own module so that maintenance +//! stays focused and configuration remains clear. + +pub mod ollama; + +pub use ollama::OllamaProvider; diff --git a/crates/owlen-ollama/src/lib.rs b/crates/owlen-core/src/providers/ollama.rs similarity index 73% rename from crates/owlen-ollama/src/lib.rs rename to crates/owlen-core/src/providers/ollama.rs index c7a4545..f55be33 100644 --- a/crates/owlen-ollama/src/lib.rs +++ b/crates/owlen-core/src/providers/ollama.rs @@ -1,27 +1,43 @@ -//! Ollama provider for OWLEN LLM client +//! Unified Ollama provider that transparently supports local and cloud usage. +//! +//! When an API key is available (via configuration or environment variables), +//! +//! * Requests are sent to `https://ollama.com`. +//! * The API key is attached as a bearer token. +//! * Model listings are pulled from the cloud endpoint. +//! +//! Without an API key the provider talks to the local Ollama daemon +//! (`http://localhost:11434` by default). +use std::{ + collections::HashMap, + env, io, + time::{Duration, SystemTime}, +}; + +use anyhow::anyhow; use futures_util::{future::BoxFuture, StreamExt}; -use owlen_core::{ +use reqwest::{header, Client, StatusCode, Url}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; +use uuid::Uuid; + +use crate::{ config::GeneralSettings, + mcp::McpToolDescriptor, model::ModelManager, provider::{LLMProvider, ProviderConfig}, types::{ ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage, ToolCall, }, - Result, + Error, Result, }; -use reqwest::{header, Client, StatusCode, Url}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::HashMap; -use std::env; -use std::io; -use std::time::Duration; -use tokio::sync::mpsc; -use tokio_stream::wrappers::UnboundedReceiverStream; const DEFAULT_TIMEOUT_SECS: u64 = 120; const DEFAULT_MODEL_CACHE_TTL_SECS: u64 = 60; +const CLOUD_BASE_URL: &str = "https://ollama.com"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OllamaMode { @@ -30,18 +46,10 @@ enum OllamaMode { } impl OllamaMode { - fn from_provider_type(provider_type: &str) -> Self { - if provider_type.eq_ignore_ascii_case("ollama-cloud") { - Self::Cloud - } else { - Self::Local - } - } - fn default_base_url(self) -> &'static str { match self { Self::Local => "http://localhost:11434", - Self::Cloud => "https://ollama.com", + Self::Cloud => CLOUD_BASE_URL, } } @@ -53,168 +61,9 @@ impl OllamaMode { } } -fn is_ollama_host(host: &str) -> bool { - host.eq_ignore_ascii_case("ollama.com") - || host.eq_ignore_ascii_case("www.ollama.com") - || host.eq_ignore_ascii_case("api.ollama.com") - || host.ends_with(".ollama.com") -} - -fn normalize_base_url( - input: Option<&str>, - mode_hint: OllamaMode, -) -> std::result::Result { - let mut candidate = input - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()) - .unwrap_or_else(|| mode_hint.default_base_url().to_string()); - - if !candidate.contains("://") { - candidate = format!("{}://{}", mode_hint.default_scheme(), candidate); - } - - let mut url = - Url::parse(&candidate).map_err(|err| format!("Invalid base_url '{candidate}': {err}"))?; - - let mut is_cloud = matches!(mode_hint, OllamaMode::Cloud); - - if let Some(host) = url.host_str() { - if is_ollama_host(host) { - is_cloud = true; - } - } - - if is_cloud { - if url.scheme() != "https" { - url.set_scheme("https") - .map_err(|_| "Ollama Cloud requires an https URL".to_string())?; - } - - match url.host_str() { - Some(host) => { - if host.eq_ignore_ascii_case("www.ollama.com") { - url.set_host(Some("ollama.com")) - .map_err(|_| "Failed to normalize Ollama Cloud host".to_string())?; - } - } - None => { - return Err("Ollama Cloud base_url must include a hostname".to_string()); - } - } - } - - // Remove trailing slash and discard query/fragment segments - let current_path = url.path().to_string(); - let trimmed_path = current_path.trim_end_matches('/'); - if trimmed_path.is_empty() { - url.set_path(""); - } else { - url.set_path(trimmed_path); - } - - url.set_query(None); - url.set_fragment(None); - - Ok(url.to_string().trim_end_matches('/').to_string()) -} - -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('/'); - - if trimmed_base.ends_with("/api") { - format!("{trimmed_base}/{trimmed_endpoint}") - } else { - format!("{trimmed_base}/api/{trimmed_endpoint}") - } -} - -fn env_var_non_empty(name: &str) -> Option { - env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn resolve_api_key(configured: Option) -> Option { - let raw = configured?.trim().to_string(); - if raw.is_empty() { - return None; - } - - if let Some(variable) = raw - .strip_prefix("${") - .and_then(|value| value.strip_suffix('}')) - .or_else(|| raw.strip_prefix('$')) - { - let var_name = variable.trim(); - if var_name.is_empty() { - return None; - } - return env_var_non_empty(var_name); - } - - Some(raw) -} - -fn debug_requests_enabled() -> bool { - std::env::var("OWLEN_DEBUG_OLLAMA") - .ok() - .map(|value| { - matches!( - value.trim(), - "1" | "true" | "TRUE" | "True" | "yes" | "YES" | "Yes" - ) - }) - .unwrap_or(false) -} - -fn mask_token(token: &str) -> String { - if token.len() <= 8 { - return "***".to_string(); - } - - let head = &token[..4]; - let tail = &token[token.len() - 4..]; - format!("{head}***{tail}") -} - -fn mask_authorization(value: &str) -> String { - if let Some(token) = value.strip_prefix("Bearer ") { - format!("Bearer {}", mask_token(token)) - } else { - "***".to_string() - } -} - -fn map_reqwest_error(action: &str, err: reqwest::Error) -> owlen_core::Error { - if err.is_timeout() { - return owlen_core::Error::Timeout(format!("{action} request timed out")); - } - - if err.is_connect() { - return owlen_core::Error::Network(format!("{action} connection failed: {err}")); - } - - if err.is_request() || err.is_body() { - return owlen_core::Error::Network(format!("{action} request failed: {err}")); - } - - owlen_core::Error::Network(format!("{action} unexpected error: {err}")) -} - -/// Ollama provider implementation with enhanced configuration and caching #[derive(Debug)] -pub struct OllamaProvider { - client: Client, - base_url: String, - api_key: Option, - model_manager: ModelManager, -} - -/// Options for configuring the Ollama provider -pub(crate) struct OllamaOptions { +struct OllamaOptions { + mode: OllamaMode, base_url: String, request_timeout: Duration, model_cache_ttl: Duration, @@ -222,8 +71,9 @@ pub(crate) struct OllamaOptions { } impl OllamaOptions { - pub(crate) fn new(base_url: impl Into) -> Self { + fn new(mode: OllamaMode, base_url: impl Into) -> Self { Self { + mode, base_url: base_url.into(), request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), model_cache_ttl: Duration::from_secs(DEFAULT_MODEL_CACHE_TTL_SECS), @@ -231,107 +81,156 @@ impl OllamaOptions { } } - pub fn with_general(mut self, general: &GeneralSettings) -> Self { + fn with_general(mut self, general: &GeneralSettings) -> Self { self.model_cache_ttl = general.model_cache_ttl(); self } } -/// Ollama-specific message format -#[derive(Debug, Clone, Serialize, Deserialize)] -struct OllamaMessage { - role: String, - content: String, - #[serde(skip_serializing_if = "Option::is_none")] - tool_calls: Option>, -} - -/// Ollama tool call format -#[derive(Debug, Clone, Serialize, Deserialize)] -struct OllamaToolCall { - function: OllamaToolCallFunction, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct OllamaToolCallFunction { - name: String, - arguments: serde_json::Value, -} - -/// Ollama chat request format -#[derive(Debug, Serialize)] -struct OllamaChatRequest { - model: String, - messages: Vec, - stream: bool, - #[serde(skip_serializing_if = "Option::is_none")] - tools: Option>, - #[serde(flatten)] - options: HashMap, -} - -/// Ollama tool definition -#[derive(Debug, Clone, Serialize, Deserialize)] -struct OllamaTool { - #[serde(rename = "type")] - tool_type: String, - function: OllamaToolFunction, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct OllamaToolFunction { - name: String, - description: String, - parameters: serde_json::Value, -} - -/// Ollama chat response format -#[derive(Debug, Deserialize)] -struct OllamaChatResponse { - message: Option, - done: bool, - #[serde(default)] - prompt_eval_count: Option, - #[serde(default)] - eval_count: Option, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Deserialize)] -struct OllamaErrorResponse { - error: Option, -} - -/// Ollama models list response -#[derive(Debug, Deserialize)] -struct OllamaModelsResponse { - models: Vec, -} - -/// Ollama model information -#[derive(Debug, Deserialize)] -struct OllamaModelInfo { - name: String, - #[serde(default)] - details: Option, -} - -#[derive(Debug, Deserialize)] -struct OllamaModelDetails { - #[serde(default)] - family: Option, +/// Ollama provider implementation that supports both local and cloud APIs. +#[derive(Debug)] +pub struct OllamaProvider { + mode: OllamaMode, + client: Client, + base_url: String, + api_key: Option, + model_manager: ModelManager, } impl OllamaProvider { - /// Create a new Ollama provider with sensible defaults + /// Create a new provider targeting a specific base URL (local usage). pub fn new(base_url: impl Into) -> Result { - let mode = OllamaMode::Local; - let supplied = base_url.into(); + let input = base_url.into(); let normalized = - normalize_base_url(Some(&supplied), mode).map_err(owlen_core::Error::Config)?; + normalize_base_url(Some(&input), OllamaMode::Local).map_err(Error::Config)?; + Self::with_options(OllamaOptions::new(OllamaMode::Local, normalized)) + } - Self::with_options(OllamaOptions::new(normalized)) + /// Construct a provider from configuration settings. + pub fn from_config(config: &ProviderConfig, general: Option<&GeneralSettings>) -> Result { + 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 mode = if api_key.is_some() { + OllamaMode::Cloud + } else { + OllamaMode::Local + }; + + // When an API key is present we always talk to the hosted cloud endpoint. + let base_candidate = if mode == OllamaMode::Cloud { + Some(CLOUD_BASE_URL) + } else { + config.base_url.as_deref() + }; + + let normalized_base_url = + normalize_base_url(base_candidate, mode).map_err(Error::Config)?; + + let mut options = OllamaOptions::new(mode, normalized_base_url); + + if let Some(timeout) = config + .extra + .get("timeout_secs") + .and_then(|value| value.as_u64()) + { + options.request_timeout = Duration::from_secs(timeout.max(5)); + } + + if let Some(cache_ttl) = config + .extra + .get("model_cache_ttl_secs") + .and_then(|value| value.as_u64()) + { + options.model_cache_ttl = Duration::from_secs(cache_ttl.max(5)); + } + + options.api_key = api_key.take(); + + if let Some(general) = general { + options = options.with_general(general); + } + + Self::with_options(options) + } + + fn with_options(options: OllamaOptions) -> Result { + let OllamaOptions { + mode, + base_url, + request_timeout, + model_cache_ttl, + api_key, + } = options; + + let client = Client::builder() + .timeout(request_timeout) + .build() + .map_err(|e| Error::Config(format!("Failed to build HTTP client: {e}")))?; + + Ok(Self { + mode, + client, + base_url: base_url.trim_end_matches('/').to_string(), + api_key, + model_manager: ModelManager::new(model_cache_ttl), + }) + } + + /// Access the underlying model manager cache (mainly used by tests). + pub fn model_manager(&self) -> &ModelManager { + &self.model_manager + } + + fn api_url(&self, endpoint: &str) -> String { + build_api_endpoint(&self.base_url, endpoint) + } + + fn apply_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if let Some(api_key) = &self.api_key { + request.bearer_auth(api_key) + } else { + request + } + } + + fn map_http_failure( + &self, + action: &str, + status: StatusCode, + detail: String, + model: Option<&str>, + ) -> Error { + match status { + StatusCode::NOT_FOUND => { + if let Some(model) = model { + Error::InvalidInput(format!( + "Model '{model}' was not found at {}. Verify the model name or load it with `ollama pull`.", + self.base_url + )) + } else { + Error::InvalidInput(format!( + "{action} returned 404 from {}: {detail}", + self.base_url + )) + } + } + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Error::Auth(format!( + "Ollama rejected the request ({status}): {detail}. Check your API key and account permissions." + )), + StatusCode::BAD_REQUEST => Error::InvalidInput(format!( + "{action} rejected by Ollama ({status}): {detail}" + )), + StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT => Error::Timeout( + format!( + "Ollama {action} timed out ({status}). The model may still be loading." + ), + ), + _ => Error::Network(format!( + "Ollama {action} failed ({status}): {detail}" + )), + } } fn debug_log_request(&self, label: &str, request: &reqwest::Request, body_json: Option<&str>) { @@ -358,8 +257,7 @@ impl OllamaProvider { eprintln!("---------------------------------------"); } - /// Convert MCP tool descriptors to Ollama tool format - fn convert_tools_to_ollama(tools: &[owlen_core::mcp::McpToolDescriptor]) -> Vec { + fn convert_tools_to_ollama(tools: &[McpToolDescriptor]) -> Vec { tools .iter() .map(|tool| OllamaTool { @@ -373,126 +271,6 @@ impl OllamaProvider { .collect() } - /// Create a provider from configuration settings - pub fn from_config(config: &ProviderConfig, general: Option<&GeneralSettings>) -> Result { - let mode = OllamaMode::from_provider_type(&config.provider_type); - let normalized_base_url = normalize_base_url(config.base_url.as_deref(), mode) - .map_err(owlen_core::Error::Config)?; - - let mut options = OllamaOptions::new(normalized_base_url); - - if let Some(timeout) = config - .extra - .get("timeout_secs") - .and_then(|value| value.as_u64()) - { - options.request_timeout = Duration::from_secs(timeout.max(5)); - } - - if let Some(cache_ttl) = config - .extra - .get("model_cache_ttl_secs") - .and_then(|value| value.as_u64()) - { - options.model_cache_ttl = Duration::from_secs(cache_ttl.max(5)); - } - - options.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")); - - if matches!(mode, OllamaMode::Cloud) && options.api_key.is_none() { - return Err(owlen_core::Error::Auth( - "Ollama Cloud requires an API key. Set providers.ollama-cloud.api_key or the OLLAMA_API_KEY environment variable.".to_string(), - )); - } - - if let Some(general) = general { - options = options.with_general(general); - } - - Self::with_options(options) - } - - /// Create a provider from explicit options - pub(crate) fn with_options(options: OllamaOptions) -> Result { - let OllamaOptions { - base_url, - request_timeout, - model_cache_ttl, - api_key, - } = options; - - let client = Client::builder() - .timeout(request_timeout) - .build() - .map_err(|e| owlen_core::Error::Config(format!("Failed to build HTTP client: {e}")))?; - - Ok(Self { - client, - base_url: base_url.trim_end_matches('/').to_string(), - api_key, - model_manager: ModelManager::new(model_cache_ttl), - }) - } - - /// Accessor for the underlying model manager - pub fn model_manager(&self) -> &ModelManager { - &self.model_manager - } - - fn api_url(&self, endpoint: &str) -> String { - build_api_endpoint(&self.base_url, endpoint) - } - - fn apply_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - if let Some(api_key) = &self.api_key { - request.bearer_auth(api_key) - } else { - request - } - } - - fn map_http_failure( - &self, - action: &str, - status: StatusCode, - detail: String, - model: Option<&str>, - ) -> owlen_core::Error { - match status { - StatusCode::NOT_FOUND => { - if let Some(model) = model { - owlen_core::Error::InvalidInput(format!( - "Model '{model}' was not found at {}. Verify the model name or load it with `ollama pull`.", - self.base_url - )) - } else { - owlen_core::Error::InvalidInput(format!( - "{action} returned 404 from {}: {detail}", - self.base_url - )) - } - } - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => owlen_core::Error::Auth( - format!( - "Ollama rejected the request ({status}): {detail}. Check your API key and account permissions." - ), - ), - StatusCode::BAD_REQUEST => owlen_core::Error::InvalidInput(format!( - "{action} rejected by Ollama ({status}): {detail}" - )), - StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT => { - owlen_core::Error::Timeout(format!( - "Ollama {action} timed out ({status}). The model may still be loading." - )) - } - _ => owlen_core::Error::Network(format!( - "Ollama {action} failed ({status}): {detail}" - )), - } - } - fn convert_message(message: &Message) -> OllamaMessage { let role = match message.role { Role::User => "user".to_string(), @@ -522,45 +300,43 @@ impl OllamaProvider { fn convert_ollama_message(message: &OllamaMessage) -> Message { let role = match message.role.as_str() { - "user" => Role::User, "assistant" => Role::Assistant, "system" => Role::System, "tool" => Role::Tool, - _ => Role::Assistant, + _ => Role::User, }; - let mut msg = Message::new(role, message.content.clone()); - - // Convert tool calls if present - if let Some(ollama_tool_calls) = &message.tool_calls { - let tool_calls: Vec = ollama_tool_calls + let tool_calls = message.tool_calls.as_ref().map(|calls| { + calls .iter() .enumerate() .map(|(idx, tc)| ToolCall { - id: format!("call_{}", idx), + id: format!("tool-call-{idx}"), name: tc.function.name.clone(), arguments: tc.function.arguments.clone(), }) - .collect(); - msg.tool_calls = Some(tool_calls); - } + .collect::>() + }); - msg + Message { + id: Uuid::new_v4(), + role, + content: message.content.clone(), + metadata: HashMap::new(), + timestamp: SystemTime::now(), + tool_calls, + } } fn build_options(parameters: ChatParameters) -> HashMap { let mut options = parameters.extra; if let Some(temperature) = parameters.temperature { - options - .entry("temperature".to_string()) - .or_insert(json!(temperature as f64)); + options.insert("temperature".to_string(), json!(temperature)); } if let Some(max_tokens) = parameters.max_tokens { - options - .entry("num_predict".to_string()) - .or_insert(json!(max_tokens)); + options.insert("num_predict".to_string(), json!(max_tokens)); } options @@ -568,83 +344,67 @@ impl OllamaProvider { async fn fetch_models(&self) -> Result> { let url = self.api_url("tags"); - let response = self .apply_auth(self.client.get(&url)) .send() .await - .map_err(|e| map_reqwest_error("model listing", e))?; + .map_err(|e| map_reqwest_error("list models", e))?; if !response.status().is_success() { let status = response.status(); - let error = parse_error_body(response).await; - return Err(self.map_http_failure("model listing", status, error, None)); + let detail = parse_error_body(response).await; + return Err(self.map_http_failure("list models", status, detail, None)); } let body = response .text() .await - .map_err(|e| map_reqwest_error("model listing", e))?; + .map_err(|e| map_reqwest_error("list models", e))?; - let ollama_response: OllamaModelsResponse = - serde_json::from_str(&body).map_err(owlen_core::Error::Serialization)?; + let models: OllamaModelsResponse = + serde_json::from_str(&body).map_err(Error::Serialization)?; - let models = ollama_response + Ok(models .models .into_iter() .map(|model| { - // Check if model supports tool calling based on known models - let supports_tools = Self::check_tool_support(&model.name); + let family = model + .details + .and_then(|details| details.family) + .unwrap_or_else(|| "unknown".to_string()); ModelInfo { id: model.name.clone(), - name: model.name.clone(), - description: model - .details - .as_ref() - .and_then(|d| d.family.as_ref().map(|f| format!("Ollama {f} model"))), + name: model.name, + description: Some(format!("Ollama model ({family})")), provider: "ollama".to_string(), context_window: None, capabilities: vec!["chat".to_string()], - supports_tools, + supports_tools: false, } }) - .collect(); - - Ok(models) - } - - /// Check if a model supports tool calling based on its name - fn check_tool_support(model_name: &str) -> bool { - let name_lower = model_name.to_lowercase(); - - // Known models with tool calling support - let tool_supporting_models = [ - "qwen", - "llama3.1", - "llama3.2", - "llama3.3", - "mistral-nemo", - "mistral:7b-instruct", - "command-r", - "firefunction", - "hermes", - "nexusraven", - "granite-code", - ]; - - tool_supporting_models - .iter() - .any(|&supported| name_lower.contains(supported)) + .collect()) } } impl LLMProvider for OllamaProvider { type Stream = UnboundedReceiverStream>; - type ListModelsFuture<'a> = BoxFuture<'a, Result>>; - type ChatFuture<'a> = BoxFuture<'a, Result>; - type ChatStreamFuture<'a> = BoxFuture<'a, Result>; - type HealthCheckFuture<'a> = BoxFuture<'a, Result<()>>; + type ListModelsFuture<'a> + = BoxFuture<'a, Result>> + where + Self: 'a; + type ChatFuture<'a> + = BoxFuture<'a, Result> + where + Self: 'a; + type ChatStreamFuture<'a> + = BoxFuture<'a, Result> + where + Self: 'a; + type HealthCheckFuture<'a> + = BoxFuture<'a, Result<()>> + where + Self: 'a; fn name(&self) -> &str { "ollama" @@ -694,9 +454,9 @@ impl LLMProvider for OllamaProvider { let mut request_builder = self.client.post(&url).json(&ollama_request); request_builder = self.apply_auth(request_builder); - let request = request_builder.build().map_err(|e| { - owlen_core::Error::Network(format!("Failed to build chat request: {e}")) - })?; + let request = request_builder + .build() + .map_err(|e| Error::Network(format!("Failed to build chat request: {e}")))?; self.debug_log_request("chat", &request, debug_body.as_deref()); @@ -718,19 +478,15 @@ impl LLMProvider for OllamaProvider { .map_err(|e| map_reqwest_error("chat", e))?; let mut ollama_response: OllamaChatResponse = - serde_json::from_str(&body).map_err(owlen_core::Error::Serialization)?; + serde_json::from_str(&body).map_err(Error::Serialization)?; if let Some(error) = ollama_response.error.take() { - return Err(owlen_core::Error::Provider(anyhow::anyhow!(error))); + return Err(Error::Provider(anyhow!(error))); } let message = match ollama_response.message { Some(ref msg) => Self::convert_ollama_message(msg), - None => { - return Err(owlen_core::Error::Provider(anyhow::anyhow!( - "Ollama response missing message" - ))) - } + None => return Err(Error::Provider(anyhow!("Ollama response missing message"))), }; let usage = if let (Some(prompt_tokens), Some(completion_tokens)) = ( @@ -791,9 +547,9 @@ impl LLMProvider for OllamaProvider { let mut request_builder = self.client.post(&url).json(&ollama_request); request_builder = self.apply_auth(request_builder); - let request = request_builder.build().map_err(|e| { - owlen_core::Error::Network(format!("Failed to build streaming request: {e}")) - })?; + let request = request_builder + .build() + .map_err(|e| Error::Network(format!("Failed to build streaming request: {e}")))?; self.debug_log_request("chat_stream", &request, debug_body.as_deref()); @@ -836,9 +592,8 @@ impl LLMProvider for OllamaProvider { match serde_json::from_str::(&line) { Ok(mut ollama_response) => { if let Some(error) = ollama_response.error.take() { - let _ = tx.send(Err(owlen_core::Error::Provider( - anyhow::anyhow!(error), - ))); + let _ = + tx.send(Err(Error::Provider(anyhow!(error)))); break; } @@ -875,26 +630,23 @@ impl LLMProvider for OllamaProvider { } } Err(e) => { - let _ = - tx.send(Err(owlen_core::Error::Serialization(e))); + let _ = tx.send(Err(Error::Serialization(e))); break; } } } } else { - let _ = tx.send(Err(owlen_core::Error::Serialization( - serde_json::Error::io(io::Error::new( + let _ = tx.send(Err(Error::Serialization(serde_json::Error::io( + io::Error::new( io::ErrorKind::InvalidData, "Non UTF-8 chunk from Ollama", - )), - ))); + ), + )))); break; } } Err(e) => { - let _ = tx.send(Err(owlen_core::Error::Network(format!( - "Stream error: {e}" - )))); + let _ = tx.send(Err(Error::Network(format!("Stream error: {e}")))); break; } } @@ -932,8 +684,8 @@ impl LLMProvider for OllamaProvider { "properties": { "base_url": { "type": "string", - "description": "Base URL for Ollama API", - "default": "http://localhost:11434" + "description": "Base URL for the Ollama API (ignored when api_key is provided)", + "default": self.mode.default_base_url() }, "timeout_secs": { "type": "integer", @@ -952,6 +704,235 @@ impl LLMProvider for OllamaProvider { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OllamaMessage { + role: String, + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OllamaToolCall { + function: OllamaToolCallFunction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OllamaToolCallFunction { + name: String, + arguments: serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct OllamaChatRequest { + model: String, + messages: Vec, + stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(flatten)] + options: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OllamaTool { + #[serde(rename = "type")] + tool_type: String, + function: OllamaToolFunction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OllamaToolFunction { + name: String, + description: String, + parameters: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct OllamaChatResponse { + message: Option, + done: bool, + #[serde(default)] + prompt_eval_count: Option, + #[serde(default)] + eval_count: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Deserialize)] +struct OllamaErrorResponse { + error: Option, +} + +#[derive(Debug, Deserialize)] +struct OllamaModelsResponse { + models: Vec, +} + +#[derive(Debug, Deserialize)] +struct OllamaModelInfo { + name: String, + #[serde(default)] + details: Option, +} + +#[derive(Debug, Deserialize)] +struct OllamaModelDetails { + #[serde(default)] + family: Option, +} + +fn is_ollama_host(host: &str) -> bool { + host.eq_ignore_ascii_case("ollama.com") + || host.eq_ignore_ascii_case("www.ollama.com") + || host.eq_ignore_ascii_case("api.ollama.com") + || host.ends_with(".ollama.com") +} + +fn normalize_base_url( + input: Option<&str>, + mode_hint: OllamaMode, +) -> std::result::Result { + let mut candidate = input + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .unwrap_or_else(|| mode_hint.default_base_url().to_string()); + + if !candidate.contains("://") { + candidate = format!("{}://{}", mode_hint.default_scheme(), candidate); + } + + let mut url = + Url::parse(&candidate).map_err(|err| format!("Invalid base_url '{candidate}': {err}"))?; + + let mut is_cloud = matches!(mode_hint, OllamaMode::Cloud); + + if let Some(host) = url.host_str() { + if is_ollama_host(host) { + is_cloud = true; + } + } + + if is_cloud { + if url.scheme() != "https" { + url.set_scheme("https") + .map_err(|_| "Ollama Cloud requires an https URL".to_string())?; + } + + match url.host_str() { + Some(host) => { + if host.eq_ignore_ascii_case("www.ollama.com") { + url.set_host(Some("ollama.com")) + .map_err(|_| "Failed to normalize Ollama Cloud host".to_string())?; + } + } + None => { + return Err("Ollama Cloud base_url must include a hostname".to_string()); + } + } + } + + let current_path = url.path().to_string(); + let trimmed_path = current_path.trim_end_matches('/'); + if trimmed_path.is_empty() { + url.set_path(""); + } else { + url.set_path(trimmed_path); + } + + url.set_query(None); + url.set_fragment(None); + + Ok(url.to_string().trim_end_matches('/').to_string()) +} + +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('/'); + + if trimmed_base.ends_with("/api") { + format!("{trimmed_base}/{trimmed_endpoint}") + } else { + format!("{trimmed_base}/api/{trimmed_endpoint}") + } +} + +fn env_var_non_empty(name: &str) -> Option { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn resolve_api_key(configured: Option) -> Option { + let raw = configured?.trim().to_string(); + if raw.is_empty() { + return None; + } + + if let Some(variable) = raw + .strip_prefix("${") + .and_then(|value| value.strip_suffix('}')) + .or_else(|| raw.strip_prefix('$')) + { + let var_name = variable.trim(); + if var_name.is_empty() { + return None; + } + return env_var_non_empty(var_name); + } + + Some(raw) +} + +fn debug_requests_enabled() -> bool { + env::var("OWLEN_DEBUG_OLLAMA") + .ok() + .map(|value| { + matches!( + value.trim(), + "1" | "true" | "TRUE" | "True" | "yes" | "YES" | "Yes" + ) + }) + .unwrap_or(false) +} + +fn mask_token(token: &str) -> String { + if token.len() <= 8 { + return "***".to_string(); + } + + let head = &token[..4]; + let tail = &token[token.len() - 4..]; + format!("{head}***{tail}") +} + +fn mask_authorization(value: &str) -> String { + if let Some(token) = value.strip_prefix("Bearer ") { + format!("Bearer {}", mask_token(token)) + } else { + "***".to_string() + } +} + +fn map_reqwest_error(action: &str, err: reqwest::Error) -> Error { + if err.is_timeout() { + return Error::Timeout(format!("{action} request timed out")); + } + + if err.is_connect() { + return Error::Network(format!("{action} connection failed: {err}")); + } + + if err.is_request() || err.is_body() { + return Error::Network(format!("{action} request failed: {err}")); + } + + Error::Network(format!("{action} unexpected error: {err}")) +} + async fn parse_error_body(response: reqwest::Response) -> String { match response.bytes().await { Ok(bytes) => { @@ -977,7 +958,8 @@ async fn parse_error_body(response: reqwest::Response) -> String { #[cfg(test)] mod tests { use super::*; - use owlen_core::provider::ProviderConfig; + use crate::provider::ProviderConfig; + use std::collections::HashMap; #[test] fn normalizes_local_base_url_and_infers_scheme() { @@ -1038,65 +1020,29 @@ mod tests { } #[test] - fn resolve_api_key_expands_braced_env_reference() { - std::env::set_var("OWLEN_TEST_KEY_BRACED", "super-secret"); + fn resolve_api_key_expands_env_var() { + env::set_var("OLLAMA_TEST_KEY", "env-key"); assert_eq!( - resolve_api_key(Some("${OWLEN_TEST_KEY_BRACED}".into())), - Some("super-secret".into()) + resolve_api_key(Some("${OLLAMA_TEST_KEY}".into())), + Some("env-key".into()) ); - std::env::remove_var("OWLEN_TEST_KEY_BRACED"); + env::remove_var("OLLAMA_TEST_KEY"); } #[test] - fn resolve_api_key_expands_unbraced_env_reference() { - std::env::set_var("OWLEN_TEST_KEY_UNBRACED", "another-secret"); - assert_eq!( - resolve_api_key(Some("$OWLEN_TEST_KEY_UNBRACED".into())), - Some("another-secret".into()) - ); - std::env::remove_var("OWLEN_TEST_KEY_UNBRACED"); - } - - #[test] - fn map_http_failure_returns_invalid_input_for_missing_model() { - let provider = - OllamaProvider::with_options(OllamaOptions::new("http://localhost:11434")).unwrap(); - let error = provider.map_http_failure( - "chat", - StatusCode::NOT_FOUND, - "missing".into(), - Some("phantom-model"), - ); - match error { - owlen_core::Error::InvalidInput(message) => { - assert!(message.contains("phantom-model")); - } - other => panic!("expected InvalidInput, got {other:?}"), - } - } - - #[test] - fn cloud_provider_without_api_key_is_rejected() { - let previous_api_key = std::env::var("OLLAMA_API_KEY").ok(); - let previous_cloud_key = std::env::var("OLLAMA_CLOUD_API_KEY").ok(); - std::env::remove_var("OLLAMA_API_KEY"); - std::env::remove_var("OLLAMA_CLOUD_API_KEY"); - - let config = ProviderConfig { - provider_type: "ollama-cloud".to_string(), - base_url: Some("https://ollama.com".to_string()), - api_key: None, - extra: std::collections::HashMap::new(), + fn cloud_mode_forces_cloud_base_url() { + let mut config = ProviderConfig { + provider_type: "ollama".into(), + base_url: Some("http://localhost:11434".into()), + api_key: Some("dummy".into()), + extra: HashMap::new(), }; + let provider = OllamaProvider::from_config(&config, None).expect("provider"); + assert!(provider.base_url.starts_with("https://ollama.com")); - let result = OllamaProvider::from_config(&config, None); - assert!(matches!(result, Err(owlen_core::Error::Auth(_)))); - - if let Some(value) = previous_api_key { - std::env::set_var("OLLAMA_API_KEY", value); - } - if let Some(value) = previous_cloud_key { - std::env::set_var("OLLAMA_CLOUD_API_KEY", value); - } + config.api_key = None; + config.base_url = Some("http://localhost:11434".into()); + let provider = OllamaProvider::from_config(&config, None).expect("provider"); + assert!(provider.base_url.starts_with("http://localhost:11434")); } } diff --git a/crates/owlen-mcp-llm-server/Cargo.toml b/crates/owlen-mcp-llm-server/Cargo.toml index bd02f07..af3a68a 100644 --- a/crates/owlen-mcp-llm-server/Cargo.toml +++ b/crates/owlen-mcp-llm-server/Cargo.toml @@ -5,7 +5,6 @@ edition = "2021" [dependencies] owlen-core = { path = "../owlen-core" } -owlen-ollama = { path = "../owlen-ollama" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/owlen-mcp-llm-server/src/main.rs b/crates/owlen-mcp-llm-server/src/main.rs index c3321b5..3e0d02e 100644 --- a/crates/owlen-mcp-llm-server/src/main.rs +++ b/crates/owlen-mcp-llm-server/src/main.rs @@ -14,13 +14,14 @@ use owlen_core::mcp::protocol::{ }; use owlen_core::mcp::{McpToolCall, McpToolDescriptor, McpToolResponse}; use owlen_core::provider::ProviderConfig; +use owlen_core::providers::OllamaProvider; use owlen_core::types::{ChatParameters, ChatRequest, Message}; use owlen_core::Provider; -use owlen_ollama::OllamaProvider; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashMap; use std::env; +use std::sync::Arc; use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt}; use tokio_stream::StreamExt; @@ -108,42 +109,56 @@ fn resources_list_descriptor() -> McpToolDescriptor { } } -fn provider_from_config() -> Result { +fn provider_from_config() -> Result, RpcError> { let mut config = OwlenConfig::load(None).unwrap_or_default(); - let provider_name = + let requested_name = env::var("OWLEN_PROVIDER").unwrap_or_else(|_| config.general.default_provider.clone()); - if config.provider(&provider_name).is_none() { - ensure_provider_config(&mut config, &provider_name); + let provider_key = canonical_provider_name(&requested_name); + if config.provider(&provider_key).is_none() { + ensure_provider_config(&mut config, &provider_key); } let provider_cfg: ProviderConfig = - config.provider(&provider_name).cloned().ok_or_else(|| { + config.provider(&provider_key).cloned().ok_or_else(|| { RpcError::internal_error(format!( - "Provider '{provider_name}' not found in configuration" + "Provider '{provider_key}' not found in configuration" )) })?; - if provider_cfg.provider_type != "ollama" && provider_cfg.provider_type != "ollama-cloud" { - return Err(RpcError::internal_error(format!( - "Unsupported provider type '{}' for MCP LLM server", - provider_cfg.provider_type - ))); + match provider_cfg.provider_type.as_str() { + "ollama" | "ollama-cloud" => { + let provider = OllamaProvider::from_config(&provider_cfg, Some(&config.general)) + .map_err(|e| { + RpcError::internal_error(format!( + "Failed to init Ollama provider from config: {e}" + )) + })?; + Ok(Arc::new(provider) as Arc) + } + other => Err(RpcError::internal_error(format!( + "Unsupported provider type '{other}' for MCP LLM server" + ))), } - - OllamaProvider::from_config(&provider_cfg, Some(&config.general)).map_err(|e| { - RpcError::internal_error(format!("Failed to init OllamaProvider from config: {}", e)) - }) } -fn create_provider() -> Result { +fn create_provider() -> Result, RpcError> { if let Ok(url) = env::var("OLLAMA_URL") { - return OllamaProvider::new(&url).map_err(|e| { - RpcError::internal_error(format!("Failed to init OllamaProvider: {}", e)) - }); + let provider = OllamaProvider::new(&url).map_err(|e| { + RpcError::internal_error(format!("Failed to init Ollama provider: {e}")) + })?; + return Ok(Arc::new(provider) as Arc); } provider_from_config() } +fn canonical_provider_name(name: &str) -> String { + if name.eq_ignore_ascii_case("ollama-cloud") { + "ollama".to_string() + } else { + name.to_string() + } +} + async fn handle_generate_text(args: GenerateTextArgs) -> Result { let provider = create_provider()?; @@ -409,16 +424,14 @@ async fn main() -> anyhow::Result<()> { } }; - // Initialize Ollama provider and start streaming - let ollama_url = env::var("OLLAMA_URL") - .unwrap_or_else(|_| "http://localhost:11434".to_string()); - let provider = match OllamaProvider::new(&ollama_url) { + // Initialize provider and start streaming + let provider = match create_provider() { Ok(p) => p, Err(e) => { let err_resp = RpcErrorResponse::new( id.clone(), RpcError::internal_error(format!( - "Failed to init OllamaProvider: {}", + "Failed to initialize provider: {:?}", e )), ); diff --git a/crates/owlen-ollama/Cargo.toml b/crates/owlen-ollama/Cargo.toml deleted file mode 100644 index 78a5aaf..0000000 --- a/crates/owlen-ollama/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "owlen-ollama" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -description = "Ollama provider for OWLEN LLM client" - -[dependencies] -owlen-core = { path = "../owlen-core" } - -# HTTP client -reqwest = { workspace = true } - -# Async runtime -tokio = { workspace = true } -tokio-stream = { workspace = true } -futures = { workspace = true } -futures-util = { workspace = true } - -# Serialization -serde = { workspace = true } -serde_json = { workspace = true } - -# Utilities -anyhow = { workspace = true } -thiserror = { workspace = true } -uuid = { workspace = true } -async-trait = { workspace = true } - -[dev-dependencies] -tokio-test = { workspace = true } diff --git a/crates/owlen-ollama/README.md b/crates/owlen-ollama/README.md deleted file mode 100644 index eba2e9a..0000000 --- a/crates/owlen-ollama/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Owlen Ollama - -This crate provides an implementation of the `owlen-core::Provider` trait for the [Ollama](https://ollama.ai) backend. - -It allows Owlen to communicate with a local Ollama instance, sending requests and receiving responses from locally-run large language models. You can also target [Ollama Cloud](https://docs.ollama.com/cloud) by pointing the provider at `https://ollama.com` (or `https://api.ollama.com`) and providing an API key through your Owlen configuration (or the `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` environment variables). The client automatically adds the required Bearer authorization header when a key is supplied, accepts either host without rewriting, and expands inline environment references like `$OLLAMA_API_KEY` if you prefer not to check the secret into your config file. The generated configuration now includes both `providers.ollama` and `providers.ollama-cloud` entries—switch between them by updating `general.default_provider`. - -## Configuration - -To use this provider, you need to have Ollama installed and running. The default address is `http://localhost:11434`. You can configure this in your `config.toml` if your Ollama instance is running elsewhere. diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 1a0e3c5..74e6bf8 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -17,7 +17,7 @@ use crate::config; use crate::events::Event; // 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::{BTreeSet, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::Arc; const ONBOARDING_STATUS_LINE: &str = @@ -2392,70 +2392,85 @@ impl ChatApp { let mut models = Vec::new(); let mut errors = Vec::new(); + let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .ok(); + let server_binary = workspace_root.as_ref().and_then(|root| { + let candidates = [ + "target/debug/owlen-mcp-llm-server", + "target/release/owlen-mcp-llm-server", + ]; + candidates + .iter() + .map(|rel| root.join(rel)) + .find(|p| p.exists()) + .map(|p| p.to_string_lossy().into_owned()) + }); + for (name, provider_cfg) in provider_entries { let provider_type = provider_cfg.provider_type.to_ascii_lowercase(); if provider_type != "ollama" && provider_type != "ollama-cloud" { continue; } - // All providers communicate via MCP LLM server (Phase 10). - // For cloud providers, the URL is passed via the provider config. - let client_result = if provider_type == "ollama-cloud" { - // Cloud Ollama - create MCP client with custom URL via env var - use owlen_core::config::McpServerConfig; - use std::collections::HashMap; - - let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .ok(); - - let binary_path = workspace_root.and_then(|root| { - let candidates = [ - "target/debug/owlen-mcp-llm-server", - "target/release/owlen-mcp-llm-server", - ]; - candidates - .iter() - .map(|rel| root.join(rel)) - .find(|p| p.exists()) - }); - - if let Some(path) = binary_path { - let mut env_vars = HashMap::new(); - if let Some(url) = &provider_cfg.base_url { - env_vars.insert("OLLAMA_URL".to_string(), url.clone()); - } - - let config = McpServerConfig { - name: name.clone(), - command: path.to_string_lossy().into_owned(), - args: Vec::new(), - transport: "stdio".to_string(), - env: env_vars, - }; - RemoteMcpClient::new_with_config(&config) - } else { - Err(owlen_core::Error::NotImplemented( - "MCP server binary not found".into(), - )) - } + let canonical_name = if name.eq_ignore_ascii_case("ollama-cloud") { + "ollama".to_string() } else { - // Local Ollama - use default MCP client - RemoteMcpClient::new() + name.clone() + }; + + // All providers communicate via MCP LLM server (Phase 10). + // Select provider by name via OWLEN_PROVIDER so per-provider settings apply. + let mut env_vars = HashMap::new(); + env_vars.insert("OWLEN_PROVIDER".to_string(), canonical_name.clone()); + + let client_result = if let Some(binary_path) = server_binary.as_ref() { + use owlen_core::config::McpServerConfig; + + let config = McpServerConfig { + name: format!("provider::{canonical_name}"), + command: binary_path.clone(), + args: Vec::new(), + transport: "stdio".to_string(), + env: env_vars.clone(), + }; + RemoteMcpClient::new_with_config(&config) + } else { + // Fallback to legacy discovery: temporarily set env vars while spawning. + let backups: Vec<(String, Option)> = env_vars + .keys() + .map(|key| (key.clone(), std::env::var(key).ok())) + .collect(); + + for (key, value) in env_vars.iter() { + std::env::set_var(key, value); + } + + let result = RemoteMcpClient::new(); + + for (key, original) in backups { + if let Some(value) = original { + std::env::set_var(&key, value); + } else { + std::env::remove_var(&key); + } + } + + result }; match client_result { Ok(client) => match client.list_models().await { Ok(mut provider_models) => { for model in &mut provider_models { - model.provider = name.clone(); + model.provider = canonical_name.clone(); } models.extend(provider_models); } Err(err) => errors.push(format!("{}: {}", name, err)), }, - Err(err) => errors.push(format!("{}: {}", name, err)), + Err(err) => errors.push(format!("{}: {}", canonical_name, err)), } } @@ -2497,13 +2512,50 @@ impl ChatApp { items.push(ModelSelectorItem::header(provider.clone(), is_expanded)); if is_expanded { - let mut matches: Vec<(usize, &ModelInfo)> = self + let relevant: Vec<(usize, &ModelInfo)> = self .models .iter() .enumerate() .filter(|(_, model)| &model.provider == provider) .collect(); + let mut best_by_canonical: HashMap = + HashMap::new(); + + let provider_lower = provider.to_ascii_lowercase(); + + 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 mut matches: Vec<(usize, &ModelInfo)> = best_by_canonical + .into_values() + .map(|entry| entry.1) + .collect(); + matches.sort_by(|(_, a), (_, b)| a.id.cmp(&b.id)); if matches.is_empty() { @@ -2680,54 +2732,67 @@ impl ChatApp { return Ok(()); } - let provider_cfg = if let Some(cfg) = self.controller.config().provider(provider_name) { - cfg.clone() + use owlen_core::config::McpServerConfig; + use std::collections::HashMap; + + let canonical_name = if provider_name.eq_ignore_ascii_case("ollama-cloud") { + "ollama" } else { - let mut guard = self.controller.config_mut(); - // Pass a mutable reference directly; avoid unnecessary deref - let cfg = config::ensure_provider_config(&mut guard, provider_name); - cfg.clone() + provider_name }; - // All providers use MCP architecture (Phase 10). - // For cloud providers, pass the URL via environment variable. - let provider: Arc = if provider_cfg - .provider_type - .eq_ignore_ascii_case("ollama-cloud") - { - // Cloud Ollama - create MCP client with custom URL - use owlen_core::config::McpServerConfig; - use std::collections::HashMap; + if self.controller.config().provider(canonical_name).is_none() { + let mut guard = self.controller.config_mut(); + config::ensure_provider_config(&mut guard, canonical_name); + } - let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize()?; - - let binary_path = [ + let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .ok(); + let server_binary = workspace_root.as_ref().and_then(|root| { + [ "target/debug/owlen-mcp-llm-server", "target/release/owlen-mcp-llm-server", ] .iter() - .map(|rel| workspace_root.join(rel)) + .map(|rel| root.join(rel)) .find(|p| p.exists()) - .ok_or_else(|| anyhow::anyhow!("MCP LLM server binary not found"))?; + }); - let mut env_vars = HashMap::new(); - if let Some(url) = &provider_cfg.base_url { - env_vars.insert("OLLAMA_URL".to_string(), url.clone()); - } + let mut env_vars = HashMap::new(); + env_vars.insert("OWLEN_PROVIDER".to_string(), canonical_name.to_string()); + let provider: Arc = if let Some(path) = server_binary { let config = McpServerConfig { - name: provider_name.to_string(), - command: binary_path.to_string_lossy().into_owned(), + name: canonical_name.to_string(), + command: path.to_string_lossy().into_owned(), args: Vec::new(), transport: "stdio".to_string(), env: env_vars, }; Arc::new(RemoteMcpClient::new_with_config(&config)?) } else { - // Local Ollama via default MCP client - Arc::new(RemoteMcpClient::new()?) + let backups: Vec<(String, Option)> = env_vars + .keys() + .map(|key| (key.clone(), std::env::var(key).ok())) + .collect(); + + for (key, value) in env_vars.iter() { + std::env::set_var(key, value); + } + + let result = RemoteMcpClient::new(); + + for (key, original) in backups { + if let Some(value) = original { + std::env::set_var(&key, value); + } else { + std::env::remove_var(&key); + } + } + + Arc::new(result?) }; self.controller.switch_provider(provider).await?; diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 0a75c3a..2f93d58 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -1390,16 +1390,17 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { .add_modifier(Modifier::BOLD), )) } - ModelSelectorItemKind::Model { - provider: _, - model_index, - } => { + ModelSelectorItemKind::Model { model_index, .. } => { if let Some(model) = app.model_info_by_index(*model_index) { - let tool_indicator = if model.supports_tools { "🔧 " } else { " " }; - let label = if model.name.is_empty() { - format!(" {}{}", tool_indicator, model.id) + let mut badges = Vec::new(); + if model.supports_tools { + badges.push("🔧"); + } + + let label = if badges.is_empty() { + format!(" {}", model.id) } else { - format!(" {}{} — {}", tool_indicator, model.id, model.name) + format!(" {} - {}", model.id, badges.join(" ")) }; ListItem::new(Span::styled( label, diff --git a/docs/configuration.md b/docs/configuration.md index 1d85221..c3b86a3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -133,13 +133,28 @@ base_url = "https://ollama.com" ### Using Ollama Cloud -To talk to [Ollama Cloud](https://docs.ollama.com/cloud), point the base URL at the hosted endpoint and supply your API key: +Owlen now ships a single unified `ollama` provider. When an API key is present, Owlen automatically routes traffic to [Ollama Cloud](https://docs.ollama.com/cloud); otherwise it talks to the local daemon. A minimal configuration looks like this: ```toml -[providers.ollama-cloud] -provider_type = "ollama-cloud" -base_url = "https://ollama.com" +[providers.ollama] +provider_type = "ollama" +base_url = "http://localhost:11434" # ignored once an API key is supplied api_key = "${OLLAMA_API_KEY}" ``` Requests target the same `/api/chat` endpoint documented by Ollama and automatically include the API key using a `Bearer` authorization header. If you prefer not to store the key in the config file, you can leave `api_key` unset and provide it via the `OLLAMA_API_KEY` (or `OLLAMA_CLOUD_API_KEY`) environment variable instead. You can also reference an environment variable inline (for example `api_key = "$OLLAMA_API_KEY"` or `api_key = "${OLLAMA_API_KEY}"`), which Owlen expands when the configuration is loaded. The base URL is normalised automatically—Owlen enforces HTTPS, trims trailing slashes, and accepts both `https://ollama.com` and `https://api.ollama.com` without rewriting the host. + +> **Tip:** If the official `ollama signin` flow fails on Linux v0.12.3, follow the [Linux Ollama sign-in workaround](#linux-ollama-sign-in-workaround-v0123) in the troubleshooting guide to copy keys from a working machine or register them manually. + +### Managing cloud credentials via CLI + +Owlen now ships with an interactive helper for Ollama Cloud: + +```bash +owlen cloud setup # Prompt for your API key (or use --api-key) +owlen cloud status # Verify authentication/latency +owlen cloud models # List the hosted models your account can access +owlen cloud logout # Forget the stored API key +``` + +When `privacy.encrypt_local_data = true`, the API key is written to Owlen's encrypted credential vault instead of being persisted in plaintext. Subsequent invocations automatically load the key into the runtime environment so that the config file can remain redacted. If encryption is disabled, the key is stored under `[providers.ollama-cloud].api_key` as before. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f989caa..5bea3e9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -40,11 +40,68 @@ If Owlen is not behaving as you expect, there might be an issue with your config ## Ollama Cloud Authentication Errors -If you see `Auth` errors when using the `ollama-cloud` provider: +If you see `Auth` errors when using the hosted service: -1. Ensure `providers.ollama-cloud.api_key` is set **or** export `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` before launching Owlen. -2. Confirm the key has access to the requested models. -3. Avoid pasting extra quotes or whitespace into the config file—`owlen config doctor` will normalise the entry for you. +1. Run `owlen cloud setup` to register your API key (with `--api-key` for non-interactive use). +2. Use `owlen cloud status` to verify Owlen can authenticate against [Ollama Cloud](https://docs.ollama.com/cloud). +3. Ensure `providers.ollama.api_key` is set **or** export `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` when encryption is disabled. With `privacy.encrypt_local_data = true`, the key lives in the encrypted vault and is loaded automatically. +4. Confirm the key has access to the requested models. +5. Avoid pasting extra quotes or whitespace into the config file—`owlen config doctor` will normalise the entry for you. + +### Linux Ollama Sign-In Workaround (v0.12.3) + +Ollama v0.12.3 on Linux ships with a broken `ollama signin` command. Until you can upgrade to ≥0.12.4, use one of the manual workflows below to register your key pair. + +#### 1. Manual key copy + +1. **Locate (or generate) keys on Linux** + ```bash + ls -la /usr/share/ollama/.ollama/ + sudo systemctl start ollama # start the service if the directory is empty + ``` +2. **Copy keys from a working Mac** + ```bash + # On macOS (source machine) + cat ~/.ollama/id_ed25519.pub + cat ~/.ollama/id_ed25519 + ``` + ```bash + # On Linux (target machine) + sudo systemctl stop ollama + sudo mkdir -p /usr/share/ollama/.ollama + sudo tee /usr/share/ollama/.ollama/id_ed25519.pub <<'EOF' + + EOF + sudo tee /usr/share/ollama/.ollama/id_ed25519 <<'EOF' + + EOF + sudo chown -R ollama:ollama /usr/share/ollama/.ollama/ + sudo chmod 600 /usr/share/ollama/.ollama/id_ed25519 + sudo chmod 644 /usr/share/ollama/.ollama/id_ed25519.pub + sudo systemctl start ollama + ``` + +#### 2. Manual web registration + +1. Read the Linux public key: + ```bash + sudo cat /usr/share/ollama/.ollama/id_ed25519.pub + ``` +2. Open and paste the public key. + +After either method, confirm access: + +```bash +ollama list +``` + +#### Troubleshooting + +- Permissions: `sudo chown -R ollama:ollama /usr/share/ollama/.ollama/` then re-apply `chmod` (`600` private, `644` public). +- Service status: `sudo systemctl status ollama` and `sudo journalctl -u ollama -f`. +- Alternate paths: Some distros run Ollama as a user process (`~/.ollama`). Copy the keys into that directory if `/usr/share/ollama/.ollama` is unused. + +This workaround mirrors what `ollama signin` should do—register the key pair with Ollama Cloud—without waiting for the patched release. Once you upgrade to v0.12.4 or newer, the interactive sign-in command works again. ## Performance Tuning