use async_trait::async_trait;
use serde_json::json;
use super::{require_env, trim_trailing_slash, BackendResponse, SearchBackend, SearchResult};
use lc_core::tools::ToolError;
pub const EXA_BASE_URL: &str = "https://api.exa.ai/search";
pub const EXA_LABEL: &str = "exa";
const EXA_TEXT_MAX_CHARS: u32 = 1_000;
#[derive(Clone)]
pub struct ExaBackend {
api_key: String,
base_url: String,
client: reqwest::Client,
}
impl std::fmt::Debug for ExaBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExaBackend")
.field("api_key", &"<redacted>")
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
impl ExaBackend {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
base_url: EXA_BASE_URL.to_string(),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.user_agent("LangChainRust/0.22 (Exa Search)")
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
}
}
pub fn from_env() -> Result<Self, ToolError> {
Ok(Self::new(require_env("EXA_API_KEY")?))
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = trim_trailing_slash(base_url.into());
self
}
pub(crate) fn parse(body: &serde_json::Value, top_k: usize) -> BackendResponse {
let mut results = Vec::new();
if let Some(items) = body
.get("data")
.and_then(|d| d.get("results"))
.and_then(|v| v.as_array())
{
for item in items {
let Some(url) = item.get("url").and_then(|v| v.as_str()) else {
continue;
};
let score = item
.get("score")
.and_then(|v| v.as_f64())
.unwrap_or(0.0)
.clamp(0.0, 1.0);
results.push(SearchResult {
title: item
.get("title")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
url: url.to_string(),
snippet: item
.get("text")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
score,
published_date: item
.get("publishedDate")
.and_then(|v| v.as_str())
.map(str::to_string),
author: item.get("author").and_then(|v| match v {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
_ => None,
}),
provider: EXA_LABEL,
});
if results.len() >= top_k {
break;
}
}
}
BackendResponse {
results,
answer: None,
}
}
}
#[async_trait]
impl SearchBackend for ExaBackend {
fn label(&self) -> &'static str {
EXA_LABEL
}
async fn search(
&self,
query: &str,
top_k: usize,
_include_answer: bool,
) -> Result<BackendResponse, ToolError> {
let payload = json!({
"query": query,
"numResults": top_k,
"contents": {"text": {"maxCharacters": EXA_TEXT_MAX_CHARS}},
});
let response = self
.client
.post(&self.base_url)
.header("x-api-key", &self.api_key)
.json(&payload)
.send()
.await
.map_err(|e| ToolError::ExecutionFailed(format!("exa request failed: {e}")))?;
let status = response.status();
let body: serde_json::Value = response
.json()
.await
.map_err(|e| ToolError::ExecutionFailed(format!("exa response parse failed: {e}")))?;
if !status.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"exa returned HTTP {}: {}",
status.as_u16(),
body.to_string().chars().take(300).collect::<String>()
)));
}
Ok(Self::parse(&body, top_k))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hosted_search::test_support::{spawn_one_shot_json, ENV_LOCK};
use serde_json::json;
#[test]
fn parse_reads_data_envelope_and_metadata() {
let body = json!({"data": {"results": [
{"title":"A","url":"https://a.example","text":"excerpt","score":0.77,"publishedDate":"2026-02-10","author":"Lee"},
{"title":"B","url":"https://b.example","text":"","score":null},
{"title":"no-url"}
]}});
let parsed = ExaBackend::parse(&body, 10);
assert_eq!(parsed.results.len(), 2);
assert_eq!(parsed.results[0].snippet, "excerpt");
assert!((parsed.results[0].score - 0.77).abs() < 1e-9);
assert_eq!(
parsed.results[0].published_date.as_deref(),
Some("2026-02-10")
);
assert_eq!(parsed.results[0].author.as_deref(), Some("Lee"));
assert_eq!(parsed.results[0].provider, EXA_LABEL);
assert_eq!(parsed.results[1].score, 0.0);
assert!(parsed.results[1].author.is_none());
}
#[test]
fn parse_handles_envelope_without_results() {
assert!(ExaBackend::parse(&json!({"data": {}}), 5)
.results
.is_empty());
assert!(ExaBackend::parse(&json!({}), 5).results.is_empty());
}
#[tokio::test]
async fn http_call_requests_text_contents_and_sends_key() {
let reply = json!({"data": {"results": [
{"title":"A","url":"https://a.example","text":"t","score":0.5}
]}});
let (base, request_rx) = spawn_one_shot_json(reply).await;
let backend = ExaBackend::new("exa-secret").with_base_url(base);
let out = backend
.search("long-context memory survey", 2, false)
.await
.unwrap();
assert_eq!(out.results[0].url, "https://a.example");
let request = String::from_utf8(request_rx.await.unwrap()).unwrap();
let head = request.split("\r\n\r\n").next().unwrap().to_lowercase();
assert!(head.contains("post / http/1.1"));
assert!(head.contains("x-api-key: exa-secret"), "{head}");
let body = request.split("\r\n\r\n").nth(1).unwrap();
let sent: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(sent["numResults"], 2);
assert_eq!(
sent["contents"]["text"]["maxCharacters"],
EXA_TEXT_MAX_CHARS
);
}
#[test]
fn from_env_requires_key() {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var("EXA_API_KEY").ok();
std::env::remove_var("EXA_API_KEY");
let err = ExaBackend::from_env().unwrap_err();
assert!(err.to_string().contains("EXA_API_KEY"));
if let Some(value) = saved {
std::env::set_var("EXA_API_KEY", value);
}
}
}