Patching Open WebUI So Saved Chats Keep Web Search Without a Socket
Open WebUI only gives a model its built-in tools when the request has a socket ID, so saved chats answered with zero tool calls. The patch, and how I ship it on the upstream image.
HomeLab, Self-hosted AI. Updated . 7 min read.
A family account asked GPT-6 Astra a research question and got back an answer that said it had no web access. Web search was on for that user, on for that model, and allowed by every permission I could find. The answer had zero tool calls and zero sources.
Open WebUI probably wasn't ignoring the setting. The best explanation I have is that it never offered the model any tools, because the request arrived without a socket ID.
TL;DR: Open WebUI v0.11.0 gives a model its built-in tools (search_web, fetch_url, sub-agents) only when the request carries a websocket session ID. The rest of the pipeline works fine without one. I patched one condition so an owned saved chat with a message ID keeps its tools. A test request with the socket disconnected went from zero tool calls to a cited answer in 19 seconds. The patch ships as a small image on top of upstream, and the build fails if the upstream code moves.
The setup
Open WebUI is the family's chat app on my homelab. It talks to a LiteLLM gateway, which routes to Azure OpenAI, AWS Bedrock and Gemini. Web search is meant to be on for everyone:
- every user has
settings.ui.webSearch = "always" - every model has
meta.defaultFeatureIds = ["web_search"] - native function calling supplies
search_webandfetch_url, and the model decides when to call them
So "I have no web access" should not be a possible answer.
Reproducing it
I couldn't prove what that one request looked like. Open WebUI doesn't keep the original request body, so its transport state is gone. What I could check: the account's permissions, the model capabilities and the saved search preference all allowed search. A fresh browser session with the same account searched, opened a source and showed citations.
So I went looking for a way to make a normal request lose its tools, and found one. Take a saved-chat request that works, remove the socket ID, send it again. No tools.
The cause is in process_chat_payload in utils/middleware.py. This is the part of the use_builtin_tools condition that matters:
bool(metadata.get('session_id'))
and metadata.get('params', {}).get('function_calling') != 'legacy'No session_id, no built-in tools, whatever features.web_search says. The odd part is that the response handler doesn't need the socket. It already runs server-side tools and writes results through the saved message and user events. The gate is stricter than the code behind it. And since sub-agents (delegate_task) are built-in tools too, the helpers disappear along with search.
The patch
One extra branch: if the request belongs to a saved chat owned by the caller and has a message ID, it gets built-in tools even without a socket.
(
bool(metadata.get('session_id'))
or bool(chat and chat.user_id == user.id and metadata.get('message_id'))
)
and metadata.get('params', {}).get('function_calling') != 'legacy'What still holds after it:
- the model's
builtin_toolscapability and thelegacyfunction-calling mode still switch tools off - feature permissions and an explicit
tools: []opt-out still apply - a plain API request with no saved chat is unchanged
- someone else's chat doesn't gain tools
- temporary chats without a socket don't gain tools
There's one tool the socket really is needed for. The code interpreter's default engine, Pyodide, runs Python in the browser, so without a socket there's nobody to run it. A second small change in utils/tools.py keeps the code interpreter off for socketless requests when the engine is pyodide. A server-side Jupyter engine still gets it, with its usual permission and feature checks.
Shipping a patch without forking
I didn't want a fork of Open WebUI to maintain. The patch is a short patch.py that does exact string replacements in the installed backend, plus a tiny Dockerfile:
ARG BASE_IMAGE=ghcr.io/open-webui/open-webui:v0.11.0
FROM ${BASE_IMAGE}
COPY patch.py test_patch.py /opt/openwebui-patches/
RUN python /opt/openwebui-patches/patch.py && python /opt/openwebui-patches/test_patch.pyTwo things keep it honest.
First, every "before" string has to appear exactly once in the upstream file, or the script stops with Upstream code differs: <file>. Check the patch before deployment. It also runs ast.parse on the result before writing anything. A new upstream version can't quietly get a half-applied patch; the build just fails.
Second, the tests don't import the app. test_patch.py parses the patched source with ast, pulls out the actual use_builtin_tools expression, compiles it, and evaluates it against fake chat, user and metadata objects. So it tests the code that shipped, not a copy of it. Cases: an owned saved chat keeps tools without a socket, another user's chat doesn't, a plain request with no chat or no message ID doesn't, the socket path still works, and the capability and legacy opt-outs still win. The code interpreter check gets a table: Pyodide without a socket is off, with a socket is on, Jupyter follows permission and feature flags.
The image is pushed to my local registry and Compose Manager runs it like before. Same database, appdata, ports and backup settings. Rollback is the old compose file and a redeploy. It's a code-only change, so no database rollback.
Proof
My end-to-end test script mirrors the UI: it creates a saved chat, sends the request, and reads the answer back from the chat. I added two switches for this. OWUI_NO_SOCKET=1 disconnects the socket and drops its ID from the request. OWUI_EXPECT_TOOLS=search_web,fetch_url fails the run unless both tool calls completed.
Running as the family account with the socket disconnected, the same search request went from zero tool calls before the patch to a cited answer in 19 seconds after it, with both search_web and fetch_url completing.
Sub-agents too. Two Astra helpers ran without the parent's socket, started in the same second and finished within 30 seconds. Each one searched and opened its source, Astra opened the sources itself to check them, and the full answer took 64 seconds. No helper delegated again.
These are integration checks, not a speed or accuracy benchmark. They show the tools are there; they don't say the answers are good.
The last two sections aren't about the socket. They're earlier fixes to the same install, from about two weeks before, and worth knowing if you run Open WebUI for other people.
The empty model picker
I signed in as a family account and GET /api/models returned []. As admin, everything looked fine.
Open WebUI v0.11 doesn't keep model visibility on the model row. It lives in access_grant: resource_type, resource_id, principal_type (user or group), principal_id, permission. A model row with no grant is private to its owner, and admins skip the check entirely, so no amount of testing as admin will find this.
The eight picker models were a mix of mistakes:
- the GPT-5.6 rows and Opus had no grant at all
- Sonnet and Fable were granted to one user ID only
- Gemini was granted to the "Subscribed Users" group, and the family accounts sit in "Free Users"
The fix is one public grant per model:
('model', <model id>, 'user', '*', 'read')
After that the family account saw all eight models, and I checked it with a full Arabic chat on Sol as that user. The rule I kept: verify visibility by signing in as a plain user, never as admin.
Scanned PDFs
The same day as the grant fix, I moved document extraction off the defaults. I ran real family PDFs through the stock PyPDFLoader with rapidocr, and it went badly:
| File | Default loader | Document Intelligence |
|---|---|---|
| 2 of 5 real PDFs | crashed (cannot reshape array, Cannot handle this data type) | read |
| scanned Arabic page | 193 chars of noise | 1,436 chars, correct |
| design PDF | 409 chars | 2,352 chars |
Azure Document Intelligence with prebuilt-layout handles PDF, DOCX, PPT and PPTX. It returns markdown with tables and does OCR for scans, Arabic included. It took 6 to 10 seconds per file. A PDF that had failed to upload back in April went through in 10 seconds with 17,062 characters.
Two settings go with it. rag.pdf_extract_images = false, so if anything falls back to the default engine it never reaches rapidocr again. And there's no silent fallback: if Document Intelligence is down, the upload fails with an error. I'd rather see an error than get 193 characters of noise indexed as a document. Azure bills it per page, about $10 per 1,000 pages at list price.
Questions
- Why does Open WebUI answer without searching even though web search is on?
- In v0.11.0, process_chat_payload only enables built-in tools such as search_web and fetch_url when the request carries a socket session ID. A request without one gets no tools, even with features.web_search set to true, so the model answers from memory and may say it has no web access.
- Is it safe to give built-in tools to requests without a socket ID?
- I only allow it for a saved chat that belongs to the requesting user and has a message ID. Model capabilities, the legacy function-calling opt-out, feature permissions and an explicit tools: [] still apply. Plain API requests and temporary chats stay unchanged, and browser-side Python still requires a socket.
- Why do non-admin users see an empty model list in Open WebUI?
- Open WebUI v0.11 keeps model visibility in the access_grant table, not on the model row. A model with no grant is private to its owner, and admins bypass the check. Add a ('model', <model id>, 'user', '*', 'read') grant per model, then check GET /api/models while signed in as a normal user.
- What is a better PDF loader for Open WebUI than the default?
- Set the content extraction engine to Azure Document Intelligence with the prebuilt-layout model. On my files the default PyPDFLoader with OCR crashed on 2 of 5 real PDFs and read 193 characters of noise from a scanned Arabic page, where Document Intelligence read 1,436 correct characters.