lc_tools/hosted_search/
exa.rs1use async_trait::async_trait;
10use serde_json::json;
11
12use super::{require_env, trim_trailing_slash, BackendResponse, SearchBackend, SearchResult};
13use lc_core::tools::ToolError;
14
15pub const EXA_BASE_URL: &str = "https://api.exa.ai/search";
17pub const EXA_LABEL: &str = "exa";
19const EXA_TEXT_MAX_CHARS: u32 = 1_000;
21
22#[derive(Clone)]
24pub struct ExaBackend {
25 api_key: String,
26 base_url: String,
27 client: reqwest::Client,
28}
29
30impl std::fmt::Debug for ExaBackend {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.debug_struct("ExaBackend")
34 .field("api_key", &"<redacted>")
35 .field("base_url", &self.base_url)
36 .finish_non_exhaustive()
37 }
38}
39
40impl ExaBackend {
41 pub fn new(api_key: impl Into<String>) -> Self {
43 Self {
44 api_key: api_key.into(),
45 base_url: EXA_BASE_URL.to_string(),
46 client: reqwest::Client::builder()
47 .timeout(std::time::Duration::from_secs(20))
48 .user_agent("LangChainRust/0.22 (Exa Search)")
49 .build()
50 .unwrap_or_else(|_| reqwest::Client::new()),
51 }
52 }
53
54 pub fn from_env() -> Result<Self, ToolError> {
56 Ok(Self::new(require_env("EXA_API_KEY")?))
57 }
58
59 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
61 self.base_url = trim_trailing_slash(base_url.into());
62 self
63 }
64
65 pub(crate) fn parse(body: &serde_json::Value, top_k: usize) -> BackendResponse {
67 let mut results = Vec::new();
68 if let Some(items) = body
69 .get("data")
70 .and_then(|d| d.get("results"))
71 .and_then(|v| v.as_array())
72 {
73 for item in items {
74 let Some(url) = item.get("url").and_then(|v| v.as_str()) else {
75 continue;
76 };
77 let score = item
78 .get("score")
79 .and_then(|v| v.as_f64())
80 .unwrap_or(0.0)
81 .clamp(0.0, 1.0);
82 results.push(SearchResult {
83 title: item
84 .get("title")
85 .and_then(|v| v.as_str())
86 .unwrap_or_default()
87 .to_string(),
88 url: url.to_string(),
89 snippet: item
90 .get("text")
91 .and_then(|v| v.as_str())
92 .unwrap_or_default()
93 .to_string(),
94 score,
95 published_date: item
96 .get("publishedDate")
97 .and_then(|v| v.as_str())
98 .map(str::to_string),
99 author: item.get("author").and_then(|v| match v {
100 serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
101 _ => None,
102 }),
103 provider: EXA_LABEL,
104 });
105 if results.len() >= top_k {
106 break;
107 }
108 }
109 }
110 BackendResponse {
111 results,
112 answer: None,
113 }
114 }
115}
116
117#[async_trait]
118impl SearchBackend for ExaBackend {
119 fn label(&self) -> &'static str {
120 EXA_LABEL
121 }
122
123 async fn search(
124 &self,
125 query: &str,
126 top_k: usize,
127 _include_answer: bool,
128 ) -> Result<BackendResponse, ToolError> {
129 let payload = json!({
132 "query": query,
133 "numResults": top_k,
134 "contents": {"text": {"maxCharacters": EXA_TEXT_MAX_CHARS}},
135 });
136 let response = self
137 .client
138 .post(&self.base_url)
139 .header("x-api-key", &self.api_key)
140 .json(&payload)
141 .send()
142 .await
143 .map_err(|e| ToolError::ExecutionFailed(format!("exa request failed: {e}")))?;
144 let status = response.status();
145 let body: serde_json::Value = response
146 .json()
147 .await
148 .map_err(|e| ToolError::ExecutionFailed(format!("exa response parse failed: {e}")))?;
149 if !status.is_success() {
150 return Err(ToolError::ExecutionFailed(format!(
151 "exa returned HTTP {}: {}",
152 status.as_u16(),
153 body.to_string().chars().take(300).collect::<String>()
154 )));
155 }
156 Ok(Self::parse(&body, top_k))
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::hosted_search::test_support::{spawn_one_shot_json, ENV_LOCK};
164 use serde_json::json;
165
166 #[test]
167 fn parse_reads_data_envelope_and_metadata() {
168 let body = json!({"data": {"results": [
169 {"title":"A","url":"https://a.example","text":"excerpt","score":0.77,"publishedDate":"2026-02-10","author":"Lee"},
170 {"title":"B","url":"https://b.example","text":"","score":null},
171 {"title":"no-url"}
172 ]}});
173 let parsed = ExaBackend::parse(&body, 10);
174 assert_eq!(parsed.results.len(), 2);
175 assert_eq!(parsed.results[0].snippet, "excerpt");
176 assert!((parsed.results[0].score - 0.77).abs() < 1e-9);
177 assert_eq!(
178 parsed.results[0].published_date.as_deref(),
179 Some("2026-02-10")
180 );
181 assert_eq!(parsed.results[0].author.as_deref(), Some("Lee"));
182 assert_eq!(parsed.results[0].provider, EXA_LABEL);
183 assert_eq!(parsed.results[1].score, 0.0);
185 assert!(parsed.results[1].author.is_none());
187 }
188
189 #[test]
190 fn parse_handles_envelope_without_results() {
191 assert!(ExaBackend::parse(&json!({"data": {}}), 5)
192 .results
193 .is_empty());
194 assert!(ExaBackend::parse(&json!({}), 5).results.is_empty());
195 }
196
197 #[tokio::test]
198 async fn http_call_requests_text_contents_and_sends_key() {
199 let reply = json!({"data": {"results": [
200 {"title":"A","url":"https://a.example","text":"t","score":0.5}
201 ]}});
202 let (base, request_rx) = spawn_one_shot_json(reply).await;
203 let backend = ExaBackend::new("exa-secret").with_base_url(base);
204 let out = backend
205 .search("long-context memory survey", 2, false)
206 .await
207 .unwrap();
208 assert_eq!(out.results[0].url, "https://a.example");
209
210 let request = String::from_utf8(request_rx.await.unwrap()).unwrap();
211 let head = request.split("\r\n\r\n").next().unwrap().to_lowercase();
213 assert!(head.contains("post / http/1.1"));
214 assert!(head.contains("x-api-key: exa-secret"), "{head}");
215 let body = request.split("\r\n\r\n").nth(1).unwrap();
216 let sent: serde_json::Value = serde_json::from_str(body).unwrap();
217 assert_eq!(sent["numResults"], 2);
218 assert_eq!(
219 sent["contents"]["text"]["maxCharacters"],
220 EXA_TEXT_MAX_CHARS
221 );
222 }
223
224 #[test]
225 fn from_env_requires_key() {
226 let _guard = ENV_LOCK.lock().unwrap();
227 let saved = std::env::var("EXA_API_KEY").ok();
228 std::env::remove_var("EXA_API_KEY");
229 let err = ExaBackend::from_env().unwrap_err();
230 assert!(err.to_string().contains("EXA_API_KEY"));
231 if let Some(value) = saved {
232 std::env::set_var("EXA_API_KEY", value);
233 }
234 }
235}