Skip to main content

lc_tools/hosted_search/
exa.rs

1// lc-tools/src/hosted_search/exa.rs
2//! Exa neural search backend (B6, v0.22.4).
3//!
4//! API reference: `POST https://api.exa.ai/search` with `x-api-key`, JSON
5//! body `{query, numResults, contents: {text: true}}`. Hits live under
6//! `data.results` with native `score` in `0..=1`, `publishedDate`, `author`
7//! and the excerpt in `text`.
8
9use 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
15/// Exa API endpoint.
16pub const EXA_BASE_URL: &str = "https://api.exa.ai/search";
17/// Backend label used in tool names, output and logs.
18pub const EXA_LABEL: &str = "exa";
19/// Maximum characters of page text requested per hit.
20const EXA_TEXT_MAX_CHARS: u32 = 1_000;
21
22/// Exa neural search backend.
23#[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        // Never log the API key (traces / error reports may capture Debug).
33        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    /// Creates the backend with an explicit API key.
42    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    /// Creates the backend from `EXA_API_KEY`.
55    pub fn from_env() -> Result<Self, ToolError> {
56        Ok(Self::new(require_env("EXA_API_KEY")?))
57    }
58
59    /// Overrides the endpoint (tests / proxies).
60    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    /// Pure response parser (`data.results` envelope).
66    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        // contents.text asks Exa to return the cleaned page excerpt alongside
130        // metadata; without it snippets would always be empty.
131        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        // Missing/None score normalizes to 0 rather than NaN.
184        assert_eq!(parsed.results[1].score, 0.0);
185        // Empty-string author normalizes to None.
186        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        // HTTP header names are case-insensitive; normalize before asserting.
212        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}