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=False resets any UI change on restart.
  • Measured quota burn. Each search turn generates 1-3 queries (ENABLE_SEARCH_QUERY_GENERATION), fired in parallel unless WEB_SEARCH_CONCURRENT_REQUESTS is 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 duckduckgo engine is mostly not DuckDuckGo. ddgs 9.14.4 with backend=auto is 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_REQUESTS does 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.

Top 3 tomorrow

  1. Decide between SearXNG (self-hosted) and DuckDuckGo (zero setup).
  2. If SearXNG: add a container module alongside open-webui with a minimal settings.yml.
  3. Set WEB_SEARCH_CONCURRENT_REQUESTS=1 whichever engine is picked.

2026-06-21 Open WebUI Deployment and Auth Cascade

  • 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_cloud backend catches every exception and returns [], so a 429 means silently empty results. Its env var is OLLAMA_CLOUD_API_KEY.

Outcome: Tavily wired in

  • Picked Tavily: 1,000 credits/month, and Open WebUI’s basic search costs 1 credit. open-webui.nix now sets ENABLE_WEB_SEARCH=True and WEB_SEARCH_ENGINE=tavily.
  • The key lives in its own file, ~/.config/secrets/TAVILY_API_KEY, as an export TAVILY_API_KEY=... line. .secrets sources it for shells, and Open WebUI’s compose file lists it directly as an optional env_file: compose’s parser accepts export lines (verified with docker compose config). In docker/lib.nix, mkComposeDeploy now 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 .secrets into a generated env file. I dropped it as overbuilt, since a shared file does the same with no code. The repo .gitignore has 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, with WEB_SEARCH_ENGINE=tavily and the key loaded.
  • .secrets finds its own directory with a snippet that works in bash, zsh and sh: BASH_SOURCE[0] under bash; eval '_self=${(%):-%x}' under zsh, with the eval hiding zsh-only syntax from bash’s parser. The key file is chmod 600.
  • Web search is now on by default in every chat (e22efd9), via DEFAULT_MODEL_METADATA={"defaultFeatureIds":["web_search"]}.
    • Gotcha: a model row’s own defaultFeatureIds replaces the global list rather than merging with it (utils/models.py, where meta.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_web tool: middleware.py skips the forced RAG search. Credits are spent only when a model chooses to call the tool.
    • Verified through /api/models: all 491 models include web_search.
  • The toggle was still off in the UI after e22efd9. Cause: Chat.svelte applies a defaultFeatureIds entry only when model.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 in 41e9028, which declares capabilities.web_search in DEFAULT_MODEL_METADATA. Capabilities merge with per-model rows; defaultFeatureIds does 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’s tavily.py discards 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_url was available: utils/tools.py adds [search_web, fetch_url] together, and the model called search_web. Neither tool description mentions the other.
    • LiteLLM spans do not record tool definitions (gen_ai.tool.definitions is null). A bare /api/chat/completions call 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.
  • 12e1dd5: code interpreter and the Obsidian MCP tool are now global defaults. globalToolIds is derived from toolServerConnections. 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 the defaultModelMetadata keys, 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).

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:
    1. 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.
    2. 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 C2PA caBX chunk differing. The JPEG copies are two lossy encodes of the same picture.
  • 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_files path.

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/restapi for 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: Read never calls RemoveResource).
  • 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 whose output_modalities lack text go to /images/generations, and Gemini stays on chat. It is keyed on the modalities because mode is polluted by the Router copying model_info.mode onto 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 carry gen_ai.cost.total_cost.
  • Pre-push hook: the deployed hook config had gone stale again (no .patch exclude), which blocked a push and mangled the restapi patch. I restored the file and ran just precommit-regen.
  • Commits: c0ebfd5 (cost fallback), fe6f154 (Images API routing), 52bd81b (Open WebUI uses Muse).