Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/tinysearch-bus/src/labels/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub fn provider_label(provider: &str) -> Cow<'_, str> {
"seltz" => "Seltz",
"searxng" => "SearXNG",
"keenable" => "Keenable",
"serply" => "Serply",
other => other,
})
}
Expand Down
1 change: 1 addition & 0 deletions crates/tinysearch-bus/src/labels/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
9 changes: 9 additions & 0 deletions crates/tinysearch-bus/src/search/catalog/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub const PROVIDERS: &[&str] = &[
"seltz",
"searxng",
"keenable",
"serply",
];

/// Providers that support [`ProviderRoute::Backend`] through the managed
Expand All @@ -43,6 +44,7 @@ const KEYED_DIRECT_PROVIDERS: &[&str] = &[
"seltz",
"parallel",
"tinyfish",
"serply",
];

/// Gemini models the managed backend accepts for grounded generation.
Expand Down Expand Up @@ -180,6 +182,12 @@ fn direct_provider_specs() -> BTreeMap<String, Vec<ToolSpec>> {
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",
Expand All @@ -202,6 +210,7 @@ fn direct_provider_specs() -> BTreeMap<String, Vec<ToolSpec>> {
("tavily".into(), tavily),
("seltz".into(), seltz),
("keenable".into(), keenable),
("serply".into(), serply),
]
.into()
}
Expand Down
13 changes: 8 additions & 5 deletions crates/tinysearch-bus/src/search/catalog/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ fn catalog_and_selection_are_stable() {
"querit",
"searxng",
"seltz",
"serply",
"tavily",
"tinyfish"
]
Expand All @@ -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]
Expand Down Expand Up @@ -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!(
Expand All @@ -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.
Expand All @@ -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(" "));
}
Expand All @@ -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]
Expand Down
5 changes: 3 additions & 2 deletions crates/tinysearch-bus/src/search/catalog/roles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
_ => &[],
}
}
Expand All @@ -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"],
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions crates/tinysearch/src/provider/direct/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod parallel;
mod querit;
mod searxng;
mod seltz;
mod serply;
mod tavily;
mod tinyfish;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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())),
}
Expand Down
63 changes: 63 additions & 0 deletions crates/tinysearch/src/provider/direct/serply.rs
Original file line number Diff line number Diff line change
@@ -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 (<https://serply.io/docs>), 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<Prepared> {
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> = 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;
128 changes: 128 additions & 0 deletions crates/tinysearch/src/provider/direct/serply/serply_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
use super::*;
use crate::ProviderConfig;
use reqwest::Client;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};

type TestResult<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;

/// Serves one canned reply and returns the raw request it received.
async fn mock(
status: u16,
body: Value,
) -> TestResult<(String, tokio::task::JoinHandle<TestResult<String>>)> {
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(())
}
2 changes: 1 addition & 1 deletion crates/tinysearch/src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading