use crate::Tool;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::net::ToSocketAddrs;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::sync::RwLock;
pub enum WebSearchBackend {
Firecrawl { key: String },
Exa { key: String },
}
impl WebSearchBackend {
fn key(&self) -> &str {
match self {
Self::Firecrawl { key } | Self::Exa { key } => key,
}
}
}
#[derive(Serialize)]
struct ScrapeOptions {
formats: Vec<String>,
#[serde(rename = "onlyMainContent")]
only_main_content: bool,
}
#[derive(Serialize)]
struct SearchRequest {
query: String,
limit: usize,
#[serde(rename = "scrapeOptions")]
scrape_options: ScrapeOptions,
#[serde(skip_serializing_if = "Option::is_none")]
sources: Option<Vec<SearchSource>>,
}
#[derive(Serialize)]
struct SearchSource {
#[serde(rename = "type")]
kind: &'static str,
}
#[derive(Deserialize, Debug)]
struct WebResult {
title: Option<String>,
url: String,
markdown: Option<String>,
description: Option<String>,
}
#[derive(Deserialize, Debug)]
struct ImageResult {
#[serde(default, rename = "imageUrl")]
image_url: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default, rename = "imageWidth")]
width: Option<u32>,
#[serde(default, rename = "imageHeight")]
height: Option<u32>,
}
#[derive(Deserialize, Debug)]
struct SearchData {
#[serde(default, deserialize_with = "null_as_default")]
web: Vec<WebResult>,
#[serde(default, deserialize_with = "null_as_default")]
images: Vec<ImageResult>,
}
fn null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de> + Default,
{
Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
}
#[derive(Deserialize, Debug)]
struct SearchResponse {
success: bool,
data: SearchData,
}
#[derive(Serialize)]
struct ExaContentsOptions {
text: bool,
#[serde(skip_serializing_if = "Option::is_none")]
extras: Option<ExaExtrasOptions>,
}
#[derive(Serialize)]
struct ExaExtrasOptions {
#[serde(rename = "imageLinks")]
image_links: usize,
#[serde(rename = "richImageLinks")]
rich_image_links: usize,
}
const EXA_SEARCH_MODE: &str = "lite";
const EXA_PICTURE_SEARCH_MODE: &str = "auto";
const PICTURES_PER_RESULT: usize = 3;
#[derive(Serialize)]
struct ExaSearchBody {
query: String,
#[serde(rename = "type")]
search_type: &'static str,
#[serde(rename = "numResults")]
num_results: usize,
contents: ExaContentsOptions,
}
#[derive(Deserialize, Debug)]
struct ExaImageLink {
#[serde(default)]
url: Option<String>,
#[serde(default)]
alt: Option<String>,
}
#[derive(Deserialize, Debug, Default)]
struct ExaExtras {
#[serde(
default,
rename = "richImageLinks",
deserialize_with = "null_as_default"
)]
rich_image_links: Vec<ExaImageLink>,
}
#[derive(Deserialize, Debug)]
struct ExaResult {
title: Option<String>,
url: String,
text: Option<String>,
#[serde(default, deserialize_with = "null_as_default")]
extras: ExaExtras,
}
#[derive(Deserialize, Debug)]
struct ExaSearchResponse {
results: Vec<ExaResult>,
}
struct SearchRow {
title: Option<String>,
url: String,
text: Option<String>,
description: Option<String>,
}
struct SearchAnswer {
rows: Vec<SearchRow>,
pictures: Pictures,
}
struct Picture {
url: String,
description: String,
page: String,
dims: Option<(u32, u32)>,
}
enum Pictures {
NotRequested,
Unavailable,
Usable(Vec<Picture>),
}
const MIN_PICTURE_WIDTH: u32 = 200;
const MIN_PICTURE_HEIGHT: u32 = 150;
const FURNITURE_URL_TOKENS: &[&str] = &[
"logo",
"logos",
"icon",
"icons",
"favicon",
"sprite",
"sprites",
"avatar",
"avatars",
"gravatar",
"placeholder",
"blank",
"spacer",
"transparent",
"pixel",
"beacon",
"analytics",
"adsct",
"tracking",
"badge",
"social",
"og",
"opengraph",
"twitter",
];
const FURNITURE_TEXT_TOKENS: &[&str] = &[
"logo",
"logos",
"icon",
"icons",
"avatar",
"avatars",
"placeholder",
"signature",
"sprite",
"watermark",
"badge",
"twitter",
"opengraph",
];
fn tokens(text: &str) -> impl Iterator<Item = String> + '_ {
text.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|token| !token.is_empty())
.map(str::to_ascii_lowercase)
}
fn describes_furniture(text: &str) -> bool {
tokens(text).any(|token| FURNITURE_TEXT_TOKENS.contains(&token.as_str()))
}
fn candidate_is_plausible(url: &str, description: &str) -> bool {
let Ok(parsed) = reqwest::Url::parse(url.trim()) else {
return false;
};
if !matches!(parsed.scheme(), "http" | "https") {
return false;
}
let without_query = url.split(['#', '?']).next().unwrap_or(url);
if tokens(without_query).any(|token| FURNITURE_URL_TOKENS.contains(&token.as_str())) {
return false;
}
let path = parsed.path().to_ascii_lowercase();
if path.contains("wiki/file:") || path.contains("special:") {
return false;
}
let file = path.rsplit('/').next().unwrap_or_default();
if file
.rsplit_once('.')
.is_some_and(|(_, extension)| !is_picture_extension(extension))
{
return false;
}
let description = description.trim();
if description.is_empty() {
return false;
}
if description.contains('/')
|| description
.rsplit_once('.')
.is_some_and(|(_, extension)| is_picture_extension(extension))
{
return false;
}
!describes_furniture(description)
}
fn is_picture_extension(extension: &str) -> bool {
image::ImageFormat::from_extension(extension.trim())
.is_some_and(|format| crate::util::image_format_native_label(format).is_some())
}
const PICTURE_PROBE_BYTES: usize = 64 * 1024;
const PICTURE_PROBE_TIMEOUT_SECS: u64 = 10;
fn probe_http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
crate::util::http::build_no_redirect_http_client(Duration::from_secs(
PICTURE_PROBE_TIMEOUT_SECS,
))
})
}
async fn probe_allowed(url: &str) -> bool {
let Ok(parsed) = reqwest::Url::parse(url) else {
return false;
};
if !matches!(parsed.scheme(), "http" | "https") {
return false;
}
let Some(host) = parsed.host_str() else {
return false;
};
let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
if let Ok(address) = host.parse::<std::net::IpAddr>() {
return is_public_ip(address);
}
if host == "localhost"
|| [".localhost", ".local", ".internal"]
.iter()
.any(|suffix| host.ends_with(suffix))
{
return false;
}
let port = parsed.port_or_known_default().unwrap_or(443);
let Ok(resolved) = tokio::task::spawn_blocking(move || {
(host.as_str(), port)
.to_socket_addrs()
.map(|addresses| addresses.map(|address| address.ip()).collect::<Vec<_>>())
})
.await
else {
return false;
};
resolved.is_ok_and(|addresses| {
!addresses.is_empty() && addresses.iter().all(|&address| is_public_ip(address))
})
}
fn is_public_ip(address: std::net::IpAddr) -> bool {
match address {
std::net::IpAddr::V4(v4) => {
!(v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_multicast()
|| v4.is_broadcast()
|| v4.is_documentation())
}
std::net::IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => is_public_ip(std::net::IpAddr::V4(v4)),
None => {
!(v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_unspecified()
|| v6.is_multicast())
}
},
}
}
const PICTURE_PROBE_HOPS: usize = 3;
async fn probe_response(url: &str) -> Option<reqwest::Response> {
let client = probe_http_client();
let mut target = reqwest::Url::parse(url).ok()?;
for _ in 0..=PICTURE_PROBE_HOPS {
if !probe_allowed(target.as_str()).await {
return None;
}
let response = client
.get(target.clone())
.header(
reqwest::header::RANGE,
format!("bytes=0-{}", PICTURE_PROBE_BYTES - 1),
)
.send()
.await
.ok()?;
if !response.status().is_redirection() {
return Some(response);
}
let location = response
.headers()
.get(reqwest::header::LOCATION)?
.to_str()
.ok()?;
target = target.join(location).ok()?;
}
None
}
async fn probe_picture_dimensions(url: &str) -> Option<(u32, u32)> {
let mut response = probe_response(url).await?;
let is_image = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|content_type| {
content_type
.trim_start()
.to_ascii_lowercase()
.starts_with("image/")
});
if !response.status().is_success() || !is_image {
return None;
}
let mut bytes = Vec::with_capacity(PICTURE_PROBE_BYTES);
while let Some(chunk) = response.chunk().await.ok().flatten() {
if chunk.is_empty() {
break;
}
bytes.extend_from_slice(&chunk);
if bytes.len() >= PICTURE_PROBE_BYTES {
break;
}
}
let format = image::guess_format(&bytes).ok()?;
image::ImageReader::with_format(std::io::Cursor::new(&bytes), format)
.into_dimensions()
.ok()
}
async fn verify_picture(picture: Picture) -> Option<Picture> {
let (width, height) = match picture.dims {
Some(dims) => dims,
None => probe_picture_dimensions(&picture.url).await?,
};
(width >= MIN_PICTURE_WIDTH && height >= MIN_PICTURE_HEIGHT).then_some(picture)
}
async fn first_verified(group: Vec<Picture>) -> Option<Picture> {
for picture in group {
if let Some(picture) = verify_picture(picture).await {
return Some(picture);
}
}
None
}
async fn verified_pictures(groups: Vec<Vec<Picture>>, cap: usize) -> Vec<Picture> {
let mut seen = HashSet::new();
futures_util::future::join_all(groups.into_iter().map(first_verified))
.await
.into_iter()
.flatten()
.filter(|picture| seen.insert(picture.url.clone()))
.take(cap)
.collect()
}
#[derive(Debug, Clone)]
pub(crate) struct CachedResult {
pub(crate) title: String,
pub(crate) url: String,
pub(crate) text: String,
}
pub(crate) struct WebSearchCache {
cache: RwLock<HashMap<u64, CachedResult>>,
next_id: AtomicU64,
}
impl WebSearchCache {
pub(crate) fn new() -> Self {
Self {
cache: RwLock::new(HashMap::new()),
next_id: AtomicU64::new(1),
}
}
pub(crate) async fn cache_result(&self, title: String, url: String, text: String) -> u64 {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
self.cache
.write()
.await
.insert(id, CachedResult { title, url, text });
id
}
pub(crate) async fn expand_result(&self, id: u64) -> anyhow::Result<String> {
let cache_guard = self.cache.read().await;
let cached = cache_guard.get(&id).ok_or_else(|| {
anyhow::anyhow!(
"No cached result with expand id: {id}. This id does not exist \
in the current session's cache (it may be from a previous search \
in a different context). Use `web_search` with `query` parameter \
to run a new search first."
)
})?;
let text = if cached.text.is_empty() {
"No full text available for this result.".to_string()
} else {
cached.text.clone()
};
Ok(format!(
"# {}\n\n**URL:** {}\n\n{}",
cached.title, cached.url, text
))
}
pub(crate) fn next_counter(&self) -> u64 {
self.next_id.load(Ordering::Relaxed)
}
}
pub(crate) async fn check_search_error(
response: reqwest::Response,
) -> anyhow::Result<reqwest::Response> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status();
let body = crate::util::http::read_error_body(response, "search").await;
if let Some(error_msg) = search_error_detail(&body) {
anyhow::bail!("search failed: {error_msg}");
}
anyhow::bail!("search failed with status {status}: {body}");
}
#[must_use]
fn search_error_detail(body: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| crate::util::extract_provider_error_detail(&v))
}
impl WebSearchCache {
pub(crate) fn validate_execute_args<'a>(
&self,
args: &'a Value,
) -> anyhow::Result<(&'a str, Option<u64>)> {
let query = super::get_opt_str_strict(args, "query")?.map(str::trim);
let expand_id = super::get_opt_u64(args, "expand")?;
match (query, expand_id) {
(Some(_), Some(_)) => {
anyhow::bail!(
"The web_search tool accepts either `query` (to run a new search) \
or `expand` (to expand a cached result), but not both at the same \
time. Please use one or the other."
);
}
(None, None) => {
anyhow::bail!(
"Missing required argument. Provide either `query` (to search \
the web) or `expand` (to expand a previous result by id)."
);
}
(Some(query), None) => {
if query.chars().count() <= 2 {
anyhow::bail!(
"Search query must be at least 3 characters long. \
Got: \"{query}\" ({} chars)",
query.chars().count()
);
}
Ok((query, None))
}
(None, Some(id)) => {
if id >= self.next_counter() {
anyhow::bail!(
"Invalid expand id: {id}. No search has been run yet in this \
session, or the id exceeds any generated value. Use `web_search` \
with `query` parameter to run a new search first.",
);
}
Ok(("", Some(id)))
}
}
}
}
pub struct WebSearchTool {
backend: WebSearchBackend,
cache: WebSearchCache,
}
impl WebSearchTool {
#[must_use]
pub fn new(backend: WebSearchBackend) -> Self {
Self {
backend,
cache: WebSearchCache::new(),
}
}
async fn backend_answer(&self, query: &str, pictures: bool) -> anyhow::Result<SearchAnswer> {
match &self.backend {
WebSearchBackend::Firecrawl { .. } => self.firecrawl_answer(query, pictures).await,
WebSearchBackend::Exa { .. } => self.exa_answer(query, pictures).await,
}
}
async fn search(&self, query: &str, pictures: bool) -> anyhow::Result<SearchAnswer> {
match self.backend_answer(query, pictures).await {
Ok(answer) => Ok(answer),
Err(error) if pictures => {
tracing::warn!(%error, "picture search failed; retrying as text only");
let mut answer = self.backend_answer(query, false).await?;
answer.pictures = Pictures::Unavailable;
Ok(answer)
}
Err(error) => Err(error),
}
}
async fn firecrawl_answer(&self, query: &str, pictures: bool) -> anyhow::Result<SearchAnswer> {
let key = self.backend.key();
let res = crate::util::http::media_http_client()
.post("https://api.firecrawl.dev/v2/search")
.header("Authorization", format!("Bearer {key}"))
.json(&search_request_body(query, pictures))
.send()
.await?;
let res = check_search_error(res).await?;
let response = res.json::<SearchResponse>().await?;
if !response.success {
return Ok(SearchAnswer {
rows: Vec::new(),
pictures: if pictures {
Pictures::Unavailable
} else {
Pictures::NotRequested
},
});
}
let rows = firecrawl_rows(response.data.web);
let pictures = if pictures {
Pictures::Usable(
verified_pictures(firecrawl_candidates(response.data.images), rows.len()).await,
)
} else {
Pictures::NotRequested
};
Ok(SearchAnswer { rows, pictures })
}
async fn exa_answer(&self, query: &str, pictures: bool) -> anyhow::Result<SearchAnswer> {
let key = self.backend.key();
let res = crate::util::http::media_http_client()
.post("https://api.exa.ai/search")
.header("x-api-key", key)
.json(&exa_request_body(query, pictures))
.send()
.await?;
let res = check_search_error(res).await?;
let response = res.json::<ExaSearchResponse>().await?;
let mut groups = Vec::new();
let mut rows = Vec::with_capacity(response.results.len());
for result in response.results {
if pictures && let Some(group) = exa_picture_group(&result) {
groups.push(group);
}
rows.push(SearchRow {
title: result.title,
url: result.url,
text: result.text,
description: None,
});
}
let pictures = if pictures {
Pictures::Usable(verified_pictures(groups, rows.len()).await)
} else {
Pictures::NotRequested
};
Ok(SearchAnswer { rows, pictures })
}
async fn format_search_output(
&self,
query: &str,
rows: &[SearchRow],
pictures: Pictures,
) -> String {
let mut lines = Vec::new();
if rows.is_empty() {
lines.push(format!("No results found for: {query}"));
} else {
lines.push(format!("Search results for: {query}"));
for (i, result) in rows.iter().enumerate() {
let title = result.title.as_deref().unwrap_or("(no title)").to_string();
let id = self
.cache
.cache_result(
title.clone(),
result.url.clone(),
result.text.clone().unwrap_or_default(),
)
.await;
lines.push(format!("{}. [{title}]({url})", i + 1, url = result.url));
if let Some(description) = result.description.as_deref().filter(|d| !d.is_empty()) {
lines.push(format!(" > {description}"));
}
lines.push(format!(" `expand` id: `{id}`"));
}
}
let mut output = if matches!(pictures, Pictures::NotRequested) {
lines.join("\n")
} else {
crate::util::scrub_credentials(&lines.join("\n"))
};
if let Some(section) = picture_section(&pictures) {
output.push_str("\n\n");
output.push_str(§ion);
}
output
}
}
fn picture_section(pictures: &Pictures) -> Option<String> {
match pictures {
Pictures::NotRequested => None,
Pictures::Unavailable => Some("Pictures unavailable for this query.".to_string()),
Pictures::Usable(found) if found.is_empty() => {
Some("No usable pictures for this query.".to_string())
}
Pictures::Usable(found) => {
let mut lines = vec!["Pictures:".to_string()];
lines.extend(found.iter().enumerate().map(|(i, picture)| {
format!(
"{}. {} — \"{}\" (from {})",
i + 1,
picture.url,
picture.description,
picture.page
)
}));
Some(lines.join("\n"))
}
}
}
fn search_request_body(query: &str, pictures: bool) -> SearchRequest {
SearchRequest {
query: query.to_string(),
limit: 10,
scrape_options: ScrapeOptions {
formats: vec!["markdown".to_string()],
only_main_content: true,
},
sources: pictures.then(|| {
vec![
SearchSource { kind: "web" },
SearchSource { kind: "images" },
]
}),
}
}
fn exa_request_body(query: &str, pictures: bool) -> ExaSearchBody {
ExaSearchBody {
query: query.to_string(),
search_type: if pictures {
EXA_PICTURE_SEARCH_MODE
} else {
EXA_SEARCH_MODE
},
num_results: 10,
contents: ExaContentsOptions {
text: true,
extras: pictures.then_some(ExaExtrasOptions {
image_links: PICTURES_PER_RESULT,
rich_image_links: PICTURES_PER_RESULT,
}),
},
}
}
fn firecrawl_rows(web: Vec<WebResult>) -> Vec<SearchRow> {
web.into_iter()
.map(|r| SearchRow {
title: r.title,
url: r.url,
text: r.markdown,
description: r.description,
})
.collect()
}
fn firecrawl_candidates(images: Vec<ImageResult>) -> Vec<Vec<Picture>> {
images
.into_iter()
.filter_map(|image| {
let url = image.image_url.as_deref()?.trim().to_string();
let page = image.url.as_deref()?.trim().to_string();
if page.is_empty() {
return None;
}
let description = image
.title
.as_deref()
.unwrap_or_default()
.trim()
.to_string();
let dims = image.width.zip(image.height);
let big_enough = dims.is_none_or(|(width, height)| {
width >= MIN_PICTURE_WIDTH && height >= MIN_PICTURE_HEIGHT
});
(big_enough && candidate_is_plausible(&url, &description)).then(|| {
vec![Picture {
url,
description,
page,
dims,
}]
})
})
.collect()
}
fn exa_picture_group(result: &ExaResult) -> Option<Vec<Picture>> {
let links = &result.extras.rich_image_links;
let page = result.url.trim();
if page.is_empty() {
return None;
}
let furniture: HashSet<&str> = links
.iter()
.filter(|link| link.alt.as_deref().is_some_and(describes_furniture))
.filter_map(|link| link.url.as_deref())
.collect();
let group: Vec<Picture> = links
.iter()
.filter_map(|link| {
let url = link.url.as_deref()?.trim();
let description = link.alt.as_deref().unwrap_or_default().trim();
(!furniture.contains(url) && candidate_is_plausible(url, description)).then(|| {
Picture {
url: url.to_string(),
description: description.to_string(),
page: page.to_string(),
dims: None,
}
})
})
.collect();
(!group.is_empty()).then_some(group)
}
#[async_trait]
impl Tool for WebSearchTool {
fn name(&self) -> &'static str {
"web_search"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The web search query. Mutually exclusive with `expand` — pass exactly one per call."
},
"expand": {
"type": "integer",
"description": "Expand ID of a result from a previous search in this same session; retrieves its full cached text instead of running a new search."
},
"pictures": {
"type": "boolean",
"description": "Also list pictures of the results' subjects, each with its address and hosting page."
}
},
"oneOf": [
{"required": ["query"]},
{"required": ["expand"]}
]
})
}
fn preserve_full_output(&self) -> bool {
true
}
fn side_effects(&self) -> bool {
false }
fn should_scrub_output(&self, args: &serde_json::Value) -> bool {
!pictures_requested(args)
}
async fn execute(
&self,
_ws: &crate::Workspace,
args: serde_json::Value,
) -> anyhow::Result<String> {
let (query, expand_id) = self.cache.validate_execute_args(&args)?;
let pictures = super::get_opt_bool(&args, "pictures")?.unwrap_or(false);
if let Some(id) = expand_id {
tracing::debug!("Expanding result id: {}", id);
return self.cache.expand_result(id).await;
}
let answer = self.search(query, pictures).await?;
Ok(self
.format_search_output(query, &answer.rows, answer.pictures)
.await)
}
}
fn pictures_requested(args: &serde_json::Value) -> bool {
let expanding = !matches!(args.get("expand"), None | Some(Value::Null));
!expanding && super::get_opt_bool(args, "pictures").ok().flatten() == Some(true)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_validate_execute_args() {
let cache = WebSearchCache::new();
let cases: &[(&str, Value, Option<&str>, Option<&str>)] = &[
("no_args", json!({}), Some("either `query`"), None),
(
"both_args",
json!({"query": "test", "expand": 1}),
Some("not both"),
None,
),
(
"query_too_short",
json!({"query": "ab"}),
Some("at least 3"),
None,
),
(
"expand_invalid_future_id",
json!({"expand": 999_999_999}),
Some("Invalid expand id"),
None,
),
(
"expand_wrong_type",
json!({"expand": "1"}),
Some("usage: argument \"expand\" must be a non-negative integer"),
None,
),
(
"query_wrong_type",
json!({"query": 42}),
Some("usage: argument \"query\" must be a string"),
None,
),
(
"good_query",
json!({"query": "rust programming"}),
None,
Some("rust programming"),
),
];
for (name, args, err_contains, ok_query) in cases {
let name = *name;
match (err_contains, ok_query) {
(Some(expected), _) => {
let err = cache.validate_execute_args(args).unwrap_err().to_string();
assert!(
err.contains(*expected),
"case: {name} — error should contain `{expected}`, got: {err}"
);
}
(None, Some(expected_query)) => {
let (query, expand_id) = cache.validate_execute_args(args).unwrap();
assert_eq!(query, *expected_query, "case: {name}");
assert!(expand_id.is_none(), "case: {name} — expected no expand_id");
}
(None, None) => unreachable!("case: {name}"),
}
}
}
#[test]
fn test_search_error_detail_string_and_nested_envelope() {
assert_eq!(
search_error_detail(r#"{"success":false,"error":"upstream down"}"#).as_deref(),
Some("upstream down")
);
assert_eq!(
search_error_detail(r#"{"error":{"message":"invalid API key"}}"#).as_deref(),
Some("invalid API key")
);
assert_eq!(
search_error_detail(
r#"{"error":{"code":"rate_limited","metadata":{"raw":"too many requests"}}}"#
)
.as_deref(),
Some("too many requests")
);
assert_eq!(search_error_detail("plain text"), None);
assert_eq!(search_error_detail(""), None);
}
#[tokio::test]
async fn test_cache_and_expand_roundtrip() {
let cache = WebSearchCache::new();
let id = cache
.cache_result(
"Test Title".to_string(),
"https://example.com".to_string(),
"Full text content".to_string(),
)
.await;
assert_eq!(id, 1, "first cached result should get id 1");
let id2 = cache
.cache_result(
"Second".to_string(),
"https://example.org".to_string(),
"More content".to_string(),
)
.await;
assert_eq!(id2, 2, "second cached result should get id 2");
let expanded = cache.expand_result(1).await.unwrap();
assert!(expanded.contains("Test Title"));
assert!(expanded.contains("https://example.com"));
assert!(expanded.contains("Full text content"));
let expanded2 = cache.expand_result(2).await.unwrap();
assert!(expanded2.contains("Second"));
assert!(expanded2.contains("More content"));
}
#[tokio::test]
async fn test_expand_missing_id() {
let cache = WebSearchCache::new();
let result = cache.expand_result(42).await;
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("No cached result"),
"Error should mention no cached result"
);
}
#[test]
fn test_candidate_is_plausible() {
let rejected = [
(
"https://en.wikipedia.org/wiki/File:Barack_Obama.jpg",
"A portrait of Barack Obama",
),
("https://t.co/1/i/adsct?event=x", "tracking pixel"),
("https://cdn.example.com/gallery/icon2.svg", "headphones"),
(
"https://secure.gravatar.com/avatar/8d63b097f5",
"Christian Thomas",
),
(
"https://rkd.nl/",
"Logo of RKD — Netherlands Institute for Art History",
),
(
"https://example.com/articles/2026/story.html",
"A red-eyed tree frog",
),
("https://example.com/media/hero.jpg", ""),
(
"https://example.com/media/_images/cords.jpg",
"_images/cords.jpg",
),
("https://example.com/og-image-1200x630.png", "Some article"),
(
"https://cdn.example.com/media/9f3a.png",
"Twitter card thumbnail",
),
("", "A red-eyed tree frog"),
(" ", "A red-eyed tree frog"),
("/media/frog.jpg", "A red-eyed tree frog"),
("data:image/png;base64,AAAA", "A red-eyed tree frog"),
];
for (url, description) in rejected {
assert!(
!candidate_is_plausible(url, description),
"should reject {url} ({description:?})"
);
}
let accepted = [
(
"https://i.natgeofe.com/n/f6ca1b91/red-eyed-tree-frog.JPG?w=374&h=249",
"a red-eyed tree frog",
),
(
"https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Hero-Desktop",
"Model 3 Performance",
),
(
"https://cdn.mos.cms.futurecdn.net/oK4kKiMEXRjrM9MyS72Ygf.jpg",
"Sony WH-1000XM5 headphones next to the Bose QC45",
),
("https://cdn.example.com/v2.1/hero", "Model 3 front"),
(
"https://cdn.example.com/hero.jpg?utm_source=social",
"Model 3 front",
),
];
for (url, description) in accepted {
assert!(
candidate_is_plausible(url, description),
"should accept {url} ({description:?})"
);
}
}
#[tokio::test]
async fn test_probe_allowed_rejects_non_web_and_private_hosts() {
for url in [
"https://93.184.216.34/hero.jpg",
"http://8.8.8.8/hero.jpg",
"https://[2606:4700:4700::1111]/hero.jpg",
] {
assert!(probe_allowed(url).await, "should allow {url}");
}
for url in [
"file:///etc/passwd",
"ftp://example.com/hero.jpg",
"http://localhost:8080/hero.jpg",
"http://127.0.0.1/hero.jpg",
"http://192.168.1.10/hero.jpg",
"http://169.254.169.254/latest/meta-data/",
"http://172.16.0.1/hero.jpg",
"http://[::1]/hero.jpg",
"http://[::ffff:127.0.0.1]/hero.jpg",
"http://[::ffff:169.254.169.254]/hero.jpg",
"http://[fd00::1]/hero.jpg",
"http://[fe80::1]/hero.jpg",
"http://printer.local/hero.jpg",
"http://metadata.internal/hero.jpg",
"no-scheme.example.com/hero.jpg",
] {
assert!(!probe_allowed(url).await, "should reject {url}");
}
}
#[test]
fn test_request_bodies_gate_pictures() {
let plain = serde_json::to_value(search_request_body("frogs", false)).unwrap();
assert!(
plain.get("sources").is_none(),
"unflagged Firecrawl body: {plain}"
);
assert!(
plain.get("scrapeOptions").is_some(),
"scraping must stay: {plain}"
);
let flagged = serde_json::to_value(search_request_body("frogs", true)).unwrap();
assert_eq!(
flagged["sources"],
json!([{"type": "web"}, {"type": "images"}])
);
let plain = serde_json::to_value(exa_request_body("frogs", false)).unwrap();
assert_eq!(plain["type"], EXA_SEARCH_MODE);
assert!(
plain["contents"].get("extras").is_none(),
"unflagged Exa body: {plain}"
);
let flagged = serde_json::to_value(exa_request_body("frogs", true)).unwrap();
assert_eq!(flagged["type"], EXA_PICTURE_SEARCH_MODE);
assert_eq!(
flagged["contents"]["extras"],
json!({"imageLinks": PICTURES_PER_RESULT, "richImageLinks": PICTURES_PER_RESULT})
);
}
#[test]
fn test_firecrawl_candidates_filter() {
let image =
|url: &str, title: &str, width: Option<u32>, height: Option<u32>, page: &str| {
ImageResult {
image_url: Some(url.to_string()),
url: Some(page.to_string()),
title: Some(title.to_string()),
width,
height,
}
};
let candidates = firecrawl_candidates(vec![
image(
"https://site.test/logo.png",
"Site logo",
Some(80),
Some(80),
"https://a.test",
),
image(
"https://img.test/9f3a.jpg",
"Jane Doe",
Some(96),
Some(96),
"https://a.test",
),
image(
"https://site.test/pixel.gif",
"Pixel",
None,
None,
"https://a.test",
),
ImageResult {
image_url: Some("https://img.test/frog.jpg".to_string()),
url: None,
title: Some("Red-eyed tree frog".to_string()),
width: Some(1600),
height: Some(900),
},
image(
"https://img.test/frog-hero.jpg",
"Red-eyed tree frog",
Some(1600),
Some(900),
"https://a.test",
),
]);
assert_eq!(candidates.len(), 1);
let picture = &candidates[0][0];
assert_eq!(picture.url, "https://img.test/frog-hero.jpg");
assert_eq!(picture.page, "https://a.test");
assert_eq!(picture.dims, Some((1600, 900)));
}
#[tokio::test]
async fn test_verified_pictures_takes_first_per_group_and_caps() {
let picture = |url: &str, (width, height): (u32, u32)| Picture {
url: url.to_string(),
description: "a red-eyed tree frog".to_string(),
page: "https://site.test/page".to_string(),
dims: Some((width, height)),
};
let groups = vec![
vec![
picture("https://img.test/small.jpg", (120, 90)),
picture("https://img.test/frog.jpg", (1200, 800)),
],
vec![picture("https://img.test/other.jpg", (800, 600))],
vec![picture("https://img.test/third.jpg", (800, 600))],
];
let pictures = verified_pictures(groups, 2).await;
assert_eq!(pictures.len(), 2, "capped at the text-result count");
assert_eq!(pictures[0].url, "https://img.test/frog.jpg");
assert_eq!(pictures[1].url, "https://img.test/other.jpg");
}
#[test]
fn test_picture_lists_survive_nulls() {
let firecrawl: SearchResponse = serde_json::from_str(
r#"{"success":true,"data":{"web":null,"images":[
{"imageUrl":null,"url":null,"title":null,"imageWidth":null,"imageHeight":null}
]}}"#,
)
.unwrap();
assert_eq!(firecrawl.data.images.len(), 1);
assert!(firecrawl.data.images[0].image_url.is_none());
let exa: ExaSearchResponse = serde_json::from_str(
r#"{"results":[{"title":null,"url":"https://site.test/page","text":null,
"extras":{"richImageLinks":[{"url":null,"alt":null}]}}]}"#,
)
.unwrap();
let links = &exa.results[0].extras.rich_image_links;
assert_eq!(links.len(), 1);
assert!(links[0].url.is_none());
let exa: ExaSearchResponse = serde_json::from_str(
r#"{"results":[{"title":"t","url":"https://site.test","text":null,"extras":null}]}"#,
)
.unwrap();
assert!(exa.results[0].extras.rich_image_links.is_empty());
}
#[test]
fn test_exa_picture_group_skips_an_address_labelled_as_furniture() {
let result = ExaResult {
title: Some("Headphones".to_string()),
url: "https://site.test/headphones".to_string(),
text: None,
extras: ExaExtras {
rich_image_links: vec![
ExaImageLink {
url: Some("https://site.test/default-guide.png".to_string()),
alt: Some("Sony WH-1000XM5".to_string()),
},
ExaImageLink {
url: Some("https://site.test/default-guide.png".to_string()),
alt: Some("Site default logo image".to_string()),
},
ExaImageLink {
url: Some("https://img.test/xm5.jpg".to_string()),
alt: Some("Sony WH-1000XM5 headphones".to_string()),
},
ExaImageLink {
url: Some(String::new()),
alt: None,
},
ExaImageLink {
url: None,
alt: None,
},
],
},
};
let group = exa_picture_group(&result).expect("a usable candidate");
assert_eq!(group.len(), 1);
assert_eq!(group[0].url, "https://img.test/xm5.jpg");
assert_eq!(group[0].page, "https://site.test/headphones");
assert_eq!(group[0].description, "Sony WH-1000XM5 headphones");
assert!(group[0].dims.is_none(), "Exa reports no dimensions");
}
#[test]
fn test_scrub_policy_covers_picture_calls_only() {
let tool = WebSearchTool::new(WebSearchBackend::Exa { key: String::new() });
assert!(!tool.should_scrub_output(&json!({"query": "frogs", "pictures": true})));
assert!(tool.should_scrub_output(&json!({"query": "frogs"})));
assert!(tool.should_scrub_output(&json!({"query": "frogs", "pictures": null})));
assert!(tool.should_scrub_output(&json!({"expand": 3, "pictures": true})));
assert!(
!tool.should_scrub_output(&json!({"query": "frogs", "pictures": true, "expand": null}))
);
}
#[tokio::test]
async fn test_picture_section_keeps_addresses_intact() {
let tool = WebSearchTool::new(WebSearchBackend::Exa {
key: "test-key".to_string(),
});
let rows = vec![SearchRow {
title: Some("Signed images".to_string()),
url: "https://site.test/page".to_string(),
text: None,
description: Some("key api_key=abcdefgh12345678".to_string()),
}];
let signed = "https://cdn.test/frog.jpg?token=abcdefgh12345678";
let output = tool
.format_search_output(
"frogs",
&rows,
Pictures::Usable(vec![Picture {
url: signed.to_string(),
description: "a red-eyed tree frog".to_string(),
page: "https://site.test/page".to_string(),
dims: None,
}]),
)
.await;
assert!(output.contains("*[REDACTED]"), "{output}");
assert!(output.contains(signed), "{output}");
assert!(output.contains("(from https://site.test/page)"), "{output}");
assert_eq!(tool.cache.next_counter(), 2, "{output}");
assert_eq!(
output.matches("`expand` id: `").count(),
1,
"one expand id for one text result: {output}"
);
}
#[tokio::test]
async fn test_picture_section_reports_empty_and_refused_separately() {
let tool = WebSearchTool::new(WebSearchBackend::Exa {
key: "test-key".to_string(),
});
let rows = vec![SearchRow {
title: Some("Result".to_string()),
url: "https://site.test/page".to_string(),
text: None,
description: None,
}];
let empty = tool
.format_search_output("frogs", &rows, Pictures::Usable(Vec::new()))
.await;
assert!(
empty.ends_with("No usable pictures for this query."),
"{empty}"
);
let refused = tool
.format_search_output("frogs", &rows, Pictures::Unavailable)
.await;
assert!(
refused.ends_with("Pictures unavailable for this query."),
"{refused}"
);
let no_rows = tool
.format_search_output("frogs", &[], Pictures::Usable(Vec::new()))
.await;
assert_eq!(
no_rows,
"No results found for: frogs\n\nNo usable pictures for this query."
);
let no_rows_refused = tool
.format_search_output("frogs", &[], Pictures::Unavailable)
.await;
assert_eq!(
no_rows_refused,
"No results found for: frogs\n\nPictures unavailable for this query."
);
let plain = tool
.format_search_output("frogs", &rows, Pictures::NotRequested)
.await;
assert!(
!plain.contains("ictures"),
"unflagged output is unchanged: {plain}"
);
}
}