Skip to main content

imp_core/tools/web/
search.rs

1//! Search provider implementations — Tavily, Exa, Linkup, Perplexity.
2//!
3//! Each provider hits its own HTTP API and maps results to a common
4//! `SearchResponse` type. Credentials can come from environment variables
5//! or imp's persisted auth store (`~/.config/imp/auth.json`).
6
7use imp_llm::auth::AuthStore;
8#[cfg(test)]
9use imp_llm::auth::StoredCredential;
10use reqwest::Client;
11use serde_json::{json, Value};
12use std::path::Path;
13
14use super::types::{SearchProvider, SearchResponse, SearchResult};
15
16/// Execute a search against the given provider.
17pub async fn search(
18    client: &Client,
19    provider: SearchProvider,
20    query: &str,
21    max_results: usize,
22) -> Result<SearchResponse, SearchError> {
23    let api_key = resolve_api_key(provider, std::env::var(provider.env_key_name()).ok(), None)?;
24
25    let response = match provider {
26        SearchProvider::Tavily => tavily_search(client, &api_key, query, max_results).await,
27        SearchProvider::Exa => exa_search(client, &api_key, query, max_results).await,
28        SearchProvider::Linkup => linkup_search(client, &api_key, query, max_results).await,
29        SearchProvider::Perplexity => perplexity_search(client, &api_key, query, max_results).await,
30    }?;
31
32    Ok(response)
33}
34
35// ── credential resolution ──────────────────────────────────────────
36
37fn resolve_api_key(
38    provider: SearchProvider,
39    env_value: Option<String>,
40    auth_path: Option<&Path>,
41) -> Result<String, SearchError> {
42    if let Some(key) = env_value.filter(|value| !value.trim().is_empty()) {
43        return Ok(key);
44    }
45
46    let auth_path = auth_path
47        .map(Path::to_path_buf)
48        .or_else(crate::storage::existing_global_auth_path)
49        .unwrap_or_else(crate::storage::global_auth_path);
50    let auth_store = AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path));
51
52    auth_store
53        .resolve_api_key_only(provider.name())
54        .map_err(|_| SearchError::MissingApiKey(provider))
55}
56
57// ── tavily ──────────────────────────────────────────────────────────
58
59async fn tavily_search(
60    client: &Client,
61    api_key: &str,
62    query: &str,
63    max_results: usize,
64) -> Result<SearchResponse, SearchError> {
65    let body = json!({
66        "api_key": api_key,
67        "query": query,
68        "search_depth": "basic",
69        "include_answer": true,
70        "max_results": max_results.min(10),
71    });
72
73    let resp = client
74        .post("https://api.tavily.com/search")
75        .json(&body)
76        .send()
77        .await
78        .map_err(|e| SearchError::Request(e.to_string()))?;
79
80    let status = resp.status();
81    let data: Value = resp
82        .json()
83        .await
84        .map_err(|e| SearchError::Parse(e.to_string()))?;
85
86    if !status.is_success() {
87        return Err(SearchError::Api(format!(
88            "Tavily {status}: {}",
89            data.get("detail")
90                .or(data.get("error"))
91                .and_then(Value::as_str)
92                .unwrap_or("unknown error")
93        )));
94    }
95
96    let answer = data.get("answer").and_then(Value::as_str).map(String::from);
97    let results = data
98        .get("results")
99        .and_then(Value::as_array)
100        .map(|arr| {
101            arr.iter()
102                .map(|r| SearchResult {
103                    title: r["title"].as_str().unwrap_or("").to_string(),
104                    url: r["url"].as_str().unwrap_or("").to_string(),
105                    snippet: r["content"].as_str().map(String::from),
106                    date: None,
107                })
108                .collect()
109        })
110        .unwrap_or_default();
111
112    Ok(SearchResponse {
113        results,
114        answer,
115        provider: SearchProvider::Tavily,
116    })
117}
118
119// ── exa ─────────────────────────────────────────────────────────────
120
121async fn exa_search(
122    client: &Client,
123    api_key: &str,
124    query: &str,
125    max_results: usize,
126) -> Result<SearchResponse, SearchError> {
127    let body = json!({
128        "query": query,
129        "numResults": max_results.min(20),
130        "type": "auto",
131    });
132
133    let resp = client
134        .post("https://api.exa.ai/search")
135        .header("x-api-key", api_key)
136        .json(&body)
137        .send()
138        .await
139        .map_err(|e| SearchError::Request(e.to_string()))?;
140
141    let status = resp.status();
142    let data: Value = resp
143        .json()
144        .await
145        .map_err(|e| SearchError::Parse(e.to_string()))?;
146
147    if !status.is_success() {
148        return Err(SearchError::Api(format!(
149            "Exa {status}: {}",
150            data.get("error")
151                .and_then(Value::as_str)
152                .unwrap_or("unknown error")
153        )));
154    }
155
156    let results = data
157        .get("results")
158        .and_then(Value::as_array)
159        .map(|arr| {
160            arr.iter()
161                .map(|r| SearchResult {
162                    title: r["title"].as_str().unwrap_or("").to_string(),
163                    url: r["url"].as_str().unwrap_or("").to_string(),
164                    snippet: r["text"].as_str().map(|t| truncate(t, 500)),
165                    date: r["publishedDate"].as_str().map(String::from),
166                })
167                .collect()
168        })
169        .unwrap_or_default();
170
171    Ok(SearchResponse {
172        results,
173        answer: None,
174        provider: SearchProvider::Exa,
175    })
176}
177
178// ── linkup ──────────────────────────────────────────────────────────
179
180async fn linkup_search(
181    client: &Client,
182    api_key: &str,
183    query: &str,
184    max_results: usize,
185) -> Result<SearchResponse, SearchError> {
186    let body = json!({
187        "q": query,
188        "depth": "standard",
189        "outputType": "sourcedAnswer",
190        "includeSources": true,
191        "maxResults": max_results.min(10),
192    });
193
194    let resp = client
195        .post("https://api.linkup.so/v1/search")
196        .bearer_auth(api_key)
197        .json(&body)
198        .send()
199        .await
200        .map_err(|e| SearchError::Request(e.to_string()))?;
201
202    let status = resp.status();
203    let data: Value = resp
204        .json()
205        .await
206        .map_err(|e| SearchError::Parse(e.to_string()))?;
207
208    if !status.is_success() {
209        return Err(SearchError::Api(format!(
210            "Linkup {status}: {}",
211            data.get("error")
212                .or(data.get("message"))
213                .and_then(Value::as_str)
214                .unwrap_or("unknown error")
215        )));
216    }
217
218    let answer = data.get("answer").and_then(Value::as_str).map(String::from);
219    let results = data
220        .get("sources")
221        .and_then(Value::as_array)
222        .map(|arr| {
223            arr.iter()
224                .map(|r| SearchResult {
225                    title: r["name"].as_str().unwrap_or("").to_string(),
226                    url: r["url"].as_str().unwrap_or("").to_string(),
227                    snippet: r["snippet"].as_str().map(String::from),
228                    date: None,
229                })
230                .collect()
231        })
232        .unwrap_or_default();
233
234    Ok(SearchResponse {
235        results,
236        answer,
237        provider: SearchProvider::Linkup,
238    })
239}
240
241// ── perplexity ──────────────────────────────────────────────────────
242
243async fn perplexity_search(
244    client: &Client,
245    api_key: &str,
246    query: &str,
247    max_results: usize,
248) -> Result<SearchResponse, SearchError> {
249    let body = json!({
250        "query": query,
251        "max_results": max_results.min(20),
252    });
253
254    let resp = client
255        .post("https://api.perplexity.ai/search")
256        .bearer_auth(api_key)
257        .header("Content-Type", "application/json")
258        .json(&body)
259        .send()
260        .await
261        .map_err(|e| SearchError::Request(e.to_string()))?;
262
263    let status = resp.status();
264    let data: Value = resp
265        .json()
266        .await
267        .map_err(|e| SearchError::Parse(e.to_string()))?;
268
269    if !status.is_success() {
270        return Err(SearchError::Api(format!(
271            "Perplexity {status}: {}",
272            data.get("error")
273                .or(data.get("detail"))
274                .and_then(Value::as_str)
275                .unwrap_or("unknown error")
276        )));
277    }
278
279    let results = data
280        .get("results")
281        .and_then(Value::as_array)
282        .map(|arr| {
283            arr.iter()
284                .map(|r| SearchResult {
285                    title: r["title"].as_str().unwrap_or("").to_string(),
286                    url: r["url"].as_str().unwrap_or("").to_string(),
287                    snippet: r["snippet"].as_str().map(String::from),
288                    date: r["date"].as_str().map(String::from),
289                })
290                .collect()
291        })
292        .unwrap_or_default();
293
294    Ok(SearchResponse {
295        results,
296        answer: None,
297        provider: SearchProvider::Perplexity,
298    })
299}
300
301// ── helpers ─────────────────────────────────────────────────────────
302
303fn truncate(s: &str, max_chars: usize) -> String {
304    if s.len() <= max_chars {
305        s.to_string()
306    } else {
307        let truncated: String = s.chars().take(max_chars).collect();
308        format!("{truncated}...")
309    }
310}
311
312#[derive(Debug)]
313pub enum SearchError {
314    MissingApiKey(SearchProvider),
315    Request(String),
316    Api(String),
317    Parse(String),
318}
319
320impl std::fmt::Display for SearchError {
321    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322        match self {
323            Self::MissingApiKey(provider) => write!(
324                f,
325                "{} not set. Run `imp login {}` or set {} in your environment.",
326                provider.env_key_name(),
327                provider.name(),
328                provider.env_key_name()
329            ),
330            Self::Request(msg) => write!(f, "Request failed: {msg}"),
331            Self::Api(msg) => write!(f, "API error: {msg}"),
332            Self::Parse(msg) => write!(f, "Failed to parse response: {msg}"),
333        }
334    }
335}
336
337impl std::error::Error for SearchError {}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use tempfile::tempdir;
343
344    #[test]
345    fn resolve_api_key_uses_explicit_env_value() {
346        let key =
347            resolve_api_key(SearchProvider::Exa, Some("exa-env-key".to_string()), None).unwrap();
348
349        assert_eq!(key, "exa-env-key");
350    }
351
352    #[test]
353    fn resolve_api_key_reads_imp_auth_store() {
354        let dir = tempdir().unwrap();
355        let auth_path = dir.path().join("auth.json");
356        let mut auth_store = AuthStore::new(auth_path.clone());
357        auth_store
358            .store(
359                SearchProvider::Tavily.name(),
360                StoredCredential::ApiKey {
361                    key: "tvly-saved-key".to_string(),
362                },
363            )
364            .unwrap();
365
366        let key = resolve_api_key(SearchProvider::Tavily, None, Some(&auth_path)).unwrap();
367        assert_eq!(key, "tvly-saved-key");
368    }
369
370    #[test]
371    fn resolve_api_key_missing_reports_provider() {
372        let dir = tempdir().unwrap();
373        let auth_path = dir.path().join("auth.json");
374        let err = resolve_api_key(SearchProvider::Exa, None, Some(&auth_path)).unwrap_err();
375        let msg = err.to_string();
376        assert!(msg.contains("EXA_API_KEY"));
377        assert!(msg.contains("imp login exa"));
378    }
379}