Skip to main content

mermaid_cli/providers/tool/
web_client.rs

1use crate::utils::{RetryConfig, retry_async_if};
2use anyhow::{Result, anyhow};
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use std::time::Duration;
6
7/// Result from a web search
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SearchResult {
10    pub title: String,
11    pub url: String,
12    pub snippet: String,
13    pub full_content: String,
14}
15
16/// Result from a web fetch
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WebFetchResult {
19    pub title: String,
20    pub content: String,
21}
22
23/// Ollama web search API response
24#[derive(Debug, Deserialize)]
25struct OllamaSearchResponse {
26    results: Vec<OllamaSearchResult>,
27}
28
29#[derive(Debug, Deserialize)]
30struct OllamaSearchResult {
31    title: String,
32    url: String,
33    content: String,
34}
35
36/// Ollama web fetch API response
37#[derive(Debug, Deserialize)]
38struct OllamaFetchResponse {
39    title: Option<String>,
40    content: Option<String>,
41}
42
43const OLLAMA_API_BASE: &str = "https://ollama.com/api";
44
45/// Carries the HTTP status of a non-success Ollama API response so the retry
46/// classifier can tell retryable (5xx / 429) from terminal (4xx) responses
47/// without string-matching the error message (#85).
48#[derive(Debug)]
49struct HttpStatusError {
50    status: u16,
51}
52
53impl std::fmt::Display for HttpStatusError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        write!(f, "HTTP {}", self.status)
56    }
57}
58
59impl std::error::Error for HttpStatusError {}
60
61/// Retry only transient web-API failures: network timeout/connect errors and
62/// 5xx / 429 responses. Terminal 4xx (auth, bad request) and parse errors are
63/// surfaced immediately rather than retried `max_attempts` times (#85). The
64/// typed errors are found through anyhow's `.context()` layers via downcast.
65fn web_error_is_retryable(e: &anyhow::Error) -> bool {
66    if let Some(re) = e.downcast_ref::<reqwest::Error>() {
67        return re.is_timeout() || re.is_connect();
68    }
69    if let Some(h) = e.downcast_ref::<HttpStatusError>() {
70        return h.status == 429 || (500..600).contains(&h.status);
71    }
72    false
73}
74
75/// Web search client that uses Ollama's cloud API
76#[derive(Clone)]
77pub struct WebSearchClient {
78    client: Client,
79    api_key: String,
80}
81
82impl WebSearchClient {
83    pub fn new(api_key: String) -> Self {
84        Self {
85            client: Client::new(),
86            api_key,
87        }
88    }
89
90    /// Execute a search query
91    pub async fn search_query(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
92        self.search(query, count).await
93    }
94
95    /// Execute search via Ollama Cloud API
96    ///
97    /// The web_search API already returns full page content per result,
98    /// so no separate web_fetch calls are needed. Each result's content
99    /// is truncated to prevent context bloat.
100    async fn search(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
101        // Validate count
102        if count == 0 || count > 10 {
103            return Err(anyhow!(
104                "Result count must be between 1 and 10, got {}",
105                count
106            ));
107        }
108
109        // Query Ollama web search API with retry logic
110        let retry_config = RetryConfig {
111            max_attempts: 3,
112            initial_delay_ms: 500,
113            max_delay_ms: 5000,
114            backoff_multiplier: 2.0,
115        };
116
117        let client = self.client.clone();
118        let api_key = self.api_key.clone();
119        let query_owned = query.to_string();
120        // `count` is Copy (usize) — safe to capture by value across retries
121        let ollama_response: OllamaSearchResponse = retry_async_if(
122            || {
123                let client = client.clone();
124                let api_key = api_key.clone();
125                let query = query_owned.clone();
126                async move {
127                    let response = client
128                        .post(format!("{}/web_search", OLLAMA_API_BASE))
129                        .header("Authorization", format!("Bearer {}", api_key))
130                        .json(&serde_json::json!({
131                            "query": query,
132                            "max_results": count,
133                        }))
134                        .timeout(Duration::from_secs(30))
135                        .send()
136                        .await
137                        .map_err(|e| {
138                            anyhow::Error::new(e).context("Failed to reach Ollama web search API")
139                        })?;
140
141                    if !response.status().is_success() {
142                        let status = response.status();
143                        let body = response.text().await.unwrap_or_default();
144                        return Err(anyhow::Error::new(HttpStatusError {
145                            status: status.as_u16(),
146                        })
147                        .context(format!(
148                            "Ollama web search API returned error {}: {}",
149                            status, body
150                        )));
151                    }
152
153                    let body =
154                        read_body_capped(response, crate::constants::MAX_WEB_BODY_BYTES).await?;
155                    serde_json::from_slice::<OllamaSearchResponse>(&body)
156                        .map_err(|e| anyhow!("Failed to parse Ollama search response: {}", e))
157                }
158            },
159            &retry_config,
160            web_error_is_retryable,
161        )
162        .await?;
163
164        // The web_search API returns full page content in each result's content field.
165        // Truncate each to prevent context bloat.
166        let search_results: Vec<SearchResult> = ollama_response
167            .results
168            .iter()
169            .take(count)
170            .map(|result| {
171                let content = crate::utils::truncate_content(
172                    &result.content,
173                    crate::constants::WEB_CONTENT_MAX_CHARS,
174                );
175                SearchResult {
176                    title: result.title.clone(),
177                    url: result.url.clone(),
178                    snippet: result.content.chars().take(200).collect(),
179                    full_content: content,
180                }
181            })
182            .collect();
183
184        if search_results.is_empty() {
185            return Err(anyhow!("No search results found for: {}", query));
186        }
187
188        Ok(search_results)
189    }
190
191    /// Fetch a URL's content via Ollama's web_fetch API
192    pub async fn fetch_url(&self, url: &str) -> Result<WebFetchResult> {
193        // Retry config for page fetches (2 attempts, shorter timeout)
194        let retry_config = RetryConfig {
195            max_attempts: 2,
196            initial_delay_ms: 200,
197            max_delay_ms: 2000,
198            backoff_multiplier: 2.0,
199        };
200
201        let client = self.client.clone();
202        let api_key = self.api_key.clone();
203        let url_owned = url.to_string();
204        let response: OllamaFetchResponse = retry_async_if(
205            || {
206                let client = client.clone();
207                let api_key = api_key.clone();
208                let url = url_owned.clone();
209                async move {
210                    let response = client
211                        .post(format!("{}/web_fetch", OLLAMA_API_BASE))
212                        .header("Authorization", format!("Bearer {}", api_key))
213                        .json(&serde_json::json!({ "url": url }))
214                        .timeout(Duration::from_secs(15))
215                        .send()
216                        .await
217                        .map_err(|e| {
218                            anyhow::Error::new(e).context(format!("Failed to fetch {}", url))
219                        })?;
220
221                    if !response.status().is_success() {
222                        let status = response.status();
223                        return Err(anyhow::Error::new(HttpStatusError {
224                            status: status.as_u16(),
225                        })
226                        .context(format!("Failed to fetch {}", url)));
227                    }
228
229                    let body =
230                        read_body_capped(response, crate::constants::MAX_WEB_BODY_BYTES).await?;
231                    serde_json::from_slice::<OllamaFetchResponse>(&body)
232                        .map_err(|e| anyhow!("Failed to parse fetch response: {}", e))
233                }
234            },
235            &retry_config,
236            web_error_is_retryable,
237        )
238        .await?;
239
240        Ok(WebFetchResult {
241            title: response.title.unwrap_or_default(),
242            content: response.content.unwrap_or_default(),
243        })
244    }
245
246    /// Format search results for model consumption
247    ///
248    /// Pure data -- no behavioral instructions. Citation rules live in the
249    /// system prompt (src/prompts.rs), which is the SSOT for all model behavior.
250    pub fn format_results(&self, results: &[SearchResult]) -> String {
251        let mut formatted = String::from("[SEARCH_RESULTS]\n");
252
253        for (i, result) in results.iter().enumerate() {
254            formatted.push_str(&format!(
255                "[{}] Title: {}\nURL: {}\nContent:\n{}\n---\n",
256                i + 1,
257                result.title,
258                result.url,
259                result.full_content
260            ));
261        }
262
263        formatted.push_str("[/SEARCH_RESULTS]\n\n");
264
265        // Source list for citation (behavior governed by system prompt)
266        formatted.push_str("Sources:\n");
267        for (i, result) in results.iter().enumerate() {
268            formatted.push_str(&format!("{}. {} - {}\n", i + 1, result.title, result.url));
269        }
270
271        formatted
272    }
273}
274
275/// Read a reqwest response body, refusing to buffer more than `max_bytes`.
276/// `Response::json`/`bytes` buffer the whole body unbounded; a compromised or
277/// misconfigured Ollama endpoint could return a multi-gigabyte body and OOM the
278/// (long-lived) process. We reject early on an oversized `Content-Length` and
279/// also enforce the cap while streaming (a lying or absent header can't bypass
280/// it) (#28).
281async fn read_body_capped(response: reqwest::Response, max_bytes: usize) -> Result<Vec<u8>> {
282    use futures::StreamExt;
283    if let Some(len) = response.content_length()
284        && len as usize > max_bytes
285    {
286        return Err(anyhow!(
287            "response body too large: {len} bytes exceeds {max_bytes} cap"
288        ));
289    }
290    let mut stream = response.bytes_stream();
291    let mut buf = Vec::new();
292    while let Some(chunk) = stream.next().await {
293        let chunk = chunk.map_err(|e| anyhow!("error reading response body: {e}"))?;
294        if buf.len() + chunk.len() > max_bytes {
295            return Err(anyhow!("response body exceeded {max_bytes} byte cap"));
296        }
297        buf.extend_from_slice(&chunk);
298    }
299    Ok(buf)
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_web_search_client_creation() {
308        let client = WebSearchClient::new("test-key".to_string());
309        assert_eq!(client.api_key, "test-key");
310    }
311
312    #[test]
313    fn test_format_results() {
314        let client = WebSearchClient::new("test-key".to_string());
315        let results = vec![SearchResult {
316            title: "Test Article".to_string(),
317            url: "https://example.com".to_string(),
318            snippet: "This is a test".to_string(),
319            full_content: "Full content here".to_string(),
320        }];
321
322        let formatted = client.format_results(&results);
323        assert!(formatted.contains("[SEARCH_RESULTS]"));
324        assert!(formatted.contains("Test Article"));
325        assert!(formatted.contains("https://example.com"));
326        assert!(formatted.contains("[/SEARCH_RESULTS]"));
327    }
328
329    #[test]
330    fn web_error_is_retryable_classifies_status() {
331        // #85: 5xx / 429 retry; 4xx and untyped (parse) errors are terminal.
332        assert!(web_error_is_retryable(&anyhow::Error::new(
333            HttpStatusError { status: 500 }
334        )));
335        assert!(web_error_is_retryable(&anyhow::Error::new(
336            HttpStatusError { status: 429 }
337        )));
338        assert!(!web_error_is_retryable(&anyhow::Error::new(
339            HttpStatusError { status: 404 }
340        )));
341        assert!(!web_error_is_retryable(&anyhow::Error::new(
342            HttpStatusError { status: 401 }
343        )));
344        assert!(!web_error_is_retryable(&anyhow!("parse failed")));
345        // Production wraps the status error with .context(); downcast must still
346        // find it through the context layer.
347        let wrapped = anyhow::Error::new(HttpStatusError { status: 503 }).context("upstream");
348        assert!(web_error_is_retryable(&wrapped));
349    }
350}