From 9ee810ad09c35841029426fac7afca1f8a2a8df7 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:38:36 +0200 Subject: [PATCH] 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. --- backend/open_webui/config.py | 9 +++ backend/open_webui/retrieval/web/staan.py | 60 +++++++++++++++++++ backend/open_webui/routers/retrieval.py | 29 +++++++++ .../admin/Settings/WebSearch.svelte | 45 ++++++++++++++ src/lib/i18n/locales/en-US/translation.json | 6 ++ 5 files changed, 149 insertions(+) create mode 100644 backend/open_webui/retrieval/web/staan.py diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index b475ccbe02..015720e4ac 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -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, diff --git a/backend/open_webui/retrieval/web/staan.py b/backend/open_webui/retrieval/web/staan.py new file mode 100644 index 0000000000..8326a0782c --- /dev/null +++ b/backend/open_webui/retrieval/web/staan.py @@ -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) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 885f45d46a..c36b0b8a53 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -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( diff --git a/src/lib/components/admin/Settings/WebSearch.svelte b/src/lib/components/admin/Settings/WebSearch.svelte index f1cba10529..ce9690a83d 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -36,6 +36,7 @@ 'serpapi', 'duckduckgo', 'tavily', + 'staan', 'jina', 'bing', 'exa', @@ -578,6 +579,50 @@ /> + {:else if webConfig.WEB_SEARCH_ENGINE === 'staan'} +
+
+
+ {$i18n.t('Staan API Key')} +
+ + +
+ + + + + + +
{:else if webConfig.WEB_SEARCH_ENGINE === 'searchapi'}
diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index c4b0e06a6d..42b16b571c 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -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": "",