diff --git a/README.md b/README.md index 4b7ab48..0e2df9c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ 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, keenable, gemini | +| `search` | `web_search_tool` | `query`, `max_results?` (1-20), `provider?` | exa, brave, tavily, parallel, querit, seltz, searxng, tinyfish, keenable, serply, gemini | | `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, keenable | @@ -162,7 +162,10 @@ backend routes; unsupported routes and tool arguments are rejected. Seltz (`seltz_search`) also requires a direct credential. It posts to `https://api.seltz.ai/v1/search` with `x-api-key`, supports domain and date -filters plus news scope, and returns up to 20 results. SearXNG +filters plus news scope, and returns up to 20 results. Serply +(`serply_search`) also requires a direct credential. It requests Google results +from `https://api.serply.io/v1/search` with `X-Api-Key`, takes an optional +two-letter `country`, and returns up to 10 organic results. SearXNG (`searxng_search`) is keyless and appears only when the host explicitly enables 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, diff --git a/crates/tinysearch-bus/src/labels/mod.rs b/crates/tinysearch-bus/src/labels/mod.rs index 3c55493..6a726de 100644 --- a/crates/tinysearch-bus/src/labels/mod.rs +++ b/crates/tinysearch-bus/src/labels/mod.rs @@ -21,6 +21,7 @@ pub fn provider_label(provider: &str) -> Cow<'_, str> { "seltz" => "Seltz", "searxng" => "SearXNG", "keenable" => "Keenable", + "serply" => "Serply", other => other, }) } diff --git a/crates/tinysearch-bus/src/labels/mod_tests.rs b/crates/tinysearch-bus/src/labels/mod_tests.rs index cf89062..149ae6c 100644 --- a/crates/tinysearch-bus/src/labels/mod_tests.rs +++ b/crates/tinysearch-bus/src/labels/mod_tests.rs @@ -14,6 +14,7 @@ fn known_ids_map_to_display_names() { ("seltz", "Seltz"), ("searxng", "SearXNG"), ("keenable", "Keenable"), + ("serply", "Serply"), ] { assert_eq!(provider_label(id), label); } diff --git a/crates/tinysearch-bus/src/search/catalog/mod.rs b/crates/tinysearch-bus/src/search/catalog/mod.rs index 8dd615b..541e78c 100644 --- a/crates/tinysearch-bus/src/search/catalog/mod.rs +++ b/crates/tinysearch-bus/src/search/catalog/mod.rs @@ -24,6 +24,7 @@ pub const PROVIDERS: &[&str] = &[ "seltz", "searxng", "keenable", + "serply", ]; /// Providers that support [`ProviderRoute::Backend`] through the managed @@ -43,6 +44,7 @@ const KEYED_DIRECT_PROVIDERS: &[&str] = &[ "seltz", "parallel", "tinyfish", + "serply", ]; /// Gemini models the managed backend accepts for grounded generation. @@ -180,6 +182,12 @@ 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 serply = vec![tool( + "serply_search", + "Search Google with Serply", + json!({"query":text,"max_results":{"type":"integer","minimum":1,"maximum":20},"country":{"type":"string","minLength":2,"maxLength":2}}), + &["query"], + )]; let keenable = vec![ tool( "keenable_search", @@ -202,6 +210,7 @@ fn direct_provider_specs() -> BTreeMap> { ("tavily".into(), tavily), ("seltz".into(), seltz), ("keenable".into(), keenable), + ("serply".into(), serply), ] .into() } diff --git a/crates/tinysearch-bus/src/search/catalog/mod_tests.rs b/crates/tinysearch-bus/src/search/catalog/mod_tests.rs index 8016578..c977ecf 100644 --- a/crates/tinysearch-bus/src/search/catalog/mod_tests.rs +++ b/crates/tinysearch-bus/src/search/catalog/mod_tests.rs @@ -39,6 +39,7 @@ fn catalog_and_selection_are_stable() { "querit", "searxng", "seltz", + "serply", "tavily", "tinyfish" ] @@ -51,7 +52,7 @@ fn catalog_and_selection_are_stable() { mode: PresentationMode::AllTools, ..PresentationConfig::default() }; - assert_eq!(select_tools(&specs, &all).tools.len(), 29); + assert_eq!(select_tools(&specs, &all).tools.len(), 30); } #[test] @@ -160,14 +161,14 @@ fn provider_roles_and_defaults_match_the_contract() { Some("keenable_fetch") ); assert_eq!(role_provider_tool(Role::Answer, "keenable"), None); - for provider in ["brave", "querit", "seltz", "searxng"] { + for provider in ["brave", "querit", "seltz", "searxng", "serply"] { assert_eq!(provider_roles(provider), [Role::Search]); } assert_eq!( default_role_providers(Role::Search), [ "exa", "brave", "tavily", "parallel", "querit", "seltz", "searxng", "tinyfish", - "keenable", "gemini" + "keenable", "serply", "gemini" ] ); assert_eq!( @@ -191,7 +192,8 @@ fn provider_roles_and_defaults_match_the_contract() { "tavily", "seltz", "searxng", - "keenable" + "keenable", + "serply" ] ); // The TinyHumans backend does not proxy TinyFish: it is own-key only. @@ -212,7 +214,7 @@ fn direct_tools_require_a_nonempty_private_credential() { let specs = provider_tool_specs(); let mut config = SearchConfig::default(); for name in [ - "exa", "parallel", "brave", "querit", "tavily", "seltz", "tinyfish", + "exa", "parallel", "brave", "querit", "tavily", "seltz", "tinyfish", "serply", ] { config.providers.insert(name.into(), keyed(" ")); } @@ -228,6 +230,7 @@ fn direct_tools_require_a_nonempty_private_credential() { assert_eq!(available["tavily"].len(), 2); assert_eq!(available["seltz"].len(), 1); assert_eq!(available["tinyfish"].len(), 3); + assert_eq!(available["serply"].len(), 1); } #[test] diff --git a/crates/tinysearch-bus/src/search/catalog/roles.rs b/crates/tinysearch-bus/src/search/catalog/roles.rs index 611724d..b72c017 100644 --- a/crates/tinysearch-bus/src/search/catalog/roles.rs +++ b/crates/tinysearch-bus/src/search/catalog/roles.rs @@ -16,7 +16,7 @@ pub fn provider_roles(provider: &str) -> &'static [Role] { "gemini" => &[Role::Search, Role::Answer], "gemini_deep_research" => &[Role::Answer], "tinyfish" | "tavily" | "keenable" => &[Role::Search, Role::Contents], - "brave" | "querit" | "seltz" | "searxng" => &[Role::Search], + "brave" | "querit" | "seltz" | "searxng" | "serply" => &[Role::Search], _ => &[], } } @@ -27,7 +27,7 @@ pub fn default_role_providers(role: Role) -> &'static [&'static str] { match role { Role::Search => &[ "exa", "brave", "tavily", "parallel", "querit", "seltz", "searxng", "tinyfish", - "keenable", "gemini", + "keenable", "serply", "gemini", ], Role::Answer => &["gemini", "gemini_deep_research", "exa", "parallel"], Role::Contents => &["exa", "tavily", "parallel", "tinyfish", "keenable"], @@ -48,6 +48,7 @@ pub fn role_provider_tool(role: Role, provider: &str) -> Option<&'static str> { (Role::Search, "tinyfish") => "tinyfish_search", (Role::Search, "parallel") => "parallel_search", (Role::Search, "keenable") => "keenable_search", + (Role::Search, "serply") => "serply_search", (Role::Answer, "gemini_deep_research") => "gemini_deep_research", (Role::Answer, "exa") => "exa_answer", (Role::Answer, "parallel") => "parallel_chat", diff --git a/crates/tinysearch/src/provider/direct/mod.rs b/crates/tinysearch/src/provider/direct/mod.rs index 1e8c7b7..f759cbc 100644 --- a/crates/tinysearch/src/provider/direct/mod.rs +++ b/crates/tinysearch/src/provider/direct/mod.rs @@ -16,6 +16,7 @@ mod parallel; mod querit; mod searxng; mod seltz; +mod serply; mod tavily; mod tinyfish; @@ -111,6 +112,7 @@ fn normalize_response( let value = match provider { "brave" => brave::unwrap(request, &value), "querit" => querit::unwrap(&value)?, + "serply" => serply::unwrap(request, &value), "tavily" => tavily::unwrap(request, value)?, "seltz" => seltz::unwrap( &value, @@ -175,6 +177,7 @@ pub(super) fn prepare( "querit" => querit::prepare(&request, key), "tavily" => tavily::prepare(&request, key), "seltz" => seltz::prepare(&request, key), + "serply" => serply::prepare(&request, key), "tinyfish" => tinyfish::prepare(&request, key), _ => Err(Error::UnavailableProvider(provider.into())), } diff --git a/crates/tinysearch/src/provider/direct/serply.rs b/crates/tinysearch/src/provider/direct/serply.rs new file mode 100644 index 0000000..d37ec82 --- /dev/null +++ b/crates/tinysearch/src/provider/direct/serply.rs @@ -0,0 +1,63 @@ +//! The `serply` provider, reached directly with the user's own key. +//! +//! Search is `GET https://api.serply.io/v1/search?q=...&num=...` with an +//! `X-Api-Key` header (), returning +//! `{results: [{title, link, description, position, ...}], ...}`. +use super::{Prepared, count, required_string}; +use crate::{Error, ExecuteToolRequest, Result}; +use reqwest::Method; +use serde_json::{Value, json}; +use std::time::Duration; + +/// Serply returns at most ten organic results per request. +const MAX_PAGE: u64 = 10; + +pub(super) fn prepare(request: &ExecuteToolRequest, key: &str) -> Result { + if request.name != "serply_search" { + return Err(Error::UnavailableTool(request.name.clone())); + } + let args = &request.arguments; + let mut params = vec![ + ("q", required_string(args, "query")?.to_owned()), + ("num", count(args, "max_results").min(MAX_PAGE).to_string()), + ]; + if let Some(country) = args.get("country").and_then(Value::as_str) { + params.push(("gl", country.into())); + } + Ok(( + Method::GET, + "https://api.serply.io/v1", + "/search".into(), + None, + params, + ("X-Api-Key", key.into()), + Duration::from_secs(35), + )) +} + +/// Maps Serply's organic `results` (`link`, `description`) onto the shared +/// result fields. `num` is an upper bound that a full page can exceed, so the +/// list is trimmed to the requested count. +pub(super) fn unwrap(request: &ExecuteToolRequest, value: &Value) -> Value { + let limit = usize::try_from(count(&request.arguments, "max_results")).unwrap_or(20); + let results: Vec = value + .get("results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| { + let link = item.get("link")?.as_str()?; + Some(json!({ + "title":item.get("title"), + "url":link, + "description":item.get("description"), + })) + }) + .take(limit) + .collect(); + json!({"results":results}) +} + +#[cfg(test)] +#[path = "serply/serply_tests.rs"] +mod test; diff --git a/crates/tinysearch/src/provider/direct/serply/serply_tests.rs b/crates/tinysearch/src/provider/direct/serply/serply_tests.rs new file mode 100644 index 0000000..f9fb538 --- /dev/null +++ b/crates/tinysearch/src/provider/direct/serply/serply_tests.rs @@ -0,0 +1,128 @@ +use super::*; +use crate::ProviderConfig; +use reqwest::Client; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; + +type TestResult = std::result::Result>; + +/// Serves one canned reply and returns the raw request it received. +async fn mock( + status: u16, + body: 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 stream, _) = listener.accept().await?; + let mut data = vec![0; 8192]; + let mut used = 0; + while !data[..used].windows(4).any(|w| w == b"\r\n\r\n") { + let read = stream.read(&mut data[used..]).await?; + if read == 0 { + break; + } + used += read; + } + 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(String::from_utf8_lossy(&data[..used]).into_owned()) + }); + Ok((url, task)) +} + +fn request(name: &str, arguments: Value) -> ExecuteToolRequest { + ExecuteToolRequest { + name: name.into(), + arguments, + } +} + +fn config(base_url: String) -> ProviderConfig { + ProviderConfig { + base_url: Some(base_url), + credential: Some("s-secret".into()), + ..ProviderConfig::default() + } +} + +#[tokio::test] +async fn search_sends_the_key_header_and_maps_organic_results() -> TestResult<()> { + let (url, server) = mock( + 200, + json!({"results":[ + {"title":"Rust","link":"https://rust.test","description":"Systems language","position":1}, + {"title":"No link","description":"dropped"}, + {"title":"Book","link":"https://book.test","description":"The book","position":2}, + {"title":"Extra","link":"https://extra.test","description":"Past the limit","position":3} + ],"ads":[{"link":"https://ad.test"}]}), + ) + .await?; + let result = super::super::run( + &Client::new(), + "serply", + &config(url), + &request( + "serply_search", + json!({"query":"rust","max_results":2,"country":"de"}), + ), + ) + .await?; + let sent = server.await??; + assert!(sent.starts_with("GET /search?q=rust&num=2&gl=de ")); + assert!(sent.to_ascii_lowercase().contains("x-api-key: s-secret")); + assert_eq!(result.provider, "serply"); + let urls: Vec<&str> = result.results.iter().map(|r| r.url.as_str()).collect(); + assert_eq!(urls, ["https://rust.test", "https://book.test"]); + assert_eq!(result.results[0].title, "Rust"); + assert_eq!( + result.results[0].snippet.as_deref(), + Some("Systems language") + ); + Ok(()) +} + +#[tokio::test] +async fn rejected_key_is_unauthorized() -> TestResult<()> { + let (url, server) = mock(401, json!({"detail":"Invalid API key"})).await?; + let error = super::super::run( + &Client::new(), + "serply", + &config(url), + &request("serply_search", json!({"query":"rust"})), + ) + .await + .err() + .ok_or("expected provider error")?; + server.await??; + assert_eq!(error, Error::Unauthorized(401)); + Ok(()) +} + +#[test] +fn page_size_is_capped_and_invalid_requests_are_rejected() -> TestResult<()> { + let prepared = prepare( + &request("serply_search", json!({"query":"rust","max_results":20})), + "s-secret", + )?; + assert_eq!(prepared.4, [("q", "rust".into()), ("num", "10".into())]); + assert_eq!( + prepare(&request("serply_search", json!({"query":" "})), "s-secret") + .err() + .ok_or("expected provider error")?, + Error::InvalidArguments + ); + assert_eq!( + prepare(&request("serply_news", json!({"query":"rust"})), "s-secret") + .err() + .ok_or("expected provider error")?, + Error::UnavailableTool("serply_news".into()) + ); + Ok(()) +} diff --git a/crates/tinysearch/src/provider/mod.rs b/crates/tinysearch/src/provider/mod.rs index 71b6421..42ede10 100644 --- a/crates/tinysearch/src/provider/mod.rs +++ b/crates/tinysearch/src/provider/mod.rs @@ -114,7 +114,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" | "keenable" => { + | "tinyfish" | "keenable" | "serply" => { return direct::run(&self.client, self.name, config, request).await; } "gemini" => return self.gemini(config, backend, request, role).await,