From e813736b478db7ae3a5571db7d9a3300b24d683e Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 24 Oct 2025 13:23:47 +0200 Subject: [PATCH] feat(commands): expose runtime web toggle AC: - :web on/off updates tool exposure immediately and persists the toggle. - owlen providers web --enable/--disable reflects the same setting and reports current status. - Help/docs cover the new toggle paths and troubleshooting guidance. Tests: - cargo test -p owlen-cli - cargo test -p owlen-core toggling_web_search_updates_config_and_registry --- README.md | 1 + crates/owlen-cli/src/commands/providers.rs | 143 +++++++++++++++++++ crates/owlen-core/tests/web_search_toggle.rs | 140 ++++++++++++++++++ crates/owlen-tui/src/chat_app.rs | 82 +++++++++++ crates/owlen-tui/src/ui.rs | 1 + docs/configuration.md | 2 + docs/troubleshooting.md | 1 + 7 files changed, 370 insertions(+) create mode 100644 crates/owlen-core/tests/web_search_toggle.rs diff --git a/README.md b/README.md index 4d92237..ec47680 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ The refreshed chrome introduces a cockpit-style header with live gradient gauges - **Context + quota cockpit**: The header shows `context used / window (percentage)` and a second gauge for hourly/weekly cloud token usage. Configure soft limits via `providers.ollama_cloud.hourly_quota_tokens` and `weekly_quota_tokens`; Owlen tracks consumption locally even when the provider omits token counters. - **Web search tooling**: When cloud is enabled, models can call the `web.search` tool automatically. Toggle availability at runtime with `:web on` / `:web off` if you need a local-only session. - **Docs & config parity**: Ship-ready config templates now include per-provider `list_ttl_secs` and `default_context_window` values, plus explicit `OLLAMA_API_KEY` guidance. Run `owlen config doctor` after upgrading from v0.1 to normalize legacy keys and receive deprecation warnings for `OLLAMA_CLOUD_API_KEY` and `OWLEN_OLLAMA_CLOUD_API_KEY`. +- **Runtime toggles**: Use `:web on` / `:web off` in the TUI or `owlen providers web --enable/--disable` from the CLI to expose or hide the `web.search` tool without editing `config.toml`. ## Security & Privacy diff --git a/crates/owlen-cli/src/commands/providers.rs b/crates/owlen-cli/src/commands/providers.rs index a1746b0..6aef1be 100644 --- a/crates/owlen-cli/src/commands/providers.rs +++ b/crates/owlen-cli/src/commands/providers.rs @@ -35,6 +35,8 @@ pub enum ProvidersCommand { /// Provider identifier to disable. provider: String, }, + /// Enable or disable the web.search tool exposure. + Web(WebCommand), } /// Arguments for the `owlen models` command. @@ -45,12 +47,36 @@ pub struct ModelsArgs { pub provider: Option, } +/// Arguments for managing the web.search tool exposure. +#[derive(Debug, Args)] +pub struct WebCommand { + /// Enable the web.search tool and allow remote lookups. + #[arg(long, conflicts_with = "disable")] + enable: bool, + /// Disable the web.search tool to keep sessions local-only. + #[arg(long, conflicts_with = "enable")] + disable: bool, +} + +impl WebCommand { + fn desired_state(&self) -> Option { + if self.enable { + Some(true) + } else if self.disable { + Some(false) + } else { + None + } + } +} + pub async fn run_providers_command(command: ProvidersCommand) -> Result<()> { match command { ProvidersCommand::List => list_providers(), ProvidersCommand::Status { provider } => status_providers(provider.as_deref()).await, ProvidersCommand::Enable { provider } => toggle_provider(&provider, true), ProvidersCommand::Disable { provider } => toggle_provider(&provider, false), + ProvidersCommand::Web(args) => handle_web_command(args), } } @@ -206,6 +232,70 @@ fn verify_provider_filter(config: &Config, filter: Option<&str>) -> Result<()> { Ok(()) } +fn handle_web_command(args: WebCommand) -> Result<()> { + let mut config = tui_config::try_load_config().unwrap_or_default(); + let initial = web_tool_enabled(&config); + + if let Some(desired) = args.desired_state() { + apply_web_toggle(&mut config, desired); + tui_config::save_config(&config).map_err(|err| anyhow!(err))?; + + if initial == desired { + println!( + "Web search tool already {}.", + if desired { "enabled" } else { "disabled" } + ); + } else { + println!( + "Web search tool {}.", + if desired { "enabled" } else { "disabled" } + ); + } + println!( + "Remote search is {}.", + if config.privacy.enable_remote_search { + "enabled" + } else { + "disabled" + } + ); + } else { + println!( + "Web search tool is {}.", + if initial { "enabled" } else { "disabled" } + ); + println!( + "Remote search is {}.", + if config.privacy.enable_remote_search { + "enabled" + } else { + "disabled" + } + ); + } + + Ok(()) +} + +fn apply_web_toggle(config: &mut Config, enabled: bool) { + config.tools.web_search.enabled = enabled; + config.privacy.enable_remote_search = enabled; + + if enabled + && !config + .security + .allowed_tools + .iter() + .any(|tool| tool.eq_ignore_ascii_case("web_search")) + { + config.security.allowed_tools.push("web_search".to_string()); + } +} + +fn web_tool_enabled(config: &Config) -> bool { + config.tools.web_search.enabled && config.privacy.enable_remote_search +} + fn toggle_provider(provider: &str, enable: bool) -> Result<()> { let mut config = tui_config::try_load_config().unwrap_or_default(); let canonical = canonical_provider_id(provider); @@ -649,3 +739,56 @@ impl ProviderStatusRow { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_web_toggle_updates_flags_and_allowed_tools() { + let mut config = Config::default(); + config.privacy.enable_remote_search = false; + config.tools.web_search.enabled = false; + config.security.allowed_tools.clear(); + + apply_web_toggle(&mut config, true); + assert!(config.tools.web_search.enabled); + assert!(config.privacy.enable_remote_search); + assert_eq!( + 1, + config + .security + .allowed_tools + .iter() + .filter(|tool| tool.eq_ignore_ascii_case("web_search")) + .count() + ); + + apply_web_toggle(&mut config, false); + assert!(!config.tools.web_search.enabled); + assert!(!config.privacy.enable_remote_search); + } + + #[test] + fn apply_web_toggle_does_not_duplicate_allowed_entries() { + let mut config = Config::default(); + config + .security + .allowed_tools + .retain(|tool| !tool.eq_ignore_ascii_case("web_search")); + config.security.allowed_tools.push("web_search".to_string()); + + apply_web_toggle(&mut config, true); + apply_web_toggle(&mut config, true); + + assert_eq!( + 1, + config + .security + .allowed_tools + .iter() + .filter(|tool| tool.eq_ignore_ascii_case("web_search")) + .count() + ); + } +} diff --git a/crates/owlen-core/tests/web_search_toggle.rs b/crates/owlen-core/tests/web_search_toggle.rs new file mode 100644 index 0000000..d8c465a --- /dev/null +++ b/crates/owlen-core/tests/web_search_toggle.rs @@ -0,0 +1,140 @@ +use std::{any::Any, collections::HashMap, sync::Arc}; + +use async_trait::async_trait; +use futures::stream; +use owlen_core::{ + ChatStream, Provider, Result, + config::Config, + llm::ProviderConfig, + session::SessionController, + storage::StorageManager, + types::{ChatRequest, ChatResponse, Message, ModelInfo}, + ui::NoOpUiController, +}; +use serde_json::Value; +use tempfile::tempdir; + +struct StubCloudProvider; + +#[async_trait] +impl Provider for StubCloudProvider { + fn name(&self) -> &str { + "ollama_cloud" + } + + async fn list_models(&self) -> Result> { + Ok(vec![ModelInfo { + id: "stub-cloud-model".to_string(), + name: "Stub Cloud Model".to_string(), + description: Some("Stub model for web toggle tests".to_string()), + provider: self.name().to_string(), + context_window: Some(8192), + capabilities: vec!["chat".to_string(), "tools".to_string()], + supports_tools: true, + }]) + } + + async fn send_prompt(&self, _request: ChatRequest) -> Result { + Ok(ChatResponse { + message: Message::assistant(String::new()), + usage: None, + is_streaming: false, + is_final: true, + }) + } + + async fn stream_prompt(&self, _request: ChatRequest) -> Result { + Ok(Box::pin(stream::empty())) + } + + async fn health_check(&self) -> Result<()> { + Ok(()) + } + + fn as_any(&self) -> &(dyn Any + Send + Sync) { + self + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn toggling_web_search_updates_config_and_registry() { + let temp_dir = tempdir().expect("temp dir"); + let storage = Arc::new( + StorageManager::with_database_path(temp_dir.path().join("owlen-tests.db")) + .await + .expect("storage"), + ); + + let mut config = Config::default(); + config.privacy.encrypt_local_data = false; + config.general.default_model = Some("stub-cloud-model".into()); + config.general.default_provider = "ollama_cloud".into(); + + let mut provider_cfg = ProviderConfig { + enabled: true, + provider_type: "ollama_cloud".to_string(), + base_url: Some("https://ollama.com".to_string()), + api_key: Some("test-key".to_string()), + api_key_env: None, + extra: HashMap::new(), + }; + provider_cfg.extra.insert( + "web_search_endpoint".into(), + Value::String("/api/web_search".into()), + ); + config.providers.insert("ollama_cloud".into(), provider_cfg); + + let provider: Arc = Arc::new(StubCloudProvider); + let ui = Arc::new(NoOpUiController); + + let mut session = SessionController::new(provider, config, storage, ui, false, None) + .await + .expect("session controller"); + + assert!( + !session + .tool_registry() + .tools() + .iter() + .any(|tool| tool == "web_search"), + "web_search should be disabled by default" + ); + + session + .set_tool_enabled("web_search", true) + .await + .expect("enable web_search"); + + { + let cfg = session.config_async().await; + assert!(cfg.tools.web_search.enabled); + assert!(cfg.privacy.enable_remote_search); + } + assert!( + session + .tool_registry() + .tools() + .iter() + .any(|tool| tool == "web_search"), + "web_search should be registered when enabled" + ); + + session + .set_tool_enabled("web_search", false) + .await + .expect("disable web_search"); + + { + let cfg = session.config_async().await; + assert!(!cfg.tools.web_search.enabled); + assert!(!cfg.privacy.enable_remote_search); + } + assert!( + !session + .tool_registry() + .tools() + .iter() + .any(|tool| tool == "web_search"), + "web_search should be removed when disabled" + ); +} diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index f90bd89..c91e8fe 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -1661,6 +1661,16 @@ impl ChatApp { self.error = None; } + async fn set_web_tool_enabled(&mut self, enabled: bool) -> Result<()> { + self.controller + .set_tool_enabled("web_search", enabled) + .await + .map_err(|err| anyhow!(err))?; + config::save_config(&self.controller.config())?; + self.refresh_usage_summary().await?; + Ok(()) + } + /// Override the status line with a custom message. pub fn set_status_message>(&mut self, status: S) { self.status = status.into(); @@ -7459,6 +7469,78 @@ impl ChatApp { self.set_input_mode(InputMode::Normal); return Ok(AppState::Running); } + "web" => { + let action = + args.get(0).map(|value| value.to_ascii_lowercase()); + match action.as_deref() { + Some("on") | Some("enable") => { + match self.set_web_tool_enabled(true).await { + Ok(_) => { + self.status = "Web search enabled; remote lookups allowed." + .to_string(); + self.error = None; + self.push_toast( + ToastLevel::Info, + "Web search enabled; remote lookups allowed.", + ); + } + Err(err) => { + self.status = + "Failed to enable web search".to_string(); + self.error = Some(format!( + "Failed to enable web search: {}", + err + )); + } + } + } + Some("off") | Some("disable") => { + match self.set_web_tool_enabled(false).await { + Ok(_) => { + self.status = + "Web search disabled; staying local." + .to_string(); + self.error = None; + self.push_toast( + ToastLevel::Warning, + "Web search disabled; staying local.", + ); + } + Err(err) => { + self.status = + "Failed to disable web search".to_string(); + self.error = Some(format!( + "Failed to disable web search: {}", + err + )); + } + } + } + Some("status") | None => { + { + let config = self.controller.config(); + let enabled = config.tools.web_search.enabled + && config.privacy.enable_remote_search; + if enabled { + self.status = + "Web search is enabled.".to_string(); + } else { + self.status = + "Web search is disabled.".to_string(); + } + } + self.error = None; + } + _ => { + self.status = "Usage: :web ".to_string(); + self.error = + Some("Usage: :web ".to_string()); + } + } + 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 { diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 05c8667..841fed3 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -4244,6 +4244,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { 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(" :web on|off → expose or hide the web.search tool"), Line::from(""), Line::from(vec![Span::styled( "SESSION MANAGEMENT", diff --git a/docs/configuration.md b/docs/configuration.md index 9cd7a5c..94c0235 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -204,6 +204,8 @@ The quota fields are optional and purely informational—they are never sent to If your deployment exposes the web search endpoint under a different path, set `web_search_endpoint` in the same table. The default (`/api/web_search`) matches the Ollama Cloud REST API documented in the web retrieval guide. +Toggle the feature at runtime with `:web on` / `:web off` from the TUI or `owlen providers web --enable/--disable` on the CLI; both commands persist the change back to `config.toml`. + > **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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 12664b8..4d55f9c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -68,6 +68,7 @@ If the hosted API returns `HTTP 429 Too Many Requests`, Owlen keeps the provider 1. Check the cockpit header or run `:limits` to see your locally tracked hourly/weekly totals. When either bar crosses 80% Owlen warns you; 95% triggers a critical toast. 2. Raise or remove the soft quotas (`providers.ollama_cloud.hourly_quota_tokens`, `weekly_quota_tokens`) if your vendor allotment is higher, or pause cloud usage until the next window resets. 3. If you need the cloud-only model, retry after the provider’s cooling-off period (Ollama currently resets the rate window hourly for most SKUs). Adjust `list_ttl_secs` upward if automated refreshes are consuming too many tokens. +4. Use `:web off` (or `owlen providers web --disable`) to keep the session local until the rate window resets; re-enable with `:web on` / `owlen providers web --enable` when you want cloud lookups again. ### Linux Ollama Sign-In Workaround (v0.12.3)