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.
This commit is contained in:
Classic298 2026-09-18 17:38:36 +02:00 • committed by GitHub
parent 655337f7bf
commit 9ee810ad09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 149 additions and 0 deletions

View file

@ -1298,6 +1298,12 @@ TAVILY_API_KEY = os.getenv('TAVILY_API_KEY', '')
TAVILY_EXTRACT_DEPTH = os.getenv('TAVILY_EXTRACT_DEPTH', 'basic')
STAAN_API_KEY = os.getenv('STAAN_API_KEY', '')
STAAN_MARKET = os.getenv('STAAN_MARKET', 'en-us')
STAAN_MAX_SNIPPETS = int(os.getenv('STAAN_MAX_SNIPPETS', '0'))
PLAYWRIGHT_WS_URL = os.getenv('PLAYWRIGHT_WS_URL', '')
PLAYWRIGHT_TIMEOUT = int(os.getenv('PLAYWRIGHT_TIMEOUT', '10000'))
@ -3018,6 +3024,9 @@ DEFAULT_CONFIG = {
'web.search.sougou_api_sk': SOUGOU_API_SK,
'web.search.tavily_api_key': TAVILY_API_KEY,
'web.search.tavily_extract_depth': TAVILY_EXTRACT_DEPTH,
'web.search.staan_api_key': STAAN_API_KEY,
'web.search.staan_market': STAAN_MARKET,
'web.search.staan_max_snippets': STAAN_MAX_SNIPPETS,
'web.loader.playwright_ws_url': PLAYWRIGHT_WS_URL,
'web.loader.playwright_timeout': PLAYWRIGHT_TIMEOUT,
'web.loader.firecrawl_api_key': FIRECRAWL_API_KEY,

View file

@ -0,0 +1,60 @@
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)

View file

@ -115,6 +115,7 @@ from open_webui.retrieval.web.serphouse import search_serphouse
from open_webui.retrieval.web.serply import search_serply
from open_webui.retrieval.web.serpstack import search_serpstack
from open_webui.retrieval.web.sougou import search_sougou
from open_webui.retrieval.web.staan import search_staan
from open_webui.retrieval.web.tavily import search_tavily
from open_webui.retrieval.web.utils import get_web_loader
from open_webui.retrieval.web.yacy import search_yacy
@ -380,6 +381,9 @@ RETRIEVAL_CONFIG_KEYS = {
'SERPSTACK_HTTPS': 'web.search.serpstack_https',
'SOUGOU_API_SID': 'web.search.sougou_api_sid',
'SOUGOU_API_SK': 'web.search.sougou_api_sk',
'STAAN_API_KEY': 'web.search.staan_api_key',
'STAAN_MARKET': 'web.search.staan_market',
'STAAN_MAX_SNIPPETS': 'web.search.staan_max_snippets',
'TAVILY_API_KEY': 'web.search.tavily_api_key',
'TAVILY_EXTRACT_DEPTH': 'web.search.tavily_extract_depth',
'TEXT_SPLITTER': 'rag.text_splitter',
@ -737,6 +741,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
'SERPLY_API_KEY': config.SERPLY_API_KEY,
'DDGS_BACKEND': config.DDGS_BACKEND,
'TAVILY_API_KEY': config.TAVILY_API_KEY,
'STAAN_API_KEY': config.STAAN_API_KEY,
'STAAN_MARKET': config.STAAN_MARKET,
'STAAN_MAX_SNIPPETS': config.STAAN_MAX_SNIPPETS,
'SEARCHAPI_API_KEY': config.SEARCHAPI_API_KEY,
'SEARCHAPI_ENGINE': config.SEARCHAPI_ENGINE,
'SERPAPI_API_KEY': config.SERPAPI_API_KEY,
@ -817,6 +824,9 @@ class WebConfig(BaseModel):
SERPLY_API_KEY: str | None = None
DDGS_BACKEND: str | None = None
TAVILY_API_KEY: str | None = None
STAAN_API_KEY: str | None = None
STAAN_MARKET: str | None = None
STAAN_MAX_SNIPPETS: int | None = None
SEARCHAPI_API_KEY: str | None = None
SEARCHAPI_ENGINE: str | None = None
SERPAPI_API_KEY: str | None = None
@ -1337,6 +1347,9 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
config.SERPLY_API_KEY = form_data.web.SERPLY_API_KEY
config.DDGS_BACKEND = form_data.web.DDGS_BACKEND
config.TAVILY_API_KEY = form_data.web.TAVILY_API_KEY
config.STAAN_API_KEY = form_data.web.STAAN_API_KEY
config.STAAN_MARKET = form_data.web.STAAN_MARKET
config.STAAN_MAX_SNIPPETS = form_data.web.STAAN_MAX_SNIPPETS
config.SEARCHAPI_API_KEY = form_data.web.SEARCHAPI_API_KEY
config.SEARCHAPI_ENGINE = form_data.web.SEARCHAPI_ENGINE
config.SERPAPI_API_KEY = form_data.web.SERPAPI_API_KEY
@ -1490,6 +1503,9 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
'SERPHOUSE_DOMAIN': config.SERPHOUSE_DOMAIN,
'SERPLY_API_KEY': config.SERPLY_API_KEY,
'TAVILY_API_KEY': config.TAVILY_API_KEY,
'STAAN_API_KEY': config.STAAN_API_KEY,
'STAAN_MARKET': config.STAAN_MARKET,
'STAAN_MAX_SNIPPETS': config.STAAN_MAX_SNIPPETS,
'SEARCHAPI_API_KEY': config.SEARCHAPI_API_KEY,
'SEARCHAPI_ENGINE': config.SEARCHAPI_ENGINE,
'SERPAPI_API_KEY': config.SERPAPI_API_KEY,
@ -2689,6 +2705,19 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li
)
else:
raise Exception('No TAVILY_API_KEY found in environment variables')
elif engine == 'staan':
if config.STAAN_API_KEY:
return await asyncio.to_thread(
search_staan,
config.STAAN_API_KEY,
query,
config.WEB_SEARCH_RESULT_COUNT,
config.WEB_SEARCH_DOMAIN_FILTER_LIST,
market=config.STAAN_MARKET,
max_snippets=config.STAAN_MAX_SNIPPETS,
)
else:
raise Exception('No STAAN_API_KEY found in environment variables')
elif engine == 'exa':
if config.EXA_API_KEY:
return await asyncio.to_thread(

View file

@ -36,6 +36,7 @@
'serpapi',
'duckduckgo',
'tavily',
'staan',
'jina',
'bing',
'exa',
@ -578,6 +579,50 @@
/>
</div>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'staan'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs text-gray-600 dark:text-gray-400 mb-1">
{$i18n.t('Staan API Key')}
</div>
<SensitiveInput
variant="settings"
placeholder={$i18n.t('Enter Staan API Key')}
bind:value={webConfig.STAAN_API_KEY}
/>
</div>
<AdminSettingField
className="mt-1.5"
label={$i18n.t('Market')}
forId="staan-market"
description={$i18n.t('Region and language of the search results, e.g. en-us.')}
>
<input
id="staan-market"
class={inputClass}
placeholder="en-us"
bind:value={webConfig.STAAN_MARKET}
/>
</AdminSettingField>
<AdminSettingField
className="mt-1.5"
label={$i18n.t('Max Snippets')}
forId="staan-max-snippets"
description={$i18n.t(
'Maximum extra snippets returned per result. Set to 0 to disable them.'
)}
>
<input
id="staan-max-snippets"
class={inputClass}
type="number"
min="0"
step="1"
bind:value={webConfig.STAAN_MAX_SNIPPETS}
/>
</AdminSettingField>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'searchapi'}
<div class="mb-2.5 flex w-full flex-col">
<div>

View file

@ -1115,6 +1115,7 @@
"Enter skill instructions in markdown...": "",
"Enter Sougou Search API sID": "",
"Enter Sougou Search API SK": "",
"Enter Staan API Key": "",
"Enter stop sequence": "",
"Enter system prompt": "",
"Enter system prompt here": "",
@ -1717,16 +1718,19 @@
"Mark as unread": "",
"Markdown": "",
"Markdown Header Text Splitter": "",
"Market": "",
"Max background": "",
"Max concurrent": "",
"Max iterations": "",
"Max output": "",
"Max Snippets": "",
"Max Speakers": "",
"Max tokens to retrieve (1024-32768, default 8192)": "",
"Max Upload Count": "",
"Max Upload Size": "",
"Maximum characters to return from fetched URLs. Leave empty for no limit.": "",
"Maximum concurrent embedding requests. Set to 0 for unlimited.": "",
"Maximum extra snippets returned per result. Set to 0 to disable them.": "",
"Maximum file size in MB. Leave empty for unlimited.": "",
"Maximum number of files allowed per folder.": "",
"Maximum number of files per folder is {{max}}.": "",
@ -2303,6 +2307,7 @@
"Regenerate": "",
"Regenerate Menu": "",
"Regenerate Response": "",
"Region and language of the search results, e.g. en-us.": "",
"Register Again": "",
"Register Client": "",
"Registered": "",
@ -2685,6 +2690,7 @@
"Speech-to-Text Language": "",
"Split documents by markdown headers before character or token splitting.": "",
"Sponsor on GitHub": "",
"Staan API Key": "",
"Start a new conversation": "",
"Start new chats as temporary unless changed.": "",
"Start of the channel": "",