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
3 changes: 2 additions & 1 deletion MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<code>: <message>`; see `tinysearch_bus::errors`.

The typed wire contract, version 2.0, is in `tinysearch-bus`.
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
21 changes: 21 additions & 0 deletions crates/tinysearch-bus/src/search/catalog/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub const PROVIDERS: &[&str] = &[
"tavily",
"seltz",
"searxng",
"keenable",
];

/// Providers that support [`ProviderRoute::Backend`] through the managed
Expand Down Expand Up @@ -179,13 +180,28 @@ 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 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()),
("brave".into(), brave),
("querit".into(), querit),
("tavily".into(), tavily),
("seltz".into(), seltz),
("keenable".into(), keenable),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority critical critique likely

Add the missing external catalog tests

The new Keenable provider specifications are registered without adding the external catalog test file required by the repository's test layout. If the existing module declaration references that file, this leaves the crate unable to compile; even if it is not referenced yet, the new wire contracts and in-memory bus behavior are untested. Add the referenced <module>_tests.rs file and cover the new provider schemas and calls.

[RULE] missing-external-test ·

]
.into()
}
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority high critique confident

Require authorization before enabling direct Keenable access

This makes every explicitly configured direct Keenable provider active even when explicit.credential is absent. A caller can therefore invoke Keenable's direct tools without authorization, contrary to the credential gate used for keyed direct providers; a public endpoint or a credential that merely raises limits does not establish that the host is authorized to spend its quota. Gate this route on the configured credential, or otherwise require the authorization mechanism used by the Keenable provider before returning true.


Additional security observation

priority high confident

Require authorization before enabling direct Keenable access

[RULE] authorization-bypass

This makes any explicitly enabled direct Keenable provider available without checking a credential or another authorization capability, unlike the other keyed direct providers. Because the catalog controls which provider tools callers can invoke, an enabled configuration can expose Keenable's network-backed search and fetch operations without an authorization gate. Require the appropriate host authorization or credential before returning these tools.

[RULE] missing-authorization ·

ProviderRoute::Direct => {
KEYED_DIRECT_PROVIDERS.contains(&name.as_str())
&& non_empty(explicit.credential.as_deref())
Expand Down
22 changes: 18 additions & 4 deletions crates/tinysearch-bus/src/search/catalog/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ fn catalog_and_selection_are_stable() {
"exa",
"gemini",
"gemini_deep_research",
"keenable",
"parallel",
"querit",
"searxng",
Expand All @@ -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]
Expand Down Expand Up @@ -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!(
Expand All @@ -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,
Expand All @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions crates/tinysearch-bus/src/search/catalog/roles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
_ => &[],
}
Expand All @@ -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"],
}
}

Expand All @@ -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",
Expand All @@ -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,
})
}
Expand Down
186 changes: 186 additions & 0 deletions crates/tinysearch/src/provider/direct/keenable.rs
Original file line number Diff line number Diff line change
@@ -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<ExecuteToolResponse> {
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<String> {
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<Value> {
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> = 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<Value> {
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<Value> {
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)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority critical critique confident

Add the referenced external test file

Rust will try to include keenable/keenable_tests.rs when compiling tests, but that file is not present in the complete change shown. As a result, cargo test and test-target builds fail with a missing-module-file error. Add the referenced sibling test file to the pull request, or remove the module declaration if tests are intentionally omitted.

[RULE] missing-module-file ·

#[path = "keenable/keenable_tests.rs"]
mod test;
Loading
Loading