Coverage for open_webui/retrieval/web/firecrawl.py: 15%
109 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1from __future__ import annotations
3import logging
4import time
5from typing import TYPE_CHECKING, Any
7import requests
8from langchain_core.documents import Document
10if TYPE_CHECKING: 10 ↛ 11line 10 didn't jump to line 11 because the condition on line 10 was never true
11 from open_webui.retrieval.web.main import SearchResult
13log = logging.getLogger(__name__)
15DEFAULT_FIRECRAWL_API_BASE_URL = 'https://api.firecrawl.dev'
16FIRECRAWL_RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
17FIRECRAWL_MAX_RETRIES = 2
20def build_firecrawl_url(base_url: str | None, path: str) -> str:
21 base_url = (base_url or DEFAULT_FIRECRAWL_API_BASE_URL).rstrip('/')
22 path = path.lstrip('/')
24 if base_url.endswith('/v2'):
25 return f'{base_url}/{path}'
27 return f'{base_url}/v2/{path}'
30def build_firecrawl_headers(api_key: str | None) -> dict[str, str]:
31 headers = {'Content-Type': 'application/json'}
32 if api_key:
33 headers['Authorization'] = f'Bearer {api_key}'
34 return headers
37def get_firecrawl_timeout_seconds(timeout: Any) -> float | None:
38 if timeout in (None, ''):
39 return None
41 try:
42 timeout = float(timeout)
43 except (TypeError, ValueError):
44 return None
46 return timeout if timeout > 0 else None
49def get_firecrawl_scrape_timeout_ms(timeout: Any) -> int | None:
50 timeout_seconds = get_firecrawl_timeout_seconds(timeout)
51 if timeout_seconds is None:
52 return None
54 # Firecrawl v2 expects scrape timeouts in milliseconds.
55 return min(300000, max(1000, int(timeout_seconds * 1000)))
58def get_firecrawl_client_timeout_seconds(timeout: Any, fallback: float = 60) -> float:
59 # Keep the local HTTP timeout slightly above Firecrawl's scrape timeout.
60 return (get_firecrawl_timeout_seconds(timeout) or fallback) + 10
63def get_firecrawl_retry_delay(headers: Any, attempt: int) -> float:
64 retry_after = headers.get('Retry-After') if headers else None
65 if retry_after:
66 try:
67 return min(10.0, max(0.0, float(retry_after)))
68 except (TypeError, ValueError):
69 pass
71 return min(8.0, float(2**attempt))
74def request_firecrawl_json(
75 method: str,
76 url: str,
77 *,
78 headers: dict[str, str],
79 json: dict[str, Any] | None = None,
80 timeout: float | None = None,
81 verify: bool = True,
82) -> dict[str, Any]:
83 last_error = None
85 for attempt in range(FIRECRAWL_MAX_RETRIES + 1):
86 try:
87 response = requests.request(
88 method,
89 url,
90 headers=headers,
91 json=json,
92 timeout=timeout,
93 verify=verify,
94 )
96 if response.status_code in FIRECRAWL_RETRY_STATUS_CODES and attempt < FIRECRAWL_MAX_RETRIES:
97 delay = get_firecrawl_retry_delay(response.headers, attempt)
98 log.warning(
99 'Firecrawl %s %s returned HTTP %s; retrying in %.1fs',
100 method,
101 url,
102 response.status_code,
103 delay,
104 )
105 time.sleep(delay)
106 continue
108 response.raise_for_status()
109 return response.json()
110 except (requests.ConnectionError, requests.Timeout) as e:
111 last_error = e
112 if attempt >= FIRECRAWL_MAX_RETRIES:
113 break
115 delay = get_firecrawl_retry_delay(None, attempt)
116 log.warning('Firecrawl %s %s failed; retrying in %.1fs: %s', method, url, delay, e)
117 time.sleep(delay)
119 if last_error:
120 raise last_error
122 raise RuntimeError(f'Firecrawl {method} {url} failed without a response')
125def get_firecrawl_result_url(result: dict[str, Any]) -> str:
126 metadata = result.get('metadata') or {}
127 return (
128 result.get('url')
129 or result.get('link')
130 or metadata.get('url')
131 or metadata.get('sourceURL')
132 or metadata.get('source_url')
133 or ''
134 )
137def scrape_firecrawl_url(
138 firecrawl_url: str,
139 firecrawl_api_key: str,
140 url: str,
141 *,
142 verify_ssl: bool = True,
143 timeout: Any = None,
144 params: dict[str, Any] | None = None,
145) -> Document | None:
146 payload = {
147 'url': url,
148 'formats': ['markdown'],
149 'skipTlsVerification': not verify_ssl,
150 'removeBase64Images': True,
151 **(params or {}),
152 }
153 scrape_timeout_ms = get_firecrawl_scrape_timeout_ms(timeout)
154 if scrape_timeout_ms is not None:
155 payload['timeout'] = scrape_timeout_ms
157 response = request_firecrawl_json(
158 'POST',
159 build_firecrawl_url(firecrawl_url, 'scrape'),
160 headers=build_firecrawl_headers(firecrawl_api_key),
161 json=payload,
162 timeout=get_firecrawl_client_timeout_seconds(timeout),
163 verify=verify_ssl,
164 )
165 data = response.get('data') or {}
166 content = data.get('markdown') or ''
167 if not isinstance(content, str) or not content.strip():
168 return None
170 metadata = data.get('metadata') or {}
171 document_metadata = {'source': get_firecrawl_result_url(data) or url}
172 if metadata.get('title'):
173 document_metadata['title'] = metadata['title']
174 if metadata.get('description'):
175 document_metadata['description'] = metadata['description']
177 return Document(page_content=content, metadata=document_metadata)
180def search_firecrawl(
181 firecrawl_url: str,
182 firecrawl_api_key: str,
183 query: str,
184 count: int,
185 filter_list: list[str] | None = None,
186) -> list[SearchResult]:
187 try:
188 response = request_firecrawl_json(
189 'POST',
190 build_firecrawl_url(firecrawl_url, 'search'),
191 headers=build_firecrawl_headers(firecrawl_api_key),
192 json={
193 'query': query,
194 'limit': count,
195 'timeout': count * 3000,
196 'ignoreInvalidURLs': True,
197 },
198 timeout=count * 3 + 10,
199 )
200 # Firecrawl /search has historically returned both `{"data": [...]}`
201 # (flat list, what v1 did and what frost19k reported under #23966)
202 # and `{"data": {"web": [...]}}` (current v2). Accept either.
203 data = response.get('data') or {}
204 results = data if isinstance(data, list) else (data.get('web') or [])
206 if filter_list:
207 from open_webui.retrieval.web.main import get_filtered_results
209 results = get_filtered_results(results, filter_list)
211 from open_webui.retrieval.web.main import SearchResult
213 search_results = []
214 for result in results[:count]:
215 url = get_firecrawl_result_url(result)
216 if not url:
217 continue
219 metadata = result.get('metadata') or {}
220 search_results.append(
221 SearchResult(
222 link=url,
223 title=result.get('title') or metadata.get('title'),
224 snippet=result.get('description') or result.get('snippet') or metadata.get('description'),
225 )
226 )
228 log.info('FireCrawl search results: %s', search_results)
229 return search_results
230 except Exception as e:
231 log.error(f'Error in FireCrawl search: {e}')
232 return []