mermaid_cli/providers/tool/
web_client.rs1use crate::utils::{RetryConfig, retry_async_if};
2use anyhow::{Result, anyhow};
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use std::time::Duration;
6
7#[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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WebFetchResult {
19 pub title: String,
20 pub content: String,
21}
22
23#[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#[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#[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
61fn 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#[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 pub async fn search_query(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
92 self.search(query, count).await
93 }
94
95 async fn search(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
101 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 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 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 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 pub async fn fetch_url(&self, url: &str) -> Result<WebFetchResult> {
193 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 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 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
275async 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 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 let wrapped = anyhow::Error::new(HttpStatusError { status: 503 }).context("upstream");
348 assert!(web_error_is_retryable(&wrapped));
349 }
350}