mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-03 06:30:51 -05:00
Raising GLOBAL_LOG_LEVEL to WARNING buys quieter output but not less work: 241 INFO call sites interpolate their payload into an f-string before the logging call gets to drop it. The heaviest is get_doc, which logs every chunk id and metadata dict in a collection, so on the full-context retrieval path that is the entire knowledge base, once per chat request.
That one line at WARNING, CPython 3.12:
| knowledge base | payload | before | after |
| -------------- | ------- | -------- | ------- |
| top-k of 3 | 1.2 kB | 3.8 us | 0.07 us |
| 500 chunks | 201 kB | 583.6 us | 0.08 us |
| 5000 chunks | 2.0 MB | 5.8 ms | 0.15 us |
The lazy form log.info('query_doc:result %s %s', result.ids, result.metadatas) hands the payload to record.getMessage(), which the InterceptHandler only reaches once a record has passed the level check. Output at INFO is byte-identical. Two sites that already built their message eagerly, one str concat and one % operator, move to the same lazy form.
233 lines
7.0 KiB
Python
233 lines
7.0 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import requests
|
|
from langchain_core.documents import Document
|
|
|
|
if TYPE_CHECKING:
|
|
from open_webui.retrieval.web.main import SearchResult
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
DEFAULT_FIRECRAWL_API_BASE_URL = 'https://api.firecrawl.dev'
|
|
FIRECRAWL_RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
FIRECRAWL_MAX_RETRIES = 2
|
|
|
|
|
|
def build_firecrawl_url(base_url: str | None, path: str) -> str:
|
|
base_url = (base_url or DEFAULT_FIRECRAWL_API_BASE_URL).rstrip('/')
|
|
path = path.lstrip('/')
|
|
|
|
if base_url.endswith('/v2'):
|
|
return f'{base_url}/{path}'
|
|
|
|
return f'{base_url}/v2/{path}'
|
|
|
|
|
|
def build_firecrawl_headers(api_key: str | None) -> dict[str, str]:
|
|
headers = {'Content-Type': 'application/json'}
|
|
if api_key:
|
|
headers['Authorization'] = f'Bearer {api_key}'
|
|
return headers
|
|
|
|
|
|
def get_firecrawl_timeout_seconds(timeout: Any) -> float | None:
|
|
if timeout in (None, ''):
|
|
return None
|
|
|
|
try:
|
|
timeout = float(timeout)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
return timeout if timeout > 0 else None
|
|
|
|
|
|
def get_firecrawl_scrape_timeout_ms(timeout: Any) -> int | None:
|
|
timeout_seconds = get_firecrawl_timeout_seconds(timeout)
|
|
if timeout_seconds is None:
|
|
return None
|
|
|
|
# Firecrawl v2 expects scrape timeouts in milliseconds.
|
|
return min(300000, max(1000, int(timeout_seconds * 1000)))
|
|
|
|
|
|
def get_firecrawl_client_timeout_seconds(timeout: Any, fallback: float = 60) -> float:
|
|
# Keep the local HTTP timeout slightly above Firecrawl's scrape timeout.
|
|
return (get_firecrawl_timeout_seconds(timeout) or fallback) + 10
|
|
|
|
|
|
def get_firecrawl_retry_delay(headers: Any, attempt: int) -> float:
|
|
retry_after = headers.get('Retry-After') if headers else None
|
|
if retry_after:
|
|
try:
|
|
return min(10.0, max(0.0, float(retry_after)))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
return min(8.0, float(2**attempt))
|
|
|
|
|
|
def request_firecrawl_json(
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
headers: dict[str, str],
|
|
json: dict[str, Any] | None = None,
|
|
timeout: float | None = None,
|
|
verify: bool = True,
|
|
) -> dict[str, Any]:
|
|
last_error = None
|
|
|
|
for attempt in range(FIRECRAWL_MAX_RETRIES + 1):
|
|
try:
|
|
response = requests.request(
|
|
method,
|
|
url,
|
|
headers=headers,
|
|
json=json,
|
|
timeout=timeout,
|
|
verify=verify,
|
|
)
|
|
|
|
if response.status_code in FIRECRAWL_RETRY_STATUS_CODES and attempt < FIRECRAWL_MAX_RETRIES:
|
|
delay = get_firecrawl_retry_delay(response.headers, attempt)
|
|
log.warning(
|
|
'Firecrawl %s %s returned HTTP %s; retrying in %.1fs',
|
|
method,
|
|
url,
|
|
response.status_code,
|
|
delay,
|
|
)
|
|
time.sleep(delay)
|
|
continue
|
|
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except (requests.ConnectionError, requests.Timeout) as e:
|
|
last_error = e
|
|
if attempt >= FIRECRAWL_MAX_RETRIES:
|
|
break
|
|
|
|
delay = get_firecrawl_retry_delay(None, attempt)
|
|
log.warning('Firecrawl %s %s failed; retrying in %.1fs: %s', method, url, delay, e)
|
|
time.sleep(delay)
|
|
|
|
if last_error:
|
|
raise last_error
|
|
|
|
raise RuntimeError(f'Firecrawl {method} {url} failed without a response')
|
|
|
|
|
|
def get_firecrawl_result_url(result: dict[str, Any]) -> str:
|
|
metadata = result.get('metadata') or {}
|
|
return (
|
|
result.get('url')
|
|
or result.get('link')
|
|
or metadata.get('url')
|
|
or metadata.get('sourceURL')
|
|
or metadata.get('source_url')
|
|
or ''
|
|
)
|
|
|
|
|
|
def scrape_firecrawl_url(
|
|
firecrawl_url: str,
|
|
firecrawl_api_key: str,
|
|
url: str,
|
|
*,
|
|
verify_ssl: bool = True,
|
|
timeout: Any = None,
|
|
params: dict[str, Any] | None = None,
|
|
) -> Document | None:
|
|
payload = {
|
|
'url': url,
|
|
'formats': ['markdown'],
|
|
'skipTlsVerification': not verify_ssl,
|
|
'removeBase64Images': True,
|
|
**(params or {}),
|
|
}
|
|
scrape_timeout_ms = get_firecrawl_scrape_timeout_ms(timeout)
|
|
if scrape_timeout_ms is not None:
|
|
payload['timeout'] = scrape_timeout_ms
|
|
|
|
response = request_firecrawl_json(
|
|
'POST',
|
|
build_firecrawl_url(firecrawl_url, 'scrape'),
|
|
headers=build_firecrawl_headers(firecrawl_api_key),
|
|
json=payload,
|
|
timeout=get_firecrawl_client_timeout_seconds(timeout),
|
|
verify=verify_ssl,
|
|
)
|
|
data = response.get('data') or {}
|
|
content = data.get('markdown') or ''
|
|
if not isinstance(content, str) or not content.strip():
|
|
return None
|
|
|
|
metadata = data.get('metadata') or {}
|
|
document_metadata = {'source': get_firecrawl_result_url(data) or url}
|
|
if metadata.get('title'):
|
|
document_metadata['title'] = metadata['title']
|
|
if metadata.get('description'):
|
|
document_metadata['description'] = metadata['description']
|
|
|
|
return Document(page_content=content, metadata=document_metadata)
|
|
|
|
|
|
def search_firecrawl(
|
|
firecrawl_url: str,
|
|
firecrawl_api_key: str,
|
|
query: str,
|
|
count: int,
|
|
filter_list: list[str] | None = None,
|
|
) -> list[SearchResult]:
|
|
try:
|
|
response = request_firecrawl_json(
|
|
'POST',
|
|
build_firecrawl_url(firecrawl_url, 'search'),
|
|
headers=build_firecrawl_headers(firecrawl_api_key),
|
|
json={
|
|
'query': query,
|
|
'limit': count,
|
|
'timeout': count * 3000,
|
|
'ignoreInvalidURLs': True,
|
|
},
|
|
timeout=count * 3 + 10,
|
|
)
|
|
# Firecrawl /search has historically returned both `{"data": [...]}`
|
|
# (flat list, what v1 did and what frost19k reported under #23966)
|
|
# and `{"data": {"web": [...]}}` (current v2). Accept either.
|
|
data = response.get('data') or {}
|
|
results = data if isinstance(data, list) else (data.get('web') or [])
|
|
|
|
if filter_list:
|
|
from open_webui.retrieval.web.main import get_filtered_results
|
|
|
|
results = get_filtered_results(results, filter_list)
|
|
|
|
from open_webui.retrieval.web.main import SearchResult
|
|
|
|
search_results = []
|
|
for result in results[:count]:
|
|
url = get_firecrawl_result_url(result)
|
|
if not url:
|
|
continue
|
|
|
|
metadata = result.get('metadata') or {}
|
|
search_results.append(
|
|
SearchResult(
|
|
link=url,
|
|
title=result.get('title') or metadata.get('title'),
|
|
snippet=result.get('description') or result.get('snippet') or metadata.get('description'),
|
|
)
|
|
)
|
|
|
|
log.info('FireCrawl search results: %s', search_results)
|
|
return search_results
|
|
except Exception as e:
|
|
log.error(f'Error in FireCrawl search: {e}')
|
|
return []
|