2026-09-10 Open WebUI Web Search Backends
What I set out to do
Find out which web search backends Open WebUI supports, and whether a free one is viable. Web search is currently not configured in nix/home-manager/modules/open-webui.nix.
What I actually did
- Listed the backends in the running container (v0.11.3). There are 33, under
/app/backend/open_webui/retrieval/web/. - Confirmed that config must go in Nix env vars:
ENABLE_PERSISTENT_CONFIG=Falseresets any UI change on restart. - Measured quota burn. Each search turn generates 1-3 queries (
ENABLE_SEARCH_QUERY_GENERATION), fired in parallel unlessWEB_SEARCH_CONCURRENT_REQUESTSis set. - Researched free-tier quotas, DuckDuckGo via ddgs, and SearXNG.
What was striking
- Google CSE is closed to new customers, and existing customers have until 2027-01-01 to move off it (source).
- Brave no longer has a free plan. It gives $5/month of credit instead, and needs a card on file (source).
- Monthly free allowances that renew: Tavily 1k, You.com 100/day, Linkup ~4k (needs a professional email), Exa $10/mo, Firecrawl ~500, SerpAPI 250.
- Open WebUI’s
duckduckgoengine is mostly not DuckDuckGo. ddgs 9.14.4 withbackend=autois a metasearch scraper: wikipedia and grokipedia first, then random engines such as brave, yandex and google. DDG itself was hit in about 1 of 8 queries.- It has no published quota.
- 9.x reports rate-limit 202s as “No results found”.
WEB_SEARCH_CONCURRENT_REQUESTSdoes not reach inside ddgs, because it sets an instance attribute where ddgs reads the class attribute.- DuckDuckGo’s robots.txt disallows
/html.
- SearXNG:
- Needs
search.formats: [html, json], or Open WebUI gets a 403. - Needs a real
secret_key, or it exits. - Keep
limiter: false: the limiter allows only 4 API requests per IP per hour. - Valkey is optional; only the limiter uses it.
- Image is
searxng/searxng, served by Granian on 8080. Uses about 150 MiB of RAM. - Google and Bing are disabled by default as of 2026-08.
- Failing engines get suspended, and Open WebUI ignores
unresponsive_engines, so a blocked engine just means fewer results with no error.
- Needs
Top 3 tomorrow
- Decide between SearXNG (self-hosted) and DuckDuckGo (zero setup).
- If SearXNG: add a container module alongside open-webui with a minimal
settings.yml. - Set
WEB_SEARCH_CONCURRENT_REQUESTS=1whichever engine is picked.
Related
2026-06-21 Open WebUI Deployment and Auth Cascade
Follow-up: Ollama web search
- The “generous free tier” is not quantified anywhere official: the blog, docs, pricing and FAQ all leave it out.
- There is no quota header or usage endpoint (ollama#15663, closed as a duplicate). The only signals are a 429 or the account dashboard.
- The Free plan allows 1 concurrent request. Usage is metered in tokens and resets monthly from the signup date. It is not documented whether web search draws from those credits.
- Open WebUI’s
ollama_cloudbackend catches every exception and returns[], so a 429 means silently empty results. Its env var isOLLAMA_CLOUD_API_KEY.
Outcome: Tavily wired in
- Picked Tavily: 1,000 credits/month, and Open WebUI’s basic search costs 1 credit.
open-webui.nixnow setsENABLE_WEB_SEARCH=TrueandWEB_SEARCH_ENGINE=tavily. - The key lives in its own file,
~/.config/secrets/TAVILY_API_KEY, as anexport TAVILY_API_KEY=...line..secretssources it for shells, and Open WebUI’s compose file lists it directly as an optionalenv_file: compose’s parser acceptsexportlines (verified withdocker compose config). Indocker/lib.nix,mkComposeDeploynow takes a list,secretsEnvPaths, and hashes every file in it, so a key change recreates the container. - An earlier version used a script that extracted the key from
.secretsinto a generated env file. I dropped it as overbuilt, since a shared file does the same with no code. The repo.gitignorehas a**secret**rule that silently ignores any path containing “secret”. - Verified with one live search from inside the container: 3 results.
- Committed as
5465ba4(feat(open-webui): enable Tavily web search). Open WebUI was recreated and is healthy, withWEB_SEARCH_ENGINE=tavilyand the key loaded. .secretsfinds its own directory with a snippet that works in bash, zsh and sh:BASH_SOURCE[0]under bash;eval '_self=${(%):-%x}'under zsh, with theevalhiding zsh-only syntax from bash’s parser. The key file is chmod 600.- Web search is now on by default in every chat (
e22efd9), viaDEFAULT_MODEL_METADATA={"defaultFeatureIds":["web_search"]}.- Gotcha: a model row’s own
defaultFeatureIdsreplaces the global list rather than merging with it (utils/models.py, wheremeta.get(key) is None). The seeded DeepSeek row therefore repeats the list, taken from the same Nix list. - Under native function calling, the toggle only offers the model a
search_webtool:middleware.pyskips the forced RAG search. Credits are spent only when a model chooses to call the tool. - Verified through
/api/models: all 491 models includeweb_search.
- Gotcha: a model row’s own
- The toggle was still off in the UI after
e22efd9. Cause:Chat.svelteapplies adefaultFeatureIdsentry only whenmodel.info.meta.capabilities[feature]is truthy, but the backend (get_model_capability) defaults a missing capability to true. So the API looked correct and the UI ignored it. Fixed in41e9028, which declarescapabilities.web_searchinDEFAULT_MODEL_METADATA. Capabilities merge with per-model rows;defaultFeatureIdsdoes not. - In 0.11 an enabled feature shows as an icon-only chip: web search is a blue globe next to Integrations. Verified in a fresh browser.
- A failed tool call in a Gemini chat was the model sending
search_web("site:pypi.org/project/pydantic/"), a query that is only a site filter. Tavily returns 400 “Query cannot consist only of site: operators”. Open WebUI’stavily.pydiscards the response body, so the model only saw a bare 400.- Root cause, per the user: the model used search to fetch a page.
fetch_urlwas available:utils/tools.pyadds[search_web, fetch_url]together, and the model calledsearch_web. Neither tool description mentions the other. - LiteLLM spans do not record tool definitions (
gen_ai.tool.definitionsis null). A bare/api/chat/completionscall without chat context attaches no tools, so it is not a valid reproduction. - A patch to the tool descriptions was proposed and is not built yet.
- Root cause, per the user: the model used search to fetch a page.
12e1dd5: code interpreter and the Obsidian MCP tool are now global defaults.globalToolIdsis derived fromtoolServerConnections. Verified in a fresh browser on Gemini: the chat shows the tool count, the globe and the terminal chips.3e12e94: image generation is a global default too. Every model now defaults to web search, code interpreter, image generation and the Obsidian tool.- The seeded DeepSeek row now holds only its display name. Its upsert
json_removes thedefaultModelMetadatakeys, so it inherits the global lists instead of shadowing them with a copy that could go stale. - Under native function calling, the image toggle only offers
generate_image/edit_image. The forced generation path runs only in legacy mode (middleware.py).
- The seeded DeepSeek row now holds only its display name. Its upsert
Follow-up: images showing twice (2026-09-11)
- Default chat model is now Gemini 3.8 Flash (
0115876). Image model research: MAI-Image-2.6 is the value pick (beats Gemini 3.1 Flash Image on all 4 arenas at ~58% of the cost) and GPT Image 2.5 Sunburst the quality pick. Meta Muse Image has no OpenRouter endpoint. No switch made yet. - Double display had two causes:
- Patch drift. OWUI 0.11.3 broke 6 of the 9 edits in
owui_image_cost_patch.py. Image-tool cost was lost, and the tool handed the model the url to re-embed. - Gemini 3 image models send the final image twice. It arrives once as the last thinking draft and once as the final (Google docs). A raw OpenRouter capture (~$0.14) showed identical PNG pixels (
IDAT), with only the per-delivery C2PAcaBXchunk differing. The JPEG copies are two lossy encodes of the same picture.
- Patch drift. OWUI 0.11.3 broke 6 of the 9 edits in
- Fixed in
8f92fdf:- Re-anchored all six edits, generated from shared helpers.
- Added a near-duplicate skip in
get_image_urls: 128px thumbnails, tolerance 8. Real pairs differ by ≤1 and a 40x40 edit control by 206. - Verified by replaying the recorded deltas through the live patched code with the upload stubbed: first image kept, re-send dropped.
- Still unexplained: a byte-identical third copy in one stored message, which did not come through the deduped
add_message_filespath.
Follow-up: Claude skills in Open WebUI via tofu (2026-09-11)
- Open WebUI 0.11.3 stores skills only in its database. There is no file loader: PR #21275 (file-based skills) was closed unmerged by tjbck, and Discussion #24606 (mount a folder) is unanswered. So skills are synced from the same directories Claude Code reads.
- The first plan was an SQL seed, then tofu with
Mastercard/restapifor real removal and drift handling. Testing showed restapi can’t do drift out of the box. 1.20 never sees edits. 3.0.0 either never sees them (ignore_server_additions) or reverts them in default mode, and misses deletions (upstream #377:Readnever callsRemoveResource). - Built the provider locally from v3.0.0 with a 9-line fix as
3.0.0-patched.377, served through a tofu filesystem mirror. The distinct version is needed because tofu reuses a same-version cached provider. - Verified on real skills with a normal
hm switch: a UI edit is reverted, a UI deletion is recreated, removal from the list destroys, and plans are clean otherwise. SigNoz shares the helper; its deletions are now repaired, while edits still go unseen because SigNoz wraps responses. - 8 skills are attached to all 493 models: knowledge-management, note, people, mermaid, and the 4 python guides.
- Commits:
7d28ec4(hook excludes .patch),069e380(provider package),90051d8(shared tofu helper + mirror),97e1c51(skills sync).
Follow-up: Meta Muse Image as the image model (2026-09-11)
- Muse Image now has a live endpoint on OpenRouter. Called directly, both generation and edit (source image in
input_references) cost $0.01 each and took 10 to 17 s. The edit kept the scene and changed only the weather. - LiteLLM 1.98 sends every OpenRouter image call to
/chat/completions, and OpenRouter refuses newer image-only models there with a 404. The upstream fix, BerriAI/litellm#40413, is a draft that moves every model and doesn’t apply to 1.98. I wrote a narrower local patch instead: models whoseoutput_modalitieslack text go to/images/generations, and Gemini stays on chat. It is keyed on the modalities becausemodeis polluted by the Router copyingmodel_info.modeonto the shared cost-map key. - A second bug turned up: Muse renders reached telemetry with no cost. Its price exists only on the chat-style
/endpoints, so the cost map has no rate and the map calculation raised, which dropped OpenRouter’s reported $0.01. I fixed the logging fallback in the 16021 companion patch to read the provider’s header key too, and SigNoz spans now carrygen_ai.cost.total_cost. - Pre-push hook: the deployed hook config had gone stale again (no
.patchexclude), which blocked a push and mangled the restapi patch. I restored the file and ranjust precommit-regen. - Commits:
c0ebfd5(cost fallback),fe6f154(Images API routing),52bd81b(Open WebUI uses Muse).