Skip to main content

mermaid_cli/providers/tool/
web_client.rs

1use crate::utils::{RetryConfig, classify_host, retry_async_if, truncate_content};
2use anyhow::{Result, anyhow};
3use async_trait::async_trait;
4use reqwest::Client;
5use serde::{Deserialize, Serialize};
6use std::time::Duration;
7
8/// Result from a web search
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SearchResult {
11    pub title: String,
12    pub url: String,
13    pub snippet: String,
14    pub full_content: String,
15}
16
17/// Result from a web fetch
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct WebFetchResult {
20    pub title: String,
21    pub content: String,
22}
23
24/// A `web_search` backend: a query maps to ranked results. Implemented by
25/// [`OllamaWebClient`] (Ollama Cloud) and [`SearxngClient`] (self-hosted).
26#[async_trait]
27pub trait SearchProvider: Send + Sync {
28    async fn search(&self, query: &str, count: usize) -> Result<Vec<SearchResult>>;
29}
30
31/// A `web_fetch` backend: a URL maps to readable page content. Implemented by
32/// [`NativeFetchClient`] (in-process fetch) and [`OllamaWebClient`] (Ollama
33/// Cloud's server-side fetch).
34#[async_trait]
35pub trait FetchProvider: Send + Sync {
36    async fn fetch(&self, url: &str) -> Result<WebFetchResult>;
37}
38
39/// Ollama web search API response
40#[derive(Debug, Deserialize)]
41struct OllamaSearchResponse {
42    results: Vec<OllamaSearchResult>,
43}
44
45#[derive(Debug, Deserialize)]
46struct OllamaSearchResult {
47    title: String,
48    url: String,
49    content: String,
50}
51
52/// Ollama web fetch API response
53#[derive(Debug, Deserialize)]
54struct OllamaFetchResponse {
55    title: Option<String>,
56    content: Option<String>,
57}
58
59/// SearXNG JSON search response (`/search?format=json`). Only the fields we use
60/// are modelled; the rest of the (large) payload is ignored.
61#[derive(Debug, Deserialize)]
62struct SearxngResponse {
63    #[serde(default)]
64    results: Vec<SearxngResult>,
65}
66
67#[derive(Debug, Deserialize)]
68struct SearxngResult {
69    #[serde(default)]
70    title: String,
71    url: String,
72    #[serde(default)]
73    content: String,
74}
75
76const OLLAMA_API_BASE: &str = "https://ollama.com/api";
77
78/// User-Agent for native (`fetch_backend = "native"`) fetches. Some sites 403 a
79/// blank UA; a descriptive one is polite and identifies the client.
80const NATIVE_FETCH_UA: &str =
81    "Mozilla/5.0 (compatible; MermaidBot/1.0; +https://github.com/noahsabaj/mermaid-cli)";
82
83/// Carries the HTTP status of a non-success response so the retry classifier can
84/// tell retryable (5xx / 429) from terminal (4xx) responses without
85/// string-matching the error message (#85).
86#[derive(Debug)]
87struct HttpStatusError {
88    status: u16,
89}
90
91impl std::fmt::Display for HttpStatusError {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(f, "HTTP {}", self.status)
94    }
95}
96
97impl std::error::Error for HttpStatusError {}
98
99/// Retry only transient web-API failures: network timeout/connect errors and
100/// 5xx / 429 responses. Terminal 4xx (auth, bad request) and parse errors are
101/// surfaced immediately rather than retried `max_attempts` times (#85). The
102/// typed errors are found through anyhow's `.context()` layers via downcast.
103fn web_error_is_retryable(e: &anyhow::Error) -> bool {
104    if let Some(re) = e.downcast_ref::<reqwest::Error>() {
105        return re.is_timeout() || re.is_connect();
106    }
107    if let Some(h) = e.downcast_ref::<HttpStatusError>() {
108        return h.status == 429 || (500..600).contains(&h.status);
109    }
110    false
111}
112
113/// Web client backed by Ollama Cloud's `/api/web_search` + `/api/web_fetch`,
114/// authenticated with a bearer token (`OLLAMA_API_KEY`).
115#[derive(Clone)]
116pub struct OllamaWebClient {
117    client: Client,
118    api_key: String,
119}
120
121impl OllamaWebClient {
122    pub fn new(api_key: String) -> Self {
123        Self {
124            client: Client::new(),
125            api_key,
126        }
127    }
128
129    /// Execute search via Ollama Cloud API.
130    ///
131    /// The web_search API already returns full page content per result, so no
132    /// separate web_fetch calls are needed. Each result's content is truncated
133    /// to prevent context bloat.
134    async fn search_impl(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
135        if count == 0 || count > 10 {
136            return Err(anyhow!(
137                "Result count must be between 1 and 10, got {}",
138                count
139            ));
140        }
141
142        let retry_config = RetryConfig {
143            max_attempts: 3,
144            initial_delay_ms: 500,
145            max_delay_ms: 5000,
146            backoff_multiplier: 2.0,
147        };
148
149        let client = self.client.clone();
150        let api_key = self.api_key.clone();
151        let query_owned = query.to_string();
152        // `count` is Copy (usize) — safe to capture by value across retries
153        let ollama_response: OllamaSearchResponse = retry_async_if(
154            || {
155                let client = client.clone();
156                let api_key = api_key.clone();
157                let query = query_owned.clone();
158                async move {
159                    let response = client
160                        .post(format!("{}/web_search", OLLAMA_API_BASE))
161                        .header("Authorization", format!("Bearer {}", api_key))
162                        .json(&serde_json::json!({
163                            "query": query,
164                            "max_results": count,
165                        }))
166                        .timeout(Duration::from_secs(30))
167                        .send()
168                        .await
169                        .map_err(|e| {
170                            anyhow::Error::new(e).context("Failed to reach Ollama web search API")
171                        })?;
172
173                    if !response.status().is_success() {
174                        let status = response.status();
175                        let body = response.text().await.unwrap_or_default();
176                        return Err(anyhow::Error::new(HttpStatusError {
177                            status: status.as_u16(),
178                        })
179                        .context(format!(
180                            "Ollama web search API returned error {}: {}",
181                            status, body
182                        )));
183                    }
184
185                    let body =
186                        read_body_capped(response, crate::constants::MAX_WEB_BODY_BYTES).await?;
187                    serde_json::from_slice::<OllamaSearchResponse>(&body)
188                        .map_err(|e| anyhow!("Failed to parse Ollama search response: {}", e))
189                }
190            },
191            &retry_config,
192            web_error_is_retryable,
193        )
194        .await?;
195
196        let search_results = map_search_results(
197            ollama_response
198                .results
199                .into_iter()
200                .map(|r| (r.title, r.url, r.content)),
201            count,
202        );
203
204        // Empty is a valid outcome (no matches), not an error.
205        Ok(search_results)
206    }
207
208    /// Fetch a URL's content via Ollama's web_fetch API.
209    async fn fetch_impl(&self, url: &str) -> Result<WebFetchResult> {
210        let retry_config = RetryConfig {
211            max_attempts: 2,
212            initial_delay_ms: 200,
213            max_delay_ms: 2000,
214            backoff_multiplier: 2.0,
215        };
216
217        let client = self.client.clone();
218        let api_key = self.api_key.clone();
219        let url_owned = url.to_string();
220        let response: OllamaFetchResponse = retry_async_if(
221            || {
222                let client = client.clone();
223                let api_key = api_key.clone();
224                let url = url_owned.clone();
225                async move {
226                    let response = client
227                        .post(format!("{}/web_fetch", OLLAMA_API_BASE))
228                        .header("Authorization", format!("Bearer {}", api_key))
229                        .json(&serde_json::json!({ "url": url }))
230                        .timeout(Duration::from_secs(15))
231                        .send()
232                        .await
233                        .map_err(|e| {
234                            anyhow::Error::new(e).context(format!("Failed to fetch {}", url))
235                        })?;
236
237                    if !response.status().is_success() {
238                        let status = response.status();
239                        return Err(anyhow::Error::new(HttpStatusError {
240                            status: status.as_u16(),
241                        })
242                        .context(format!("Failed to fetch {}", url)));
243                    }
244
245                    let body =
246                        read_body_capped(response, crate::constants::MAX_WEB_BODY_BYTES).await?;
247                    serde_json::from_slice::<OllamaFetchResponse>(&body)
248                        .map_err(|e| anyhow!("Failed to parse fetch response: {}", e))
249                }
250            },
251            &retry_config,
252            web_error_is_retryable,
253        )
254        .await?;
255
256        Ok(WebFetchResult {
257            title: response.title.unwrap_or_default(),
258            content: response.content.unwrap_or_default(),
259        })
260    }
261}
262
263#[async_trait]
264impl SearchProvider for OllamaWebClient {
265    async fn search(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
266        self.search_impl(query, count).await
267    }
268}
269
270#[async_trait]
271impl FetchProvider for OllamaWebClient {
272    async fn fetch(&self, url: &str) -> Result<WebFetchResult> {
273        self.fetch_impl(url).await
274    }
275}
276
277/// `web_search` backed by a self-hosted SearXNG instance's JSON API. Keyless —
278/// the instance itself queries upstream engines; Mermaid only talks to the
279/// user's local SearXNG.
280#[derive(Clone)]
281pub struct SearxngClient {
282    client: Client,
283    base_url: String,
284}
285
286impl SearxngClient {
287    pub fn new(base_url: String) -> Self {
288        Self {
289            client: Client::new(),
290            base_url: base_url.trim_end_matches('/').to_string(),
291        }
292    }
293}
294
295#[async_trait]
296impl SearchProvider for SearxngClient {
297    async fn search(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
298        let request_url = reqwest::Url::parse_with_params(
299            &format!("{}/search", self.base_url),
300            &[("q", query), ("format", "json")],
301        )
302        .map_err(|e| anyhow!("invalid SearXNG URL {}: {e}", self.base_url))?;
303
304        let response = self
305            .client
306            .get(request_url)
307            .timeout(Duration::from_secs(30))
308            .send()
309            .await
310            .map_err(|e| {
311                anyhow::Error::new(e).context(format!(
312                    "Failed to reach SearXNG at {} — is it running?",
313                    self.base_url
314                ))
315            })?;
316
317        if !response.status().is_success() {
318            let status = response.status();
319            return Err(anyhow!(
320                "SearXNG at {} returned {status}. A 403 usually means the JSON format is \
321                 disabled — add `json` to `search.formats` in its settings.yml.",
322                self.base_url
323            ));
324        }
325
326        let body = read_body_capped(response, crate::constants::MAX_WEB_BODY_BYTES).await?;
327        let parsed: SearxngResponse = serde_json::from_slice(&body).map_err(|e| {
328            anyhow!("Failed to parse SearXNG response (is `format=json` enabled?): {e}")
329        })?;
330
331        let results = map_search_results(
332            parsed
333                .results
334                .into_iter()
335                .map(|r| (r.title, r.url, r.content)),
336            count,
337        );
338
339        // Empty is a valid outcome (no matches), not an error — the tool layer
340        // reports "no results" and keeps any sibling queries' results.
341        Ok(results)
342    }
343}
344
345/// `web_search` backed by an auto-managed local SearXNG container
346/// (`crate::searxng`): starts the container lazily on the first search, reuses
347/// it after, and mermaid tears it down on exit. This is the zero-config default
348/// when no Ollama key is configured — the user installs and configures nothing.
349pub struct ManagedSearxngBackend;
350
351#[async_trait]
352impl SearchProvider for ManagedSearxngBackend {
353    async fn search(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
354        let base_url = crate::searxng::manager().ensure_running().await?;
355        SearxngClient::new(base_url).search(query, count).await
356    }
357}
358
359/// `web_fetch` performed in-process: fetch the URL directly and convert its
360/// HTML to markdown. No API key, no third party. Because the request leaves
361/// from this machine, the SSRF guards in `web.rs` (lexical) plus
362/// [`guard_resolved_ips`] (resolved-address) are the boundary here — not a
363/// remote service's.
364pub struct NativeFetchClient {
365    client: Client,
366}
367
368impl Default for NativeFetchClient {
369    fn default() -> Self {
370        Self::new()
371    }
372}
373
374impl NativeFetchClient {
375    pub fn new() -> Self {
376        let client = Client::builder()
377            .user_agent(NATIVE_FETCH_UA)
378            .timeout(Duration::from_secs(20))
379            .redirect(reqwest::redirect::Policy::limited(5))
380            .build()
381            .unwrap_or_else(|_| Client::new());
382        Self { client }
383    }
384}
385
386#[async_trait]
387impl FetchProvider for NativeFetchClient {
388    async fn fetch(&self, url: &str) -> Result<WebFetchResult> {
389        // Native fetch makes THIS process the fetcher, so re-check that the
390        // host doesn't resolve to an internal address (defense-in-depth on top
391        // of the lexical guard `web.rs` already applied).
392        guard_resolved_ips(url).await?;
393
394        let response = self
395            .client
396            .get(url)
397            .send()
398            .await
399            .map_err(|e| anyhow::Error::new(e).context(format!("Failed to fetch {url}")))?;
400
401        if !response.status().is_success() {
402            let status = response.status();
403            return Err(anyhow::Error::new(HttpStatusError {
404                status: status.as_u16(),
405            })
406            .context(format!("Failed to fetch {url}")));
407        }
408
409        let content_type = response
410            .headers()
411            .get(reqwest::header::CONTENT_TYPE)
412            .and_then(|v| v.to_str().ok())
413            .unwrap_or("")
414            .to_ascii_lowercase();
415
416        let body = read_body_capped(response, crate::constants::MAX_WEB_BODY_BYTES).await?;
417
418        // Only convert markup/text. A binary content-type (pdf, image, zip)
419        // can't become useful markdown — reject with a clear reason. An absent
420        // content-type is allowed (treated as text).
421        let looks_textual = content_type.is_empty()
422            || content_type.contains("html")
423            || content_type.contains("xml")
424            || content_type.contains("json")
425            || content_type.starts_with("text/");
426        if !looks_textual {
427            return Err(anyhow!(
428                "web_fetch: unsupported content-type '{content_type}' (only html/text pages)"
429            ));
430        }
431
432        let html = String::from_utf8_lossy(&body).into_owned();
433        let url_owned = url.to_string();
434        // HTML parsing + readability + markdown conversion is CPU-bound; keep it
435        // off the async runtime's worker threads.
436        let (title, content) =
437            tokio::task::spawn_blocking(move || extract_readable(&html, &url_owned))
438                .await
439                .map_err(|e| anyhow!("content extraction failed: {e}"))?;
440
441        Ok(WebFetchResult { title, content })
442    }
443}
444
445/// Reject a native fetch whose host resolves to a loopback / private /
446/// link-local / metadata address. Best-effort: the eventual connect re-resolves
447/// (a TOCTOU gap a no-network check can't close), but this stops the obvious
448/// "public name pointing at 127.0.0.1 / 169.254.169.254" aim.
449async fn guard_resolved_ips(url: &str) -> Result<()> {
450    let parsed = reqwest::Url::parse(url).map_err(|e| anyhow!("invalid URL: {e}"))?;
451    let host = parsed
452        .host_str()
453        .ok_or_else(|| anyhow!("URL has no host"))?;
454    let port = parsed.port_or_known_default().unwrap_or(80);
455    let addrs = tokio::net::lookup_host((host, port))
456        .await
457        .map_err(|e| anyhow!("DNS resolution failed for {host}: {e}"))?;
458    for addr in addrs {
459        if classify_host(&addr.ip().to_string()).is_internal() {
460            return Err(anyhow!(
461                "refusing to fetch '{host}' — it resolves to an internal address"
462            ));
463        }
464    }
465    Ok(())
466}
467
468/// Extract a page's title + main content (as markdown) from raw HTML. Uses a
469/// readability pass to drop nav/boilerplate, then converts the isolated content
470/// to markdown (preserving links). Falls back to whole-document conversion when
471/// readability can't parse the page.
472fn extract_readable(html: &str, url: &str) -> (String, String) {
473    use dom_smoothie::Readability;
474
475    if let Ok(mut readability) = Readability::new(html, Some(url), None)
476        && let Ok(article) = readability.parse()
477    {
478        let content_html = article.content.to_string();
479        let markdown = htmd::convert(&content_html).unwrap_or_default();
480        let markdown = markdown.trim();
481        // Readability can succeed yet strip a page down to nothing (SPA shells,
482        // pages it misjudges). Only trust a non-empty extraction; otherwise
483        // fall through to whole-document conversion.
484        if !markdown.is_empty() {
485            let title = if article.title.trim().is_empty() {
486                fallback_title(html)
487            } else {
488                article.title
489            };
490            return (title, markdown.to_string());
491        }
492    }
493
494    let markdown = htmd::convert(html).unwrap_or_default();
495    (fallback_title(html), markdown.trim().to_string())
496}
497
498/// Crude `<title>` scrape for the fallback path (readability normally supplies
499/// the title; this only runs when it couldn't parse the document).
500fn fallback_title(html: &str) -> String {
501    let lower = html.to_ascii_lowercase();
502    let Some(open) = lower.find("<title") else {
503        return String::new();
504    };
505    let after_tag = match html[open..].find('>') {
506        Some(gt) => &html[open + gt + 1..],
507        None => return String::new(),
508    };
509    match after_tag.to_ascii_lowercase().find("</title>") {
510        Some(end) => after_tag[..end].trim().to_string(),
511        None => String::new(),
512    }
513}
514
515/// Map raw `(title, url, content)` search hits to [`SearchResult`], truncating
516/// each hit's content to bound model context. Shared by every search backend.
517fn map_search_results(
518    hits: impl Iterator<Item = (String, String, String)>,
519    count: usize,
520) -> Vec<SearchResult> {
521    hits.take(count)
522        .map(|(title, url, content)| {
523            let full_content = truncate_content(&content, crate::constants::WEB_CONTENT_MAX_CHARS);
524            let snippet = content.chars().take(200).collect();
525            SearchResult {
526                title,
527                url,
528                snippet,
529                full_content,
530            }
531        })
532        .collect()
533}
534
535/// Format search results for model consumption.
536///
537/// Pure data -- no behavioral instructions. Citation rules live in the system
538/// prompt (src/prompts.rs), which is the SSOT for all model behavior.
539pub fn format_results(results: &[SearchResult]) -> String {
540    let mut formatted = String::from("[SEARCH_RESULTS]\n");
541
542    for (i, result) in results.iter().enumerate() {
543        formatted.push_str(&format!(
544            "[{}] Title: {}\nURL: {}\nContent:\n{}\n---\n",
545            i + 1,
546            result.title,
547            result.url,
548            result.full_content
549        ));
550    }
551
552    formatted.push_str("[/SEARCH_RESULTS]\n\n");
553
554    // Source list for citation (behavior governed by system prompt)
555    formatted.push_str("Sources:\n");
556    for (i, result) in results.iter().enumerate() {
557        formatted.push_str(&format!("{}. {} - {}\n", i + 1, result.title, result.url));
558    }
559
560    formatted
561}
562
563/// Read a reqwest response body, refusing to buffer more than `max_bytes`.
564/// `Response::json`/`bytes` buffer the whole body unbounded; a compromised or
565/// misconfigured endpoint could return a multi-gigabyte body and OOM the
566/// (long-lived) process. We reject early on an oversized `Content-Length` and
567/// also enforce the cap while streaming (a lying or absent header can't bypass
568/// it) (#28).
569async fn read_body_capped(response: reqwest::Response, max_bytes: usize) -> Result<Vec<u8>> {
570    use futures::StreamExt;
571    if let Some(len) = response.content_length()
572        && len as usize > max_bytes
573    {
574        return Err(anyhow!(
575            "response body too large: {len} bytes exceeds {max_bytes} cap"
576        ));
577    }
578    let mut stream = response.bytes_stream();
579    let mut buf = Vec::new();
580    while let Some(chunk) = stream.next().await {
581        let chunk = chunk.map_err(|e| anyhow!("error reading response body: {e}"))?;
582        if buf.len() + chunk.len() > max_bytes {
583            return Err(anyhow!("response body exceeded {max_bytes} byte cap"));
584        }
585        buf.extend_from_slice(&chunk);
586    }
587    Ok(buf)
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn test_ollama_web_client_creation() {
596        let client = OllamaWebClient::new("test-key".to_string());
597        assert_eq!(client.api_key, "test-key");
598    }
599
600    #[test]
601    fn test_format_results() {
602        let results = vec![SearchResult {
603            title: "Test Article".to_string(),
604            url: "https://example.com".to_string(),
605            snippet: "This is a test".to_string(),
606            full_content: "Full content here".to_string(),
607        }];
608
609        let formatted = format_results(&results);
610        assert!(formatted.contains("[SEARCH_RESULTS]"));
611        assert!(formatted.contains("Test Article"));
612        assert!(formatted.contains("https://example.com"));
613        assert!(formatted.contains("[/SEARCH_RESULTS]"));
614    }
615
616    #[test]
617    fn map_search_results_truncates_and_caps_count() {
618        let hits = (0..5).map(|i| {
619            (
620                format!("t{i}"),
621                format!("https://e{i}.com"),
622                "x".repeat(crate::constants::WEB_CONTENT_MAX_CHARS * 2),
623            )
624        });
625        let out = map_search_results(hits, 3);
626        assert_eq!(out.len(), 3, "count cap applied");
627        assert!(
628            out[0].full_content.len() <= crate::constants::WEB_CONTENT_MAX_CHARS + 64,
629            "content truncated"
630        );
631        assert!(out[0].snippet.chars().count() <= 200);
632    }
633
634    #[test]
635    fn searxng_response_parses_results() {
636        let json = serde_json::json!({
637            "results": [
638                {"title": "A", "url": "https://a.com", "content": "alpha"},
639                {"url": "https://b.com"},
640            ]
641        })
642        .to_string();
643        let parsed: SearxngResponse = serde_json::from_str(&json).unwrap();
644        assert_eq!(parsed.results.len(), 2);
645        assert_eq!(parsed.results[0].url, "https://a.com");
646        // Missing title/content default to empty, not a parse error.
647        assert_eq!(parsed.results[1].title, "");
648        assert_eq!(parsed.results[1].content, "");
649    }
650
651    #[test]
652    fn extract_readable_produces_markdown() {
653        let html = r#"<html><head><title>My Page</title></head>
654            <body><article><h1>Heading</h1><p>Hello <a href="https://x.com">link</a>.</p>
655            <p>More text to satisfy the readability length heuristic so this block is
656            treated as the main article content rather than boilerplate chrome.</p>
657            </article></body></html>"#;
658        let (title, md) = extract_readable(html, "https://example.com/page");
659        assert!(!title.is_empty(), "title extracted, got {title:?}");
660        assert!(
661            md.contains("Hello"),
662            "content converted to markdown: {md:?}"
663        );
664        assert!(md.contains("](https://x.com)"), "links preserved: {md:?}");
665    }
666
667    #[test]
668    fn extract_readable_fallback_title_on_unparseable() {
669        // A bare fragment with no article structure still yields a title + body.
670        let html = "<title>Bare</title><p>just a snippet</p>";
671        let (title, md) = extract_readable(html, "https://example.com");
672        assert_eq!(title, "Bare");
673        assert!(md.contains("just a snippet"));
674    }
675
676    #[test]
677    fn web_error_is_retryable_classifies_status() {
678        // #85: 5xx / 429 retry; 4xx and untyped (parse) errors are terminal.
679        assert!(web_error_is_retryable(&anyhow::Error::new(
680            HttpStatusError { status: 500 }
681        )));
682        assert!(web_error_is_retryable(&anyhow::Error::new(
683            HttpStatusError { status: 429 }
684        )));
685        assert!(!web_error_is_retryable(&anyhow::Error::new(
686            HttpStatusError { status: 404 }
687        )));
688        assert!(!web_error_is_retryable(&anyhow::Error::new(
689            HttpStatusError { status: 401 }
690        )));
691        assert!(!web_error_is_retryable(&anyhow!("parse failed")));
692        // Production wraps the status error with .context(); downcast must still
693        // find it through the context layer.
694        let wrapped = anyhow::Error::new(HttpStatusError { status: 503 }).context("upstream");
695        assert!(web_error_is_retryable(&wrapped));
696    }
697}