diff --git a/MODULE.md b/MODULE.md index c5df6eb..98ce332 100644 --- a/MODULE.md +++ b/MODULE.md @@ -14,7 +14,8 @@ By default `ListTools` presents capability roles (`web_search_tool`, `web_answer_tool`, `web_contents_tool`), each dispatched across an ordered provider list with fallback. Only Exa and Gemini have a managed backend route; TinyFish, Parallel and the other keyed providers need the host to -supply the user's own provider credential. Classified failures cross the bus as +supply the user's own provider credential. SearXNG and Keenable work without +one. Classified failures cross the bus as `tinysearch.: `; see `tinysearch_bus::errors`. The typed wire contract, version 2.0, is in `tinysearch-bus`. diff --git a/README.md b/README.md index 4ff1ab4..a0ccc6d 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ role that has at least one usable provider: | Role | Tool | Arguments | Providers (default order) | | --- | --- | --- | --- | -| `search` | `web_search_tool` | `query`, `max_results?` (1-20), `provider?` | exa, brave, tavily, parallel, querit, seltz, searxng, tinyfish | +| `search` | `web_search_tool` | `query`, `max_results?` (1-20), `provider?` | exa, brave, tavily, parallel, querit, seltz, searxng, tinyfish, keenable | | `answer` | `web_answer_tool` | `query`, `depth?` (`quick` or `deep`), `provider?` | gemini, gemini_deep_research, exa, parallel | -| `contents` | `web_contents_tool` | `urls` (1-10), `query?`, `provider?` | exa, tavily, parallel, tinyfish | +| `contents` | `web_contents_tool` | `urls` (1-10), `query?`, `provider?` | exa, tavily, parallel, tinyfish, keenable | `presentation.roles` sets an ordered provider list per role; an absent or empty list uses the default order above. The first usable provider answers. When it @@ -136,6 +136,20 @@ it with a `base_url` and a direct route. It requests `/search?format=json`, maps `web` to the `general` category, uses the configured default language, and returns up to 50 results with their source names in `provider_data.sources`. +Keenable (`keenable_search`, `keenable_fetch`) needs no credential: once the +host enables it with a direct route, it calls Keenable's keyless +`/v1/search/public` and `/v1/fetch/public`, which are rate limited per IP. A +configured credential switches both tools to `/v1/search` and `/v1/fetch`, +sent as `X-API-Key`, for higher limits. Every request names the caller with +`X-Keenable-Title: tinysearch`, which the keyless endpoints require; it +carries no user or host identifier. Search posts the query with optional +`site`, `published_after` and `published_before` filters, returns up to 20 +results with publish dates, and asks for 1,200-character snippets. +`keenable_fetch` reads each URL (1-10) as markdown from Keenable's index and +fetches a page live from the source when it is not indexed (a 404). Pages that +fail are counted in `provider_data.failed_count`; when every page fails, the +first failure's code is returned so the contents role can fall back. + Every response bounds results, citations, snippets, answers, and retained provider metadata. Upstream error bodies are not returned or logged. Provider base URL overrides are intended for local testing and controlled deployments. diff --git a/crates/tinysearch-bus/src/search/catalog/mod.rs b/crates/tinysearch-bus/src/search/catalog/mod.rs index 30fe9d1..8dd615b 100644 --- a/crates/tinysearch-bus/src/search/catalog/mod.rs +++ b/crates/tinysearch-bus/src/search/catalog/mod.rs @@ -23,6 +23,7 @@ pub const PROVIDERS: &[&str] = &[ "tavily", "seltz", "searxng", + "keenable", ]; /// Providers that support [`ProviderRoute::Backend`] through the managed @@ -179,6 +180,20 @@ fn direct_provider_specs() -> BTreeMap> { json!({"query":text,"max_results":{"type":"integer","minimum":1,"maximum":20},"include_domains":urls(20),"exclude_domains":urls(20),"from_date":text,"to_date":text,"scope":{"type":"string","enum":["news"]}}), &["query"], )]; + let keenable = vec![ + tool( + "keenable_search", + "Search the web with Keenable", + json!({"query":text,"max_results":{"type":"integer","minimum":1,"maximum":20},"site":text,"published_after":text,"published_before":text}), + &["query"], + ), + tool( + "keenable_fetch", + "Read web pages with Keenable", + json!({"urls":urls(10)}), + &["urls"], + ), + ]; [ ("exa".into(), exa), ("parallel".into(), parallel_specs()), @@ -186,6 +201,7 @@ fn direct_provider_specs() -> BTreeMap> { ("querit".into(), querit), ("tavily".into(), tavily), ("seltz".into(), seltz), + ("keenable".into(), keenable), ] .into() } @@ -288,6 +304,11 @@ pub fn configured_provider_tools( ProviderRoute::Direct if name == "searxng" => { non_empty(explicit.base_url.as_deref()) } + // Keenable has keyless public endpoints, so like SearXNG it has no + // credential to gate on: the host's own enabled entry for it is the + // opt-in (a default configuration lists nothing), and calls go only to + // Keenable or the host's base URL. A credential just raises limits. + ProviderRoute::Direct if name == "keenable" => true, ProviderRoute::Direct => { KEYED_DIRECT_PROVIDERS.contains(&name.as_str()) && non_empty(explicit.credential.as_deref()) diff --git a/crates/tinysearch-bus/src/search/catalog/mod_tests.rs b/crates/tinysearch-bus/src/search/catalog/mod_tests.rs index 4cee742..85359c7 100644 --- a/crates/tinysearch-bus/src/search/catalog/mod_tests.rs +++ b/crates/tinysearch-bus/src/search/catalog/mod_tests.rs @@ -34,6 +34,7 @@ fn catalog_and_selection_are_stable() { "exa", "gemini", "gemini_deep_research", + "keenable", "parallel", "querit", "searxng", @@ -43,13 +44,14 @@ fn catalog_and_selection_are_stable() { ] ); assert_eq!(specs["tinyfish"].len(), 3); + assert_eq!(specs["keenable"].len(), 2); assert_eq!(specs["exa"].len(), 4); assert_eq!(specs["parallel"].len(), 9); let all = PresentationConfig { mode: PresentationMode::AllTools, ..PresentationConfig::default() }; - assert_eq!(select_tools(&specs, &all).tools.len(), 27); + assert_eq!(select_tools(&specs, &all).tools.len(), 29); } #[test] @@ -144,13 +146,24 @@ fn provider_roles_and_defaults_match_the_contract() { assert_eq!(provider_roles("gemini_deep_research"), [Role::Answer]); assert_eq!(provider_roles("tinyfish"), [Role::Search, Role::Contents]); assert_eq!(provider_roles("tavily"), [Role::Search, Role::Contents]); + assert_eq!(provider_roles("keenable"), [Role::Search, Role::Contents]); + assert_eq!( + role_provider_tool(Role::Search, "keenable"), + Some("keenable_search") + ); + assert_eq!( + role_provider_tool(Role::Contents, "keenable"), + Some("keenable_fetch") + ); + assert_eq!(role_provider_tool(Role::Answer, "keenable"), None); for provider in ["brave", "querit", "seltz", "searxng"] { assert_eq!(provider_roles(provider), [Role::Search]); } assert_eq!( default_role_providers(Role::Search), [ - "exa", "brave", "tavily", "parallel", "querit", "seltz", "searxng", "tinyfish" + "exa", "brave", "tavily", "parallel", "querit", "seltz", "searxng", "tinyfish", + "keenable" ] ); assert_eq!( @@ -159,7 +172,7 @@ fn provider_roles_and_defaults_match_the_contract() { ); assert_eq!( default_role_providers(Role::Contents), - ["exa", "tavily", "parallel", "tinyfish"] + ["exa", "tavily", "parallel", "tinyfish", "keenable"] ); assert_eq!( PROVIDERS, @@ -173,7 +186,8 @@ fn provider_roles_and_defaults_match_the_contract() { "querit", "tavily", "seltz", - "searxng" + "searxng", + "keenable" ] ); // The TinyHumans backend does not proxy TinyFish: it is own-key only. diff --git a/crates/tinysearch-bus/src/search/catalog/roles.rs b/crates/tinysearch-bus/src/search/catalog/roles.rs index 008118c..6712533 100644 --- a/crates/tinysearch-bus/src/search/catalog/roles.rs +++ b/crates/tinysearch-bus/src/search/catalog/roles.rs @@ -14,7 +14,7 @@ pub fn provider_roles(provider: &str) -> &'static [Role] { match provider { "exa" | "parallel" => &[Role::Search, Role::Answer, Role::Contents], "gemini" | "gemini_deep_research" => &[Role::Answer], - "tinyfish" | "tavily" => &[Role::Search, Role::Contents], + "tinyfish" | "tavily" | "keenable" => &[Role::Search, Role::Contents], "brave" | "querit" | "seltz" | "searxng" => &[Role::Search], _ => &[], } @@ -26,9 +26,10 @@ pub fn default_role_providers(role: Role) -> &'static [&'static str] { match role { Role::Search => &[ "exa", "brave", "tavily", "parallel", "querit", "seltz", "searxng", "tinyfish", + "keenable", ], Role::Answer => &["gemini", "gemini_deep_research", "exa", "parallel"], - Role::Contents => &["exa", "tavily", "parallel", "tinyfish"], + Role::Contents => &["exa", "tavily", "parallel", "tinyfish", "keenable"], } } @@ -44,6 +45,7 @@ pub fn role_provider_tool(role: Role, provider: &str) -> Option<&'static str> { (Role::Search, "searxng") => "searxng_search", (Role::Search, "tinyfish") => "tinyfish_search", (Role::Search, "parallel") => "parallel_search", + (Role::Search, "keenable") => "keenable_search", (Role::Answer, "gemini") => "gemini_agentic_search", (Role::Answer, "gemini_deep_research") => "gemini_deep_research", (Role::Answer, "exa") => "exa_answer", @@ -52,6 +54,7 @@ pub fn role_provider_tool(role: Role, provider: &str) -> Option<&'static str> { (Role::Contents, "tavily") => "tavily_extract", (Role::Contents, "tinyfish") => "tinyfish_fetch", (Role::Contents, "parallel") => "parallel_extract", + (Role::Contents, "keenable") => "keenable_fetch", _ => return None, }) } diff --git a/crates/tinysearch/src/provider/direct/keenable.rs b/crates/tinysearch/src/provider/direct/keenable.rs new file mode 100644 index 0000000..2fa2a5f --- /dev/null +++ b/crates/tinysearch/src/provider/direct/keenable.rs @@ -0,0 +1,186 @@ +//! Keenable search and page fetch, usable without a credential. +//! +//! Without a credential, requests go to Keenable's public endpoints, which are +//! rate limited per IP and identify the caller by the `X-Keenable-Title` +//! header. A configured credential switches both tools to the keyed endpoints, +//! which have higher limits, and is sent as `X-API-Key`. +use super::{configured_timeout, direct_url, normalize, required_string, urls}; +use crate::{Error, ExecuteToolRequest, ExecuteToolResponse, ProviderConfig, Result}; +use reqwest::{Client, RequestBuilder, StatusCode}; +use serde_json::{Value, json}; +use std::time::Duration; + +const BASE: &str = "https://api.keenable.ai/v1"; +/// Names the calling software. Keenable rejects a keyless request without it; +/// it carries no user, host, or installation identifier. +const APP_TITLE: &str = "tinysearch"; +/// Characters of page text requested per search result. Normalization keeps at +/// most this many per snippet, so a longer excerpt would only be discarded. +const SNIPPET_CHARS: u64 = 1200; + +pub(super) async fn run( + client: &Client, + config: &ProviderConfig, + request: &ExecuteToolRequest, +) -> Result { + let key = config + .credential + .as_deref() + .map(str::trim) + .filter(|key| !key.is_empty()); + let value = match request.name.as_str() { + "keenable_search" => search(client, config, key, &request.arguments).await?, + "keenable_fetch" => fetch(client, config, key, &request.arguments).await?, + _ => return Err(Error::UnavailableTool(request.name.clone())), + }; + Ok(normalize("keenable", &request.name, &value)) +} + +/// The keyed endpoint, or its keyless `/public` twin when there is no key. +fn endpoint(config: &ProviderConfig, key: Option<&str>, path: &str) -> Result { + let suffix = if key.is_some() { "" } else { "/public" }; + direct_url(config.base_url.as_deref(), BASE, &format!("{path}{suffix}")) +} + +fn identified(builder: RequestBuilder, key: Option<&str>) -> RequestBuilder { + let builder = builder + .header(reqwest::header::ACCEPT, "application/json") + .header("X-Keenable-Title", APP_TITLE); + match key { + Some(key) => builder.header("X-API-Key", key), + None => builder, + } +} + +async fn search( + client: &Client, + config: &ProviderConfig, + key: Option<&str>, + args: &Value, +) -> Result { + let mut body = json!({ + "query": required_string(args, "query")?, + "max_results": args + .get("max_results") + .and_then(Value::as_u64) + .or(config.max_results) + .unwrap_or(5) + .clamp(1, 20), + "snippet_max_length": SNIPPET_CHARS, + }); + for field in ["site", "published_after", "published_before"] { + if let Some(value) = args.get(field) { + body[field] = value.clone(); + } + } + let response = identified(client.post(endpoint(config, key, "/search")?), key) + .json(&body) + .timeout(configured_timeout(config, Duration::from_secs(15))) + .send() + .await + .map_err(super::super::http::transport_error)?; + let value = super::super::http::read_json(response).await?; + Ok(search_results(&value)) +} + +/// Maps Keenable results onto the fields normalization reads. The page text +/// is in `snippet`; `description` is usually empty and only a fallback. +fn search_results(value: &Value) -> Value { + let results: Vec = value + .get("results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|item| { + let text = |field: &str| { + item.get(field) + .and_then(Value::as_str) + .filter(|text| !text.trim().is_empty()) + }; + json!({ + "url": text("url"), + "title": text("title"), + "snippet": text("snippet").or_else(|| text("description")), + "published_date": text("published_at"), + }) + }) + .collect(); + json!({"results": results}) +} + +async fn fetch( + client: &Client, + config: &ProviderConfig, + key: Option<&str>, + args: &Value, +) -> Result { + let urls = urls(args)?; + let mut results = Vec::new(); + let mut first_error = None; + for url in urls + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + match fetch_page(client, config, key, url).await { + Ok(page) => results.push(json!({ + "url": page.get("url").and_then(Value::as_str).unwrap_or(url), + "title": page.get("title").and_then(Value::as_str).unwrap_or(""), + "snippet": page.get("content").and_then(Value::as_str).unwrap_or(""), + })), + Err(error) => { + first_error.get_or_insert(error); + } + } + } + // A partial failure still returns the pages that were read; a total one + // keeps the first error's classification so the role can fall back. + if results.is_empty() + && let Some(error) = first_error + { + return Err(error); + } + let failed = urls + .as_array() + .map_or(0, Vec::len) + .saturating_sub(results.len()); + Ok(json!({"results": results, "failed_count": failed})) +} + +/// Reads Keenable's indexed copy of a page and, when the page is not indexed +/// (404), fetches it live from the source instead. +async fn fetch_page( + client: &Client, + config: &ProviderConfig, + key: Option<&str>, + url: &str, +) -> Result { + let send = |live: bool| { + let mut builder = + identified(client.get(endpoint(config, key, "/fetch")?), key).query(&[("url", url)]); + if live { + builder = builder.query(&[("live", "true")]); + } + Ok::<_, Error>( + builder + .timeout(configured_timeout(config, Duration::from_secs(30))) + .send(), + ) + }; + let response = send(false)? + .await + .map_err(super::super::http::transport_error)?; + let response = if response.status() == StatusCode::NOT_FOUND { + send(true)? + .await + .map_err(super::super::http::transport_error)? + } else { + response + }; + super::super::http::read_json(response).await +} + +#[cfg(test)] +#[path = "keenable/keenable_tests.rs"] +mod test; diff --git a/crates/tinysearch/src/provider/direct/keenable/keenable_tests.rs b/crates/tinysearch/src/provider/direct/keenable/keenable_tests.rs new file mode 100644 index 0000000..6489dee --- /dev/null +++ b/crates/tinysearch/src/provider/direct/keenable/keenable_tests.rs @@ -0,0 +1,311 @@ +use super::*; +use crate::{PresentationMode, SearchConfig, SearchService}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; + +type TestResult = std::result::Result>; + +/// Serves one canned `(status, body)` reply per connection, in order, and +/// returns the raw requests it received. +async fn mock( + replies: Vec<(u16, Value)>, +) -> TestResult<(String, tokio::task::JoinHandle>>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let url = format!("http://{}", listener.local_addr()?); + let task = tokio::spawn(async move { + let mut requests = Vec::new(); + for (status, body) in replies { + let (mut stream, _) = listener.accept().await?; + let mut data = vec![0; 65536]; + let mut used = 0; + loop { + let read = stream.read(&mut data[used..]).await?; + if read == 0 { + break; + } + used += read; + if let Some(end) = data[..used].windows(4).position(|w| w == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&data[..end + 4]).to_ascii_lowercase(); + let length = head + .lines() + .find_map(|line| { + line.strip_prefix("content-length: ") + .and_then(|v| v.trim().parse::().ok()) + }) + .unwrap_or(0); + if used >= end + 4 + length { + break; + } + } + } + requests.push(String::from_utf8_lossy(&data[..used]).into_owned()); + let body = body.to_string(); + let reply = format!( + "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(reply.as_bytes()).await?; + } + Ok(requests) + }); + Ok((url, task)) +} + +fn request(name: &str, arguments: Value) -> ExecuteToolRequest { + ExecuteToolRequest { + name: name.into(), + arguments, + } +} + +fn config(base_url: &str, credential: Option<&str>) -> ProviderConfig { + ProviderConfig { + base_url: Some(base_url.into()), + credential: credential.map(Into::into), + ..Default::default() + } +} + +fn header<'a>(request: &'a str, name: &str) -> Option<&'a str> { + request.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case(name).then(|| value.trim()) + }) +} + +fn body(request: &str) -> TestResult { + let (_, body) = request.split_once("\r\n\r\n").ok_or("missing body")?; + Ok(serde_json::from_str(body)?) +} + +#[tokio::test] +async fn keyless_search_uses_the_public_endpoint_and_names_the_caller() -> TestResult<()> { + let (url, server) = mock(vec![( + 200, + json!({"query":"rust","results":[ + {"title":"Rust","url":"https://rust.test","description":"","snippet":"Page text","published_at":"2026-01-15T10:30:00Z","acquired_at":"2026-01-16T08:12:34Z"}, + {"title":"Only a description","url":"https://two.test","description":"Summary","snippet":" "}, + {"title":"No URL","snippet":"dropped"} + ]}), + )]) + .await?; + let response = run( + &Client::new(), + &config(&url, None), + &request("keenable_search", json!({"query":"rust"})), + ) + .await?; + let sent = server.await??; + let sent = sent.first().ok_or("no request")?; + assert!(sent.starts_with("POST /search/public ")); + assert_eq!(header(sent, "x-keenable-title"), Some("tinysearch")); + assert_eq!(header(sent, "x-api-key"), None); + assert_eq!( + body(sent)?, + json!({"query":"rust","max_results":5,"snippet_max_length":1200}) + ); + assert_eq!(response.provider, "keenable"); + assert_eq!(response.results.len(), 2); + assert_eq!(response.results[0].snippet.as_deref(), Some("Page text")); + assert_eq!( + response.results[0].published.as_deref(), + Some("2026-01-15T10:30:00Z") + ); + assert_eq!(response.results[1].snippet.as_deref(), Some("Summary")); + assert_eq!(response.results[1].published, None); + Ok(()) +} + +#[tokio::test] +async fn keyed_search_uses_the_key_the_configured_count_and_filters() -> TestResult<()> { + let (url, server) = mock(vec![(200, json!({"results":[]}))]).await?; + let mut config = config(&url, Some(" keen_test ")); + config.max_results = Some(50); + let response = run( + &Client::new(), + &config, + &request( + "keenable_search", + json!({"query":"rust","site":"rust-lang.org","published_after":"7d","published_before":"2026-10-01","ignored":true}), + ), + ) + .await?; + let sent = server.await??; + let sent = sent.first().ok_or("no request")?; + assert!(sent.starts_with("POST /search ")); + assert_eq!(header(sent, "x-api-key"), Some("keen_test")); + assert_eq!(header(sent, "x-keenable-title"), Some("tinysearch")); + assert_eq!( + body(sent)?, + json!({"query":"rust","max_results":20,"snippet_max_length":1200,"site":"rust-lang.org","published_after":"7d","published_before":"2026-10-01"}) + ); + assert_eq!(response.status, crate::SearchStatus::Empty); + Ok(()) +} + +#[tokio::test] +async fn fetch_reads_each_url_and_retries_an_unindexed_page_live() -> TestResult<()> { + let (url, server) = mock(vec![ + ( + 200, + json!({"url":"https://a.test/","title":"A","description":"","content":"# A"}), + ), + (404, json!({"error":"Not found","message":"private"})), + ( + 200, + json!({"url":"https://b.test/","title":"B","content":"# B"}), + ), + ]) + .await?; + let response = run( + &Client::new(), + &config(&url, None), + &request( + "keenable_fetch", + json!({"urls":["https://a.test/","https://b.test/"]}), + ), + ) + .await?; + let sent = server.await??; + assert_eq!(sent.len(), 3); + assert!(sent[0].starts_with("GET /fetch/public?url=https%3A%2F%2Fa.test%2F ")); + assert!(sent[1].starts_with("GET /fetch/public?url=https%3A%2F%2Fb.test%2F ")); + assert!(sent[2].starts_with("GET /fetch/public?url=https%3A%2F%2Fb.test%2F&live=true ")); + assert!( + sent.iter() + .all(|s| header(s, "x-keenable-title") == Some("tinysearch")) + ); + assert_eq!(response.results.len(), 2); + assert_eq!(response.results[0].title, "A"); + assert_eq!(response.results[0].snippet.as_deref(), Some("# A")); + assert_eq!(response.results[1].url, "https://b.test/"); + assert_eq!( + response.provider_data.ok_or("missing provider data")?["failed_count"], + 0 + ); + Ok(()) +} + +#[tokio::test] +async fn fetch_with_a_key_uses_the_keyed_endpoint_and_reports_partial_failures() -> TestResult<()> { + let (url, server) = mock(vec![ + (503, json!({"message":"busy"})), + (200, json!({"content":"page text"})), + ]) + .await?; + let response = run( + &Client::new(), + &config(&url, Some("keen_test")), + &request( + "keenable_fetch", + json!({"urls":["https://a.test/","https://b.test/"]}), + ), + ) + .await?; + let sent = server.await??; + assert!(sent[0].starts_with("GET /fetch?url=")); + assert_eq!(header(&sent[1], "x-api-key"), Some("keen_test")); + assert_eq!(response.results.len(), 1); + // A page without its own URL or title keeps the requested URL. + assert_eq!(response.results[0].url, "https://b.test/"); + assert_eq!(response.results[0].title, ""); + assert_eq!( + response.provider_data.ok_or("missing provider data")?["failed_count"], + 1 + ); + Ok(()) +} + +#[tokio::test] +async fn a_fetch_where_every_page_fails_keeps_the_classification() -> TestResult<()> { + let (url, server) = mock(vec![( + 429, + json!({"message":"Public API hourly limit reached"}), + )]) + .await?; + let error = run( + &Client::new(), + &config(&url, None), + &request("keenable_fetch", json!({"urls":["https://a.test/"]})), + ) + .await + .err() + .ok_or("expected an error")?; + server.await??; + // Rate limits stay fallback-eligible so the contents role can try the next provider. + assert!(matches!(error, Error::RateLimited)); + Ok(()) +} + +#[tokio::test] +async fn invalid_requests_fail_before_any_call() -> TestResult<()> { + let config = config("http://127.0.0.1:9", None); + let client = Client::new(); + let unknown = run(&client, &config, &request("keenable_answer", json!({}))).await; + assert!(matches!(unknown, Err(Error::UnavailableTool(name)) if name == "keenable_answer")); + let no_query = run( + &client, + &config, + &request("keenable_search", json!({"query":" "})), + ) + .await; + assert!(matches!(no_query, Err(Error::InvalidArguments))); + let no_urls = run( + &client, + &config, + &request("keenable_fetch", json!({"urls":[]})), + ) + .await; + assert!(matches!(no_urls, Err(Error::InvalidArguments))); + let bad_base = run( + &client, + &ProviderConfig { + base_url: Some("ftp://keenable.test".into()), + ..Default::default() + }, + &request("keenable_search", json!({"query":"rust"})), + ) + .await; + assert!(matches!(bad_base, Err(Error::Provider(_)))); + Ok(()) +} + +#[test] +fn keenable_is_listed_without_a_credential_only_when_enabled_and_direct() -> TestResult<()> { + let tool_names = |config: &SearchConfig| -> Vec { + SearchService::with_providers(config.clone(), super::super::super::builtins()) + .list_tools() + .tools + .into_iter() + .map(|tool| tool.name) + .collect() + }; + let mut config = SearchConfig::default(); + config.presentation.mode = PresentationMode::AllTools; + assert_eq!(tool_names(&config).len(), 0); + config + .providers + .insert("keenable".into(), ProviderConfig::default()); + assert_eq!(tool_names(&config), ["keenable_search", "keenable_fetch"]); + config.presentation.mode = PresentationMode::Roles; + assert_eq!( + tool_names(&config), + ["web_search_tool", "web_contents_tool"] + ); + let keenable = config + .providers + .get_mut("keenable") + .ok_or("missing keenable provider")?; + keenable.route = crate::ProviderRoute::Backend; + assert_eq!(tool_names(&config).len(), 0); + let keenable = config + .providers + .get_mut("keenable") + .ok_or("missing keenable provider")?; + keenable.route = crate::ProviderRoute::Direct; + keenable.enabled = false; + assert_eq!(tool_names(&config).len(), 0); + Ok(()) +} diff --git a/crates/tinysearch/src/provider/direct/mod.rs b/crates/tinysearch/src/provider/direct/mod.rs index 41881f4..1e8c7b7 100644 --- a/crates/tinysearch/src/provider/direct/mod.rs +++ b/crates/tinysearch/src/provider/direct/mod.rs @@ -9,6 +9,7 @@ use std::time::Duration; mod brave; mod exa; +mod keenable; #[cfg(test)] mod migration_tests; mod parallel; @@ -32,6 +33,10 @@ pub(super) async fn run( if provider == "searxng" { return searxng::run(client, config, request).await; } + // Keenable works without a key, so it skips the credential check below. + if provider == "keenable" { + return keenable::run(client, config, request).await; + } if provider == "parallel" { return parallel::run(client, config, request).await; } diff --git a/crates/tinysearch/src/provider/mod.rs b/crates/tinysearch/src/provider/mod.rs index 5dba72d..8893ba6 100644 --- a/crates/tinysearch/src/provider/mod.rs +++ b/crates/tinysearch/src/provider/mod.rs @@ -68,7 +68,7 @@ impl BuiltinProvider { let (path, body) = match self.name { "exa" if config.route == ProviderRoute::Backend => exa_request(request)?, "exa" | "parallel" | "brave" | "querit" | "tavily" | "seltz" | "searxng" - | "tinyfish" => { + | "tinyfish" | "keenable" => { return direct::run(&self.client, self.name, config, request).await; } "gemini" => return self.gemini(config, backend, request).await,