open-webui/backend/open_webui/retrieval/web/staan.py
Classic298 9ee810ad09
feat: add Staan as a native web search provider (#30138)
European deployments that need EU data residency have no hosted web search option out of the box: every plug-and-play provider shipped today is US-based, and the only sovereign alternative is self-hosting SearXNG, which means running and maintaining that infrastructure yourself. Staan (staan.ai) is a European search API with EU data residency, so adding it gives those deployments a drop-in choice.

It is configured like any other provider, through the admin UI or STAAN_API_KEY, STAAN_MARKET and STAAN_MAX_SNIPPETS. Market sets the region and language of the results and defaults to en-us. Max snippets asks Staan to fetch each result page and return semantically scored chunks of it, which get merged into that result's snippet so retrieval has more to work with; leaving it at 0 uses the plain search endpoint.

Wired the same way as Tavily and Exa, with domain filtering going through the shared get_filtered_results.

Requested in #26006.
2026-09-18 10:38:36 -05:00

60 lines
1.9 KiB
Python

from __future__ import annotations
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
def search_staan(
api_key: str,
query: str,
count: int,
filter_list: list[str] | None = None,
market: str | None = None,
max_snippets: int | None = None,
) -> list[SearchResult]:
"""Search using Staan's Web Search API and return the results as a list of SearchResult objects.
Args:
api_key (str): A Staan API key
query (str): The query to search for
count (int): The maximum number of results to return
filter_list (list[str] | None): The domains to allow or block
market (str | None): The market to search in, e.g. 'en-us'
max_snippets (int | None): The maximum extra snippets to request per result
Returns:
A list of SearchResult objects.
"""
url = 'https://api.staan.ai/v2/search/web'
headers = {
'Accept': 'application/json',
'Authorization': f'Bearer {api_key}',
}
params = {'q': query, 'market': market, 'count': count}
if max_snippets:
params['extra_snippets'] = 'true'
params['max_snippets'] = max_snippets
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
results = response.json().get('web', {}).get('results', [])
if filter_list:
results = get_filtered_results(results, filter_list)
return [
SearchResult(
link=result.get('url', ''),
title=result.get('title'),
snippet=_build_snippet(result),
)
for result in results[:count]
]
def _build_snippet(result: dict) -> str:
"""Combine the snippet and the extra snippets list into a single string."""
parts = [result.get('snippet')]
parts.extend(extra.get('chunk') for extra in result.get('extra_snippets', []))
return '\n\n'.join(part for part in parts if part)