Saving a streaming response serialized the payload with orjson, decoded it to
str, scanned it for the three Unicode line separators and let redis-py encode
it straight back to UTF-8: on an 8 MB non-ASCII chat that is 6.9 ms and ~22 MB
of transient buffers per write, synchronously on the event loop.
json_codec now exposes dumps_bytes, which returns the serialized payload as
UTF-8 bytes without the line-separator escaping, and the two Redis writes in
tasks.py use it. That escaping only protects line-framed protocols such as
SSE; every reader of these Redis values re-parses them before anything is
served, and the escaped and raw forms parse identically, so mixed versions
during a rolling deploy interoperate both ways. The same write drops to
0.9 ms and one 8 MB buffer (7.5x), with 31-66% saved on KB-sized writes.
With ENABLE_ORJSON off, dumps_bytes wraps stdlib json, behaviour unchanged.
The str path keeps the escaping but applies it with chained str.replace
instead of a translate table, cutting a separator-containing 8 MB payload
from 312 ms to 5.7 ms with byte-identical output.
Exporting workspace models loaded every model row, built a full response object with its owner for each, and only then dropped the ones the caller may not see. On a large model table that made the export endpoint slow in proportion to models the user cannot even access.
The owner-or-grant check now happens in the query itself, reusing the permission filter this file already applies to the paginated list endpoint, so only visible rows are ever hydrated. The by-user wrapper had one caller left and is gone with it.
Measured with 500 workspace models of which 3 are visible to the caller: 5 queries and ~12.7 ms before, 4 queries and ~2.8 ms after. The resulting set is unchanged for owner, public, direct-user, group and multi-grant entries, and base model entries stay excluded as before.
Every streamed event that persists to a chat (status updates, citations,
file attachments, message content) serialized the entire conversation JSON
three times: a null-byte check of the stored row, a second sanitize of the
whole blob after merging in the event payload and the flush of the UPDATE
itself. The middle pass rescans megabytes of already-clean history for null
bytes that can only come from the small incoming payload, so long chats pay
for their full history on every single event.
The write paths now sanitize just the incoming message, message id and
status dict and keep the row-level sanitize, so legacy rows with null bytes
still self-heal as before. Median per-event write time (sqlite, orjson):
1 MB chat 15.0 ms to 11.1 ms, 4 MB 65.2 ms to 52.4 ms, 10 MB 159.9 ms to
124.5 ms, roughly 20 percent less per event. As a side effect the
chat_message dual write now receives the sanitized message; previously null
bytes in non-content fields were cleaned in the blob but written raw to
chat_message, which failed that insert on PostgreSQL. Verified byte-identical
rows against the previous implementation across nine scenarios covering null
bytes in every input, legacy dirty rows, a missing title and a NULL chat
column.
Listing a user's folders re-checks which entries they may still see, and it resolved their group membership again for every folder, then again inside the collection and note branches for every entry. A comment in that helper claims one membership fetch for the whole listing, but the caller invokes it once per folder, so the claim never held.
The listing now resolves membership once, and only when some folder actually carries entries, then threads it through the file, collection and note checks. Callers that do not supply it are unchanged and still resolve for themselves.
Measured with twenty folders holding six files, two knowledge bases and two notes each: 245 queries and ~145 ms before, 186 and ~117 ms after. The folders returned, and the entries the integrity pass writes back, are unchanged. That was checked against entries the caller owns, entries shared through a group, entries shared with nobody, another user's files, and an unrecognised entry type.
The tool callable now takes its connection's cookie jar as a parameter, matching how its headers are already passed and how the terminal tool factory in the same module builds its callables.
A calendar event's `meta` is a free-form dict, so `meta.alert_minutes` can hold any JSON type, while the upcoming-events lookup assumed it was a number and compared it directly. It now ignores a value that is not numeric and falls back to the default alert window for that event.
Handled on the read side rather than on the write path so events already stored with a non-numeric value are covered too. Numeric values are untouched, including the negative "no alert" sentinel.
A chat attached via the "+" menu or dropped from the sidebar references an
existing chat by id and carries no url. add_file_context() filtered on
`file.get('url')`, so the reference was dropped from <attached_files>
entirely and the model was never told it existed.
When the RAG file-context path is enabled the chat content still reaches
the model as <source> context, which masked this. With file_context
disabled that path is skipped, and get_attached_knowledge() only promotes
collection/note items into <attached_knowledge> - so an attached chat was
visible in the UI but invisible to the model, which then reported having
no chat attachments despite having a view_chat tool available.
Keep chat references and emit their id so the model can resolve them with
view_chat. The url attribute is now conditional, since a chat has none;
the id guard it replaces was dead once the filter guarantees a url or a
chat id.
Co-authored-by: Claude <noreply@anthropic.com>
The tool-call continuation re-submits with bypass_system_prompt=True, but only
routers/openai.py and routers/ollama.py checked it, so pipe and manifold models
had the system prompt applied again on every continuation. Since
add_or_update_system_message() prepends rather than replaces, N tool-call rounds
left N+1 copies of the system prompt in the payload.
Checking whether a user may reach a file loaded and validated every workspace model that user can access, then scanned each model's knowledge list in Python for one file id. Folder listings run that check once per file, so opening a folder of twenty files rebuilt the whole accessible-model set twenty times, and the same check sits on every retrieval and download path.
The lookup now runs the other way round: the database returns the models that attach the file, and only those are access-checked. The text match on the metadata column is a prefilter and the knowledge entries still decide, so a file id that merely appears in a description grants nothing; file ids are server-generated uuids, so the match can only be too wide, never too narrow.
Measured with 500 accessible workspace models: a single check drops from 9 queries and ~20 ms to 6 and ~2.6 ms, and a twenty-file folder listing from 180 queries and ~680 ms to 120 and ~56 ms. A 72-case matrix over owner, public, direct-user and group grants, for both read and write, returns exactly what it returned before, and write still requires the model owner to own the file. The check also no longer writes to the database while answering a read-only question.
Listing skills ran one database query per skill in the instance. A non-admin opening the list on a workspace with 500 skills issued over 500 queries, the paginated list re-resolved the caller's group membership once per row, and every chat message carrying a skill loaded every skill the user can read, full body and owner included, to use the two or three it actually referenced.
Skills now arrive already filtered: the owner-or-grant check runs in the query as an EXISTS subquery, the same way prompts and the search endpoints already do it, the per-item write flag uses the existing batch grant lookup, and the chat path asks only for the skill ids the request names.
Measured with 500 skills of which 3 are visible to the caller: 504 queries and ~300 ms before, 4 queries and ~2.6 ms after. The resulting set is unchanged for owner, public, direct-user, group and multi-grant entries, for both read and write.
Three methods on KnowledgeTable have no callers anywhere in the repository. get_knowledge_bases_by_user_id loaded every knowledge base and filtered them in Python, which search_knowledge_bases already does in SQL with pagination. get_knowledge_by_id_and_user_id duplicates check_access_by_user_id with the permission hardcoded to write. update_knowledge_data_by_id writes a data column that a migration dropped, so it could only ever raise and return None through its own except block.
What remains is one per-entry access helper and one SQL-filtered list path, so nobody reaches for the slower or the broken variant by accident.
No behaviour change.
Assembling the builtin tools for a chat message fetched the chat row a second time to answer one question: whether this is a note chat. The caller had loaded that same row a few lines earlier, from the same id in the same metadata dict, and had already evaluated the same predicate for its own note handling. So every message with builtin tools enabled read the whole conversation blob twice.
The caller now works the flag out once and passes it down. Tool assembly no longer touches a chat model at all, so the two files cannot drift apart when the shape of that metadata changes.
Measured with a stub request across five chat shapes, a note chat, a plain chat, an internal chat that is not a note, a chat id with no row behind it, and an unsaved chat id: the returned tool set is identical in every case and the query count drops from six to five. The note tools are still enabled for a note chat with the notes feature switched off, which is the only thing that predicate decides.
Opening the shared folder list fetched every shared folder in its own query, fetched a chunk of them a second time to walk their children, and looked up each distinct owner separately. With forty folders shared with a user that is over a hundred queries before any subtree work starts.
The folders and their owners now come back in one query each, and the inheritance pass reuses the rows already in hand. Both folder listings also gained an explicit order: the sidebar merges shared subfolders in response order without sorting them, and neither query had an ORDER BY, so on Postgres a folder rename could reshuffle its siblings.
Measured with forty shared folders and no subtrees: 181 queries and ~105 ms before, 92 and ~66 ms after. With subtrees attached, 203 folders in total, it is 341 queries before against 252 after; the remainder is the recursive child walk, which this change deliberately leaves alone. The returned set, permissions and owner names are unchanged, including for a grant pointing at a deleted folder row, a folder the caller owns that is also shared with them, a folder whose owner record is gone, and a child folder that is itself directly shared.
Saving a chat rewrote its message rows one at a time. Each message took its own session out of the pool and committed on its own, and the save endpoint hands over the entire merged history rather than only what changed, so a two hundred message chat cost two hundred sessions and two hundred commits on every save.
The messages now go through a single select and a single commit. The field mapping for the insert and the update branch moved into two small helpers, so the batch and the single-message path cannot drift apart.
Measured on a two hundred message chat with one message edited: 201 queries and 200 transactions before, 2 queries and 1 transaction after, ~149 ms against ~6 ms. Re-saving an unchanged history now costs one select and no writes at all.
One behaviour change worth stating: a message the database cannot store used to be skipped on its own, and now costs the rest of that same save. This table is a rebuildable fast path, so the reader falls back to the history on the chat row and re-triggers the backfill, and the next save reconciles everything still present. A per-message retry was tried and dropped, because a commit that lands but still raises would re-apply the usage merge and double the recorded token counts.
Changing a password left every other logged-in device working until the JWT expired on its own, up to four weeks with the default settings. The hardening docs already promise the opposite: with Redis configured a password change is supposed to put the user's tokens on the revocation list, but only sign-out and OIDC back-channel logout ever wrote to it.
Both password-change paths, self-service and an admin resetting someone's password, now stamp the per-user revocation marker that token validation already checks, so every session issued before the change stops working. The acting device is signed out as well and asked to sign in again, which is the safer default when the password is being changed precisely because the old one may be compromised. Without Redis nothing can be revoked, as before, and the backend now logs a warning saying so.
The marker is written through one shared helper, so its lifetime follows the configured JWT lifetime instead of a fixed 30 days and never expires at all when JWT_EXPIRES_IN disables expiry. Back-channel logout picks that up too, where a long or disabled JWT lifetime previously let the marker expire while the tokens it revoked were still valid. API keys keep working, they are separate credentials with their own lifecycle.
Discussed in #28647.
The Python test suite was deleted in 4527c747b but its dependencies stayed behind, so pytest, pytest-docker and the docker SDK still install into every image variant, and moto joins them for anyone running pip install open-webui[all]. No Python test file remains in the repository, nothing imports these packages, and no CI job runs pytest. They are removed from backend/requirements.txt and from the all extra, which are the only two channels they ship through.
netcat-openbsd goes for the same reason. It was added in January 2024 without a consumer and nc has never been invoked anywhere in the repository, in any script, workflow or compose file. Both the readiness wait and the healthcheck use curl, and the Ollama install script does not ask for it either.
uv.lock is regenerated output, not hand-edited. It drops three of the four packages plus three transitives that nothing else needs, with no version changes and no additions. pytest stays locked because pytest-asyncio in the dev group still requires it. The dependency markers it adds on the CUDA and numpy entries are inert: each one is a superset of the condition its parent already installs under, and the resolved default install set is identical before and after.
This saves roughly 2 MB uncompressed, which is nothing next to the image as a whole. The point is that a production image stops shipping a test framework and a Docker socket client it never uses.
Everything else stays and is load-bearing. The container installs pip packages at runtime for user-authored tools and functions, so it needs git and a working compiler for anything that is not a prebuilt wheel, and libmariadb-dev for the manual MariaDB install. zstd is required for updating Ollama inside the bundled image. black looks dev-only but backs the code formatting endpoint.
Ref: https://github.com/open-webui/open-webui/discussions/28716
The legacy function-calling path acted on the client-supplied `features` dict after checking only the per-user permission, so a user who still held `features.web_search` or `features.image_generation` could keep triggering web searches and image generation after an administrator had switched those off instance-wide. The native function-calling path already gates the equivalent builtin tools on `web.search.enable` and `image_generation.enable` in `get_builtin_tools`, so the two paths disagreed and the admin-level switch did not actually stop the outbound provider calls it was turned off to stop.
Gate the legacy web search handler on `web.search.enable` at its call site, and gate `chat_image_generation_handler` on the two image switches internally. The image handler needs the check inside it because `image_generation.enable` and `images.edit.enable` are independent: editing stays available when generation is disabled, matching the `/images/generations` and `/images/edit` routes and the native `generate_image`/`edit_image` tools. The handler calls `image_generations`/`image_edits` directly and so bypasses the route guards, which is why the check has to live at the caller.
The "Creating image" status event moves below the new guard so a disabled configuration returns without leaving an unresolved progress indicator in the chat.
Seven route handlers declare a request-scoped database session as a FastAPI dependency and then never touch it. Three of them are `GET /api/v1/users/user/settings`, `/user/status` and `/user/info`, which the frontend hits on every page load, and all three carry a comment saying the user object is already available, so the parameter is leftover from the refactor that removed the refetch. The other four are admin-only external-knowledge connection endpoints that read their data from the config store.
Measured on a route with and without the dependency, 20k requests, best of 5:
| | µs per request |
| --- | --- |
| no dependency | 16.18 |
| unused session dependency | 62.85 |
The dependency costs about three times as much as everything else the request does put together. It is worth being precise about why, because the obvious guess is wrong: this is not database I/O and not connection pool pressure. SQLAlchemy connects lazily, so a session that is never used checks out zero connections, verified by watching the pool's counter stay at zero across the request. The cost is FastAPI resolving an extra async-generator dependency onto the request's exit stack, plus constructing and closing the session object.
Deleting the seven parameters is the whole change. An AST scan over the backend finds exactly these seven handlers before and none after.
With database session sharing enabled, which the docs recommend for PostgreSQL and for multi-replica deployments, the knowledge pending-files and file process-status endpoints each pinned one pooled connection for as long as their SSE stream stayed open, up to one and two hours respectively. A file wedged in processing keeps a stream open for the full duration, so a handful of users sitting on that page can consume every connection in the pool, and the held transactions sit idle and block autovacuum on those tables.
Both handlers took a request-scoped session for their access checks, and FastAPI only releases a yield dependency once the response body has finished streaming, so the session outlived the handler by the whole life of the stream. Neither generator ever used it. They no longer take that dependency, and the queries they run already open their own short-lived sessions when none is passed. This is the approach the chat completion endpoints already use for the same long-response problem.
Measured against a pool with capacity 11: before, at most 11 concurrent streams could ever be open and every further attempt failed, deterministically across repeat runs. After, 25 of 25 opened. Non-stream latency is unchanged, within run-to-run noise, and behaviour is identical whether session sharing is on or off.
Three searches LIKE against cast(json_col AS text), which means they have to match
bytes a JSON encoder wrote. Encoders disagree on non-ASCII: stdlib escapes it to
\uXXXX, orjson writes it raw. Which one produced a row depends on the codec in force
when it was written, so any single pattern finds only half the table.
models.py hard-codes the stdlib spelling, with a comment asserting SQLite stores
JSON via json.dumps(ensure_ascii=True). Model.meta is a JSONField, which has
serialised through JSONCodec since ENABLE_ORJSON was introduced, so on that setting
it stores raw UTF-8 and the escaped pattern matches nothing: non-ASCII workspace
model tag search is broken today. prompts.py and automations.py hard-code the
opposite spelling and miss rows written the other way.
json_text_variants returns both spellings a string can take inside serialised JSON,
collapsing to one for ASCII, and the three call sites OR over them. Rows written
under either setting are now found under either setting, which also covers a
database holding a mix of the two.
Case handling is unchanged. models.py keeps matching non-ASCII tags case-sensitively
on SQLite, whose LOWER() is ASCII-only and would not fold the stored text the way
str.lower() folds the tag. ASCII tags collapse to a single variant and take exactly
the query they took before.
Verified on SQLite across every combination of codec-that-wrote-the-row and
codec-the-app-is-running, for an ASCII and a CJK tag, over all three call sites: 24
of 24 match, against 12 of 24 before. Quoting still bounds whole-tag matches, so
searching "weather" does not match a row tagged "weathervane".
Co-authored-by: Claude <noreply@anthropic.com>
POST /api/v1/models/sync only worked when every model in the payload was new. As soon as one id already existed, the whole call blew up and the endpoint still answered HTTP 200 with an empty list, so nothing was updated and well-behaved clients saw a success. Only a first-ever sync into an empty catalogue went through.
The update branch splatted the model dump (which already carries user_id and updated_at) and then passed both again as explicit keyword arguments, which is a duplicate-keyword TypeError before SQLAlchemy ever sees it. The insert branch right below merged the same values into a dict first, so it never collided.
Fixed by building that dict once and using it for both branches, matching how sync_functions already does it. Left the broad exception handler alone: it is the reason the failure was silent, but changing the error contract of sync_models is a separate call.
Fixes#28033
The Playwright loader's route interceptor now performs each intercepted request with the same requests/aiohttp clients the other web loader paths already use and fulfills the page with that response, rather than having the browser issue it. Redirect handling, header forwarding and cookie delivery to the browser are unchanged.
Two consequences worth knowing. Page requests now leave from the backend instead of the browser, so with PLAYWRIGHT_WS_URL set they originate from a different host, and TLS is verified against certifi plus AIOHTTP_CLIENT_SSL_CERT_FILE rather than the browser's own trust store. And because the synchronous interceptor blocks, sub-resources on that path fetch one at a time: 30 assets at 40ms went from 2.01s to 3.01s, and 8 assets at 500ms from 1.05s to 4.50s. The asynchronous path is unaffected, at 0.65s and 1.05s respectively.
Both the OAuth and SCIM user lookups now compare the nested JSON value with SQLAlchemy's subscript operator, which emits the correct SQL for each supported database on its own. This replaces the hand-written sqlite and postgresql branches and the column-level contains() call they used.
A skill ID goes straight into the path of every mutating skill endpoint (/api/v1/skills/id/{id}/...), but create only replaced spaces with hyphens. An ID containing a "/" was stored verbatim as the primary key, so the route never matched, the request fell through to the SPA static mount and the client got 405 Method Not Allowed. The skill could not be opened, edited, toggled or deleted, by admins either, and since skill.name is UNIQUE it could not be recreated under a corrected ID. Percent-encoding does not help: uvicorn decodes the path before Starlette routes it, so the only remaining fix was a direct database write.
Create now rejects any ID outside [a-z0-9_-] with 400 instead of silently storing an unreachable one. Two frontend paths that fed unsanitized IDs into it are fixed as well: the manual "Skill ID" field, which was bound with no sanitization at all and is the path that reproduces on every version, and the markdown import, which put the raw frontmatter name into the ID before opening the editor in clone mode, where the reactive slugify is disabled.
Existing rows with an unreachable ID are not repaired here; rewriting a primary key would also have to re-point the access grants keyed on it.
Fixes#27655
With ENABLE_OAUTH_GROUP_MANAGEMENT enabled, every SSO login reconciles the user's group membership against the IdP claims, adding and removing them from groups and, with ENABLE_OAUTH_GROUP_CREATION, creating groups that do not exist yet. None of it emitted an event, so the same membership change was observable when an admin made it through the UI or when it arrived over SCIM, but invisible when the IdP drove it. That is the path that changes membership most often.
Emits group.member_added and group.member_removed per membership transition and group.created for each auto-created group, using the same payload keys as the groups router. The member events are published only when the write returned a group, so a failed or no-op write emits nothing, and both loops already run only on an actual transition. update_user_groups takes the request so the events can be published; it has a single caller.
The tool callable now takes its connection's cookie jar as a parameter, matching how its headers are already passed and how the terminal tool factory in the same module builds its callables.
The gate now applies the same authorship condition the channel message update route already uses, so both paths agree on which messages a caller may modify.
Changing "Default Pinned Models" in admin settings had no effect for anyone who had already opened Open WebUI once. The sidebar copied the admin default into that user's own settings the first time it rendered and saved it to the server, which marked them as having customized their pins, so every later change to the default was ignored for them. Merely loading the page was enough, the user never had to touch a pin.
The default is now resolved for display only, through a shared store that falls back to the admin list while the user has no pins of their own, the same way default models already work. Nothing is written to the user's settings until they actually pin, unpin or reorder something, at which point their choice takes over for good. Unpinning everything still persists an empty list rather than snapping back to the default.
Users whose settings were already overwritten by the old behaviour keep that copy, since a stored pin list cannot be told apart from a deliberate one.
Fixes a drag-reorder path that mixed sidebar positions with stored ones, and stops the sidebar section reopening itself after any unrelated settings change.
Every toggle row in the chat integrations menu (filters, Web Search, Image, Code Interpreter, Tools, Skills) is a button whose on/off state was carried only by the decorative Switch inside it. Screen readers announced the row name and nothing else, so there was no way to tell whether a tool or feature was active without looking at it.
Each row button now carries aria-pressed, and the Switch wrapper is marked inert so the nested role=switch stops competing with the row for the announcement and stops adding a nameless tab stop. Hit testing skips inert content, so clicking the switch still toggles the row.
Tool rows that are not yet authenticated omit aria-pressed: activating those starts an OAuth redirect rather than toggling, so announcing them as an unpressed toggle would be wrong.
The Web Search, Image and Code Interpreter rows also had a state-flipping aria-label ("Disable Web Search") on top of aria-pressed, which announces as "Disable Web Search, pressed" and reads as the opposite of the truth. Removed: the visible row text already names each control.
Fixes#17150
Revoking a user's `features.memories` permission removed their access to the memories API and to the native function-calling memory tools, but their stored memories were still injected into the system context on the legacy function-calling path.
The branch in `process_chat_payload` only checked the client-supplied `features['memory']` flag plus the global `memories.system_context.enable` switch, with no user-permission check. `add_memory_context` did not compensate: it only checks `model_allows_memory`, which is a model capability rather than a permission, and the one call inside it that does check the permission (`query_memory`) has its 403 swallowed by a `try/except`, so `Memories.get_memories_by_user_id` and the neighbourhood scan still fed the system prompt.
Gate the branch with the same permission check the native path already performs in `get_builtin_tools`, matching the neighbouring `web_search` and `image_generation` branches.
Only the caller's own memories were injected into the caller's own context, so there was no cross-user exposure. The practical effect was that the permission toggle did not do what its name implies: an admin who revoked it still got memory content injected for that user.
Clicking "Merged Response" in a multi-model chat often made the merging model answer with "It appears that the responses provided from the other models were empty".
The merge handler collected each model's answer via `history.messages[id].content`. Assistant messages are persisted by the backend with `output` only (`upsert_message_to_chat_by_id_and_message_id` writes `done`/`role`/`output`, never `content`), so `content` is only populated in the browser session that generated the responses, where the streaming handler mirrors it. Once the chat is reloaded from the database, every assistant message has `content: ''` and the merge request is sent with a list of empty strings, which is exactly what the merging model then reports. That is why the failure looks random: merging works right after generating, and fails after a refresh or when reopening the chat.
Read the responses through `getOutputText(message.output) || message.content`, the same fallback already used by every other read site (`ResponseMessage`, `Overview/Node`, `SearchModal`, `ChatItem`, `ChatMenu`, `Navbar/Menu`).
Fixes#26962
Only the frontend added `stream_options: {include_usage: true}` to the completion payload, gated on the model's `usage` capability. Every backend-initiated run builds its own payload (automations, timers, subagents, channels) and omitted it, so those responses came back without token counts and never rendered the usage block, even with the capability enabled on the model.
Set it in `chat_completion` instead, the single handler all of those callers go through, and drop the two duplicate copies (the Anthropic-compat handler and the frontend). Capabilities are read from the resolved model before the custom-model fallback can rebind it, and the flag is applied after the model's `stream_response` override so a non-streaming model is unaffected.
Fixes#27653
The "expand input" button was positioned with `fixed top-0 right-0`. That only kept it near the composer by accident: `#message-input-container` sets `backdrop-blur-sm`, and a backdrop-filter makes an element the containing block for fixed descendants, so the button resolved to the top-right corner of the entire composer instead of the text area it belongs to.
That corner is already taken. The `@`-tagged model chip renders as the first row of the same container with its dismiss button at the right end, so with a multi-line prompt and a tagged model the two controls are drawn on top of each other. The attached-files row has the same problem: the button paints over the first thumbnail and its remove button.
Anchor the button to the wrapper that holds the text area instead, using `relative`/`absolute`, so it always sits at the top-right of the input row and below whatever rows precede it. With no chip and no files the position is unchanged. As a side effect the button is no longer a child of the `overflow-auto` scroller, so it can no longer be clipped or scrolled out of view on long prompts.
Fixes#26736
`get_message_list` moves through `messages_map` by key but tracked each message's own `id` field, which the message body does not have to carry. Track the key instead.
The key generation loop redirected input from a non-existent file
(`SET /p WEBUI_SECRET_KEY=<!random!>>%KEY_FILE%`), printing "The system
cannot find the file specified." once per iteration and leaving the key
file empty, so startup failed with "WEBUI_SECRET_KEY is not set".
Build a fixed-length alphanumeric key by indexing into a charset with
%RANDOM% and write it once with `<nul set /p`. Also quote the key file
path and use delayed expansion so paths with spaces work.
Claude-Session: https://claude.ai/code/session_01CmgBivWjad68mX4yBVWMi2
Co-authored-by: Claude <noreply@anthropic.com>
With ENABLE_OTEL and ENABLE_OTEL_LOGS set, InterceptHandler builds the message once for loguru and then hands the same LogRecord to the OpenTelemetry handler, whose _translate calls record.getMessage() a second time. That used to be free, because the message was already a finished f-string with nothing to substitute. Now that log calls pass lazy %-args, the second call re-runs the whole interpolation, so every exported record is formatted twice.
The two getMessage() calls on a 78 kB retrieval record:
before 373.0 us
after 0.1 us
Stamping the built message back onto the record makes the second call a plain string return. msg and args are both in OpenTelemetry's _RESERVED_ATTRS, so neither ever reaches the exported attributes. The isinstance guard matters: _translate exports a non-str msg such as the dicts routers/audio.py logs as a typed body rather than a string, so those records are left untouched, and they have no %-args to format twice anyway. Body, attributes and severity were compared against LoggingHandler._translate for str, dict, list, int, None, exception and exc_info records.
SRC_LOG_LEVELS became an empty dict when per-module log levels were dropped, and env.py keeps it only as a legacy name. opengauss.py is the last thing in the tree that still indexes it, at module scope, so importing the module raises KeyError: 'RAG' and any deployment on VECTOR_DB=opengauss dies the first time it touches the vector store. The factory imports it lazily, which is why nothing else trips over it. Deleting the line is the whole fix: every other vector backend takes getLogger(__name__) and inherits the root level.
colbert.py passes an argument to a message with no placeholder to consume it:
log.info('ColBERT: Loading model', name)
At INFO, which is the default, logging evaluates 'ColBERT: Loading model' % ('colbert-ir/colbertv2.0',) and raises TypeError: not all arguments converted during string formatting. The record is swallowed by handleError, so loading a ColBERT reranker prints '--- Logging error ---' plus a traceback to stderr instead of the model name. Adding %s prints the name and drops the traceback.
When ENABLE_OAUTH_PERSISTENT_CONFIG is off (the default), oauth.* config is
never persisted and is read from environment variables, but the admin panel
still let admins edit the OAuth/OIDC fields and silently dropped every save on
restart, which kept confusing users who missed the docs warning
(open-webui/open-webui#28247).
The OAuth/OIDC section is now read-only in that case: the admin oauth config
endpoint reports the flag and the UI wraps the section in a disabled fieldset,
slightly dimmed with every control inert but all values still visible, plus a
note naming the env var. Saving skips the OAuth POST since nothing can change.
With the flag enabled the section behaves exactly as before.
Known limits: the guard is UI-side only (the POST endpoint keeps accepting
writes, unchanged), and disabled fields mean values cannot be selected and the
masked client secret cannot be revealed while read-only. Switch.svelte gains a
disabled:cursor-not-allowed style that applies to any disabled switch app-wide.
Co-authored-by: Tim Baek <tim@openwebui.com>
Logging in through CyberArk Identity dies at the callback with "Unsupported {'app_id'} in header" and the user sees "The email or password provided is incorrect". Any provider that puts a vendor-specific parameter in the ID token header hits this; CAS was already patched by name, CyberArk is the next one.
Authlib 1.7 verifies ID tokens with joserfc, which rejects header parameters it does not recognise. The old fix registered `client_id` so CAS would work, which only ever fixes one provider at a time. This turns off the unknown-header rejection instead, so any private header parameter is ignored rather than fatal. Signature verification, the algorithm allowlist, `crit` handling and value validation of registered headers all still run, so nothing that actually protects the token is relaxed.
Fixes#28062
Since v0.11.0 shipped aiodns, aiohttp silently switched every outbound request from the OS resolver to c-ares. On some Windows hosts the bundled c-ares 1.34.6 (pycares 5) discovers only 127.0.0.1:53 as nameserver, so every external provider lookup fails (#28013). In Docker the long-lived c-ares channel intermittently stops resolving container names while Docker's embedded DNS keeps answering, which wipes the Ollama model list and fails all in-flight chats with a misleading "Model not found" (#28215).
This restores the pre-0.11 ThreadedResolver (OS resolver) by default and gates the c-ares path behind a new env var, AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER, off by default. The event-loop DNS perf improvement is now opt-in for deployments whose resolver setup is known to work with c-ares, instead of a process-wide side effect of the package being installed.
aiodns is also downgraded and pinned to 3.6.1 (pycares<5), the last release before the broken c-ares 1.34.6 build, so opting in does not hit the Windows regression. The hardcoded AsyncResolver in the Mistral OCR loader now follows the same switch. Simply removing aiodns instead was not an option because opting in would then be impossible, and #28215 showed the Docker failure is c-ares itself, not aiodns 4.x.
Newer OpenAI chat models put metadata on the opening line of a colon fence block, like :::writing{variant="email" id="48173" subject="Short question" recipient="mail@example.com"}. The tokenizer matched that line and discarded it, so every block rendered under the same generic "Writing" heading no matter what it contained.
The opening line is now parsed into an attributes map on the token and the header uses it: the subject becomes the title, the recipient follows it and the full string is reachable on hover when the row is too narrow for it. Blocks without metadata render exactly as before, and the other fence types get the parsed attributes for free.
Attributes are read only from inside the {...} braces, not from the whole opening line. Scanning the whole line turned ordinary prose containing key="value" into metadata, and it backtracked quadratically: a 40k character opening line took 586ms to parse, and that runs again on every re-lex while the message streams. Anchored to the braces it is 0.0ms.
Nothing here turns the recipient into a link or a send action. That metadata is model output and can be steered by whatever is in the context, so a prefilled mail action is a separate decision rather than a side effect of parsing.
Adding a model ID that was already on the list in the connection settings modal simply appended it again, so the same model could sit in the whitelist any number of times. The arena model modal had the same flaw, its dropdown kept offering models that were already selected.
The connection modal now rejects a duplicate with a toast and trims the input first; surrounding whitespace renders invisibly in the list, so an untrimmed ID would slip past the duplicate check and still show up as a visually identical row. The arena modal instead filters already-added models out of the dropdown, matching the existing model selector in the admin settings, so a duplicate can no longer be picked at all. Both modals also drop duplicates when loading a stored list, so configs that already contain them are cleaned on their next save.
Until such a config is re-saved, one residual effect of old data remains: a duplicated ID in an arena model's stored list keeps double weight in the random model draw. New duplicates can no longer be created through the UI.
Fixes#28249
Chat creation and chat moves each carried their own copy of the same folder_id validation, resolving the folder and checking ownership and shared write access in slightly different ways. Both now call a single has_folder_write_access helper, which the chat-completions creation path uses as well, so ownership, inherited write grants and nonexistent or malformed ids behave identically everywhere a chat folder_id is set. The owner case also costs one query fewer than before.
JSONField serializes with JSONCodec, but columns declared as SQLAlchemy's own JSON
type go through the engine's serializer instead, and no engine set one. That left
Chat.chat - the largest blob the app stores - on stdlib json.dumps/loads no matter
what ENABLE_ORJSON was set to, while the rest of the app used the codec. SQLAlchemy
invokes it once per write and once per read, so every chat read and write paid a
full stdlib pass over the whole conversation on top of whatever the caller did.
Both engine constructors are now wrapped so the codec is wired in by default and
cannot be missed by a call site that forgets it; an explicit json_serializer still
wins. The 10 create_engine/create_async_engine calls in this module go through the
wrappers. Vector-store engines (pgvector, mariadb, opengauss) are separate databases
and are left alone.
Serializing and deserializing chat-shaped blobs, median of 11 runs:
| chat blob | write | read |
| --- | --- | --- |
| 600 msgs (2.8 MB) | 10.1 -> 1.7 ms | 8.4 -> 3.7 ms |
| 3000 msgs (14.2 MB) | 51.9 -> 8.0 ms | 48.5 -> 27.8 ms |
| 6000 msgs (28.5 MB) | 105.9 -> 29.5 ms | 112.7 -> 80.5 ms |
With ENABLE_ORJSON off JSONCodec is stdlib json, so this is a no-op until the flag
is set - the change cannot regress a default deployment.
With it on, a round-trip probe through a native JSON column returns objects equal to
the stdlib ones on all 12 shapes tried: ASCII, CJK, emoji, astral-plane, unicode
keys, null bytes, lone surrogates, floats, ints above 2**63 and 2**64, line
separators, empty and deeply nested. Stored text changes for non-ASCII, which is
written as raw UTF-8 rather than backslash-uXXXX escapes and is correspondingly
smaller. Nothing queries that text by escape except two Postgres safety filters in
chats.py, and both still hold: a null byte is escaped identically by both codecs,
and the title filter reads a text column rather than JSON. The ->> and json_extract
searches decode the string before matching, so escaping cannot reach them.
Two differences are inherent to JSONCodec and already apply to every JSONField
column: ints beyond 2**64-1 come back as float, and NaN/Infinity serialize to null
rather than the bare literals stdlib emits - the latter being invalid JSON that a
Postgres json column rejects today. Neither shape occurs in chat blobs. Alembic
builds its own engine and stays on stdlib, which is fine in both directions since
each codec reads the other's output.
Claude-Session: https://claude.ai/code/session_014BXoM6QiFJKisxcxKAXii8
Co-authored-by: Claude <noreply@anthropic.com>
The Responses API handler had a branch for response.output_item.done whose own comment said it was handled specifically below, but it never ran. The generic branch matching any response.*.done event came first in the chain and matched this event too, so it fell through and returned the accumulated output unchanged, leaving the dedicated branch below unreachable since the feature was added.
Moving the dedicated branch above the generic one makes the event apply. On a compliant stream this changes nothing, since response.completed replaces the whole output with the same data straight afterwards. It matters when a provider is less tidy: one that never sends response.content_part.added leaves the assistant's own reply unextractable from the next turn's context, and one that omits response.content_part.done drops the annotations that only arrive with the finished item. Both are repaired by honouring the event.
Worth knowing: the item replaces whatever the deltas accumulated, with no guard against a provider sending back less than it streamed. A reasoning item arriving without its content would therefore lose the reasoning body, which is the same shape of provider brokenness that #27800 already needed a guard for.
An interactive prompt raised by __event_call__ was meant to come back as an error dictionary when it timed out. It never did: sio.call raises socketio.exceptions.TimeoutError, which does not inherit from the builtin TimeoutError the handler was catching, so the exception escaped into plugin code instead. Because that exception carries no message, the call sites that wrap plugin calls in except Exception as e turned it into an empty string, so a timed-out prompt looked like an empty answer rather than a failure, and the error branches written for it were dead.
The handler now catches socketio's class alongside the builtin, so a timeout returns the intended error dictionary and a plugin can tell the two apart.
The session eviction that sat inside that handler is removed rather than switched on. It had never executed, and it is wrong in both directions: it compares the pool entry by value, which the heartbeat rewrites every thirty seconds, so it would usually not fire, and when it did fire on a short timeout it would evict a live tab whose user had simply not answered yet, with nothing to restore the entry short of a reload. Genuinely dead sessions are already reaped on missed heartbeats by periodic_session_pool_cleanup.
WEBSOCKET_EVENT_CALLER_TIMEOUT is unset by default, which means no timeout at all, so this only affects deployments that set it.
The generic response.*.delta branch could leave the streaming handler in two states that crash the caller. It bound its result only inside the guard that checks the target item exists, but returned that result outside the guard, so a delta arriving before its output item, or carrying an index past the end, raised UnboundLocalError. Separately, an event name with only two dot-separated parts failed the length check and fell off the end of the branch, so the function returned None and both call sites raised TypeError unpacking it.
Where the response is streamed to a browser both crashes were swallowed at debug level and cost a chunk. On the direct API path there is no handler between here and the server, so the caller kept its 200 while the body was cut short with no [DONE], and the outlet filters never ran.
The return now sits inside the guard with a branch-level fallback that hands back the accumulated output untouched, which is what the sibling done branch and every other skip path in this function already do. Deltas whose item exists behave exactly as before.
Dropping an orphan delta is deliberate rather than synthesizing the missing item: response.output_item.added appends without regard to output_index, so a placeholder would be duplicated when the real item arrives, and a fabricated function_call would have no name or call id.
Raising GLOBAL_LOG_LEVEL to WARNING buys quieter output but not less work: 241 INFO call sites interpolate their payload into an f-string before the logging call gets to drop it. The heaviest is get_doc, which logs every chunk id and metadata dict in a collection, so on the full-context retrieval path that is the entire knowledge base, once per chat request.
That one line at WARNING, CPython 3.12:
| knowledge base | payload | before | after |
| -------------- | ------- | -------- | ------- |
| top-k of 3 | 1.2 kB | 3.8 us | 0.07 us |
| 500 chunks | 201 kB | 583.6 us | 0.08 us |
| 5000 chunks | 2.0 MB | 5.8 ms | 0.15 us |
The lazy form log.info('query_doc:result %s %s', result.ids, result.metadatas) hands the payload to record.getMessage(), which the InterceptHandler only reaches once a record has passed the level check. Output at INFO is byte-identical. Two sites that already built their message eagerly, one str concat and one % operator, move to the same lazy form.
`backend/open_webui/models/chats.py` imports `json`, but the module contains no `json.` references. The only remaining matches in the file are SQL function names such as `json_each` and `json_typeof` inside `text()` strings, which are unrelated to the import.
Noticed while profiling the chat read/write path: the import made it look as though the module serialized locally, when all of that happens in `utils/misc.py:sanitize_data_for_db`.
One-line deletion, no behaviour change.
The `response.completed` handler replaced the accumulated output with the terminal event's `output` whenever that key was present, guarded only by `is not None`. An empty array satisfies that guard, so a provider that finishes the stream with `"output": []` wiped everything collected from `response.output_item.added`, `response.output_text.delta` and `response.output_item.done`.
The assistant message was then persisted with `output: []` and empty content, which shows up as a reply that renders correctly while streaming and disappears the moment the stream ends.
Fall back to the accumulated output when the terminal array is empty. A spec-compliant `response.completed` still wins, since a populated array is truthy, and when nothing was streamed the accumulated output is empty too, so the fallback cannot invent content.
Fixes#27789
Permission checks are the most repeated database work in a request, and every one of them asks the same question: which groups is this user in. Today that question cannot use an index.
`group_member` has only its primary key and a `(group_id, user_id)` unique constraint. That constraint leads on `group_id`, so a lookup by `user_id` has to walk the entire membership table, every time. `Groups.get_groups_by_member_id` sits under `has_permission`, `has_access`, `check_model_access` and the `AccessGrants` fallbacks, so an ordinary chat completion pays that walk several times before the model is even called, and the admin user list pays it once per row.
The cost scales with total memberships across all users rather than with the size of any one user's, so it stays invisible on a small instance and then arrives all at once on a large one.
Measured on SQLite, timing the real join from `get_groups_by_member_id`:
| memberships | before | after |
|---|---|---|
| 5,000 | 0.04 ms | 0.03 ms |
| 50,000 | 0.10 ms | 0.04 ms |
| 200,000 | 1.33 ms | 0.04 ms |
| 500,000 | 2.94 ms | 0.04 ms |
The after column is flat because the lookup becomes a seek instead of a scan. Concretely: on a deployment with 500k memberships, say 10,000 users in 50 groups each, one chat completion currently spends roughly 15 ms of database time answering the same question over and over. Afterwards it is under 0.2 ms. On a small install you will not be able to measure the difference, and that is fine, the point is that the curve stops bending.
The index is `(user_id, group_id)`. The trailing column makes those lookups index-only, since `group_id` is the column they select. Queries that lead on `group_id`, such as `get_group_user_ids_by_id` and the `chat_messages` subqueries, are already served by the existing unique constraint and are unaffected.
What to expect when the migration runs: on PostgreSQL this is a plain `CREATE INDEX`, which takes a SHARE lock, so reads continue while writes to `group_member` block until it completes. The table holds one row per membership, so expect sub-second even on the numbers above. `CONCURRENTLY` cannot be used here because the migration runner wraps the upgrade in a transaction, and it is not warranted at this table size.
GLOBAL_LOG_LEVEL defaults to INFO, so every log.debug(...) in the backend is discarded, but the message is built first: 187 call sites interpolate their payload into an f-string before the logging call runs, so the work happens on every request and the result is thrown away. The worst one sits in process_chat_payload and stringifies the whole request body, full conversation history included, once per chat completion.
That one line with DEBUG disabled, CPython 3.12:
| conversation | payload | before | after |
| ------------ | ------- | -------- | ------- |
| 4 messages | 1.2 kB | 3.4 us | 0.07 us |
| 20 messages | 17 kB | 24.8 us | 0.07 us |
| 60 messages | 123 kB | 216.6 us | 0.07 us |
The lazy form log.debug('form_data: %s', form_data) hands the payload to record.getMessage(), which the InterceptHandler only reaches once a record has passed the level check. With DEBUG enabled the emitted lines are byte-identical, f'{x=}' sites included: those map to %r. MistralLoader._debug_log callers get the same treatment, since that wrapper already forwards *args.
`get_permissions` deep-copies the default permission tree with a `json.loads(json.dumps(...))` round trip before merging group permissions into it. It runs on signin, signup, the permissions endpoint, OAuth, and the chat-completion middleware.
It now goes through `JSONCodec`, which selects orjson when `ENABLE_ORJSON` is set. The intermediate string never leaves the expression, so neither the escaping nor the separator differences between the two backends are observable; only the resulting object is used.
`default_permissions` always originates from `Config.get('user.permissions')`, a SQLAlchemy `JSON` column, so the tree is JSON-native by construction and the round trip is exact.
Note for anyone tempted to simplify this to `copy.deepcopy`: measured on the real `DEFAULT_USER_PERMISSIONS` shape over 200k iterations, `deepcopy` takes 3.51s against 1.45s for the stdlib round trip and 0.40s for orjson. The round trip is the fast option, not a workaround.
With `ENABLE_ORJSON` unset, which is the default, `JSONCodec` is stdlib `json` and this call site behaves exactly as before.
The code interpreter parses every message from the Jupyter kernel websocket with stdlib `json`, in a loop that runs for the duration of an execution. Messages carrying large stdout or a base64 image payload are the expensive ones.
It now goes through `JSONCodec`, which selects orjson when `ENABLE_ORJSON` is set. Every consumer of the parsed message reads strings only: `content.text`, `content.data['text/plain']` and `['image/png']`, `content.traceback`, and `content.execution_state`. Jupyter renders large integers into `text/plain` as strings rather than JSON numbers, so no numeric round trip is involved.
The one-shot `execute_request` message this module sends keeps stdlib `json`; it is a small fixed-shape dict sent once per execution.
With `ENABLE_ORJSON` unset, which is the default, `JSONCodec` is stdlib `json` and this call site behaves exactly as before.
`_json_to_metadata` parses the metadata of every result row returned by search and get. It now goes through `JSONCodec`, which selects orjson when `ENABLE_ORJSON` is set.
The text it parses is produced by Oracle's own `JSON_SERIALIZE`, and the column is a native `JSON` type, so the database normalises whatever was written and the reader never depends on the writer's escaping.
The matching `_metadata_to_json` write deliberately keeps stdlib `json`: it passes `default=self._decimal_handler`, orjson accepts none of stdlib's keyword arguments, and dropping the handler would turn a currently successful insert of a `Decimal` into a hard failure. The read side has no such constraint.
With `ENABLE_ORJSON` unset, which is the default, `JSONCodec` is stdlib `json` and this call site behaves exactly as before.
`routers/ollama.py` serializes the outbound body with stdlib `json` on six inference paths: `/api/chat`, the OpenAI-compatible completions and chat completions proxies, embeddings, the Anthropic messages proxy, and responses. All six carry a full conversation or an embedding batch.
They now go through `JSONCodec`, which selects orjson when `ENABLE_ORJSON` is set. Every one is passed to `send_request`, which hands it to aiohttp as `data=`; aiohttp encodes `str` as UTF-8 and derives `Content-Length` from the encoded bytes. None is hashed, cached, length-measured or persisted.
Admin model management keeps stdlib: `/api/unload`, `/api/pull`, `/api/delete` and `/api/show` serialize fixed one- or two-key dicts, as do the blob download and upload progress events and the error frames. Codec dispatch on those costs about what it saves.
With `ENABLE_ORJSON` unset, which is the default, `JSONCodec` is stdlib `json` and this call site behaves exactly as before.
`passthrough_anthropic_messages` in `main.py` serializes the full request payload with stdlib `json` before sending it upstream. It is the largest single serialization on that path, since the body carries the whole conversation.
It now goes through `JSONCodec`, which selects orjson when `ENABLE_ORJSON` is set. The result is passed to aiohttp as `data=`, which encodes `str` as UTF-8 and sets `Content-Length` from the encoded bytes. The payload originates from a parsed request dict, so it holds only JSON-native types, and the serialized string is never hashed, compared or persisted.
The remaining stdlib `json` calls in this module are left alone: two are a debug log line and a fixed Ollama unload payload, and one parses an upstream error body.
With `ENABLE_ORJSON` unset, which is the default, `JSONCodec` is stdlib `json` and this call site behaves exactly as before.
`request.app.state.MODELS` is a `RedisDict` when Redis is configured. Unpacking it with `{**pool}` makes Python call `keys()` and then `__getitem__` once per key, which is one HKEYS plus one HGET per model, issued sequentially through a synchronous client. At 200 models that is 201 blocking Redis round trips per call.
`RedisDict.items()` is a single HGETALL, so `dict(pool.items())` fetches the same data in one round trip. `utils/chat.py:184` already does exactly this and carries a comment explaining why; these ten call sites were missed.
They are on the direct-connection branch of the task endpoints (title, tags, follow-up, autocomplete, query generation and the rest), of `chat_completed`, and of context compaction, so they run for background tasks fired on ordinary chat turns.
Behaviour is unchanged. The merged mapping is identical, the explicitly added direct model still overrides any pool entry with the same id, and when Redis is not configured the pool is a plain dict where `dict(d.items())` and `{**d}` are equivalent.
It also closes a race. `RedisDict.set` writes with HSET and then HDELs the stale keys, so a key returned by HKEYS could be deleted before its HGET arrived, raising `KeyError` out of the dict literal and failing the request mid model refresh. The old path could likewise observe a mix of pre- and post-refresh entries. HGETALL is atomic, so the caller now always sees one coherent snapshot.
Fourteen modules import `json` without using it. Ruff flags every one with F401, and a word-boundary search for `json` in each file matches only the import line itself, including inside strings, comments and annotations.
Two exclusions, both deliberate. Migration files are left alone: the import is equally dead there, but those files are frozen history and not worth the churn. `models/chats.py` has the same dead import and is handled in its own change, so it is skipped here to avoid two changes touching the same line.
No behaviour change.
orjson emits U+2028, U+2029 and U+0085 raw, where stdlib `json.dumps` escapes them under its default `ensure_ascii=True`. Python treats all three as line boundaries, so with `ENABLE_ORJSON` set, one of them inside model output splits a `data: {...}` SSE frame in half. Both halves then fail to parse and the delta is dropped with no error.
`utils/middleware.py` reassembles frames with `splitlines()`, so an affected response silently loses content on the direct API path. External clients are exposed as well: httpx's `LineDecoder` reimplements the same line-boundary semantics, so any SDK reading the OpenAI-compatible stream through `aiter_lines` breaks on a raw separator.
The three characters are escaped on the way out of `ORJSONCodec.dumps`. That restores parity with stdlib and fixes every reader at once, rather than patching one consumer and leaving external clients broken. They are the complete set: of the ten code points `splitlines()` treats as boundaries, the other seven are below U+0020, where JSON already forces an escape.
The membership guard is load bearing. Calling `translate` unconditionally costs roughly 1.5 us on a typical SSE chunk against 0.115 us for the serialization it wraps, so it would spend more than orjson saves. The three scans cost about 0.04 us.
Payloads containing none of the three are returned unchanged, byte for byte. With `ENABLE_ORJSON` unset, which is the default, none of this code runs.
U+2028 and U+2029 are common in text extracted from PDFs and word processor documents, so the realistic trigger is a model quoting an uploaded file back to the user.
The Valkey backend serializes chunk metadata on every insert and parses it back on every result row in `get` and `query`. Both directions now go through `JSONCodec`, which selects orjson when `ENABLE_ORJSON` is set.
The stored `metadata_json` field is never matched against as text. `_build_filter_expression` only emits TAG predicates, and the TAG fields are `id`, `hash`, `file_id`, `source` and `knowledge_base_id`; `metadata_json` appears only as a return field that is immediately re-parsed. So rows written with escaped non-ASCII and rows written raw are indistinguishable to every reader, and no migration is needed.
`process_metadata` already stringifies datetimes and strips null bytes and lone surrogates before the write, so the two backends cannot disagree about what is serializable here.
Both read `except` clauses widen from `(json.JSONDecodeError, TypeError)` to `(ValueError, TypeError)`. The codec falls back to engineio's codec, which installs `parse_int=_safe_int` and raises a bare `ValueError` for integer literals longer than 100 characters; the narrower clause would have let that escape and abort a search instead of yielding empty metadata. `json.JSONDecodeError` is a `ValueError` subclass, so this is a strict superset. That removes the module's last use of stdlib `json`, so the import goes with it.
With `ENABLE_ORJSON` unset, which is the default, `JSONCodec` is stdlib `json` and this call site behaves exactly as before.
RAG vector search runs in a thread pool, but then calls `future.result()` on the event loop thread, so the whole worker freezes until every collection answers. Every other user's token stream stops for that long. It's the default retrieval path.
Now `asyncio.gather` over `asyncio.to_thread`, matching what `routers/retrieval.py:2779` already does for the same call.
Measured with 3 queries across 4 collections, 60 ms search, and a second request wanting a turn every 5 ms:
| | before | after |
|---|---|---|
| RAG call | 62.0 ms | 61.2 ms |
| other request's turns | 0 | 7 |
| its worst stall | 62.5 ms | 16.0 ms |
Same results, same order, same `(result, error)` contract. Cancellation now lands mid-search instead of after every thread finishes. Threads move from an unbounded per-call pool to the loop's bounded shared one.
Swap the JSON encoder/decoder used across the backend from stdlib json to
orjson when ENABLE_ORJSON is set — HTTP request bodies, JSONResponse
bodies, upstream provider responses, SSE chunks, and socket.io/Redis
payloads.
The flag defaults to off, in which case the app uses stdlib json and
engineio's codec verbatim, so default behaviour is unchanged.
- json_codec exports JSONCodec (stdlib json or the orjson codec) and
SOCKETIO_JSON (engineio's codec or the orjson codec); call sites import
JSONCodec and stay implementation-agnostic
- apply_orjson_http_json() is a no-op when the flag is off, leaving
starlette's Request.json / JSONResponse.render untouched
- the orjson codec falls back to the stdlib for inputs orjson rejects
(non-str dict keys, ints beyond 64 bits, NaN literals)
- orjson is imported only when the flag is on
- FastAPI(default_response_class=...) is deliberately not used: an
explicit default disables the Pydantic direct-to-bytes fast path for
response_model routes
build_matcher compiled a caller-supplied pattern with Python's backtracking re and ran it over every line of every reachable file, with no timeout, no thread offload and no length caps. is_regex_pattern promotes any pattern containing a metacharacter, and a bare pipe counts, so no explicit regex flag is needed to reach the compiler. The search loop is synchronous inside an async handler, and UVICORN_WORKERS defaults to 1, so the cost lands on every other user of the instance. MAX_GREP_RESULTS bounds how many matches are reported, not how much work is done.
Backtracking cost is exponential in the length of the text being matched, so capping the pattern or the line does not bound it: the subject in the measurements below is 30 characters. `(x|x)*y` against a line of 30 x took 80 seconds, `(a+)+$` against 32 a took 169 seconds, and the same subject with a literal pattern took 0.6 microseconds.
Matching now runs on the regex module, which accepts a per-search timeout that re has no equivalent for. The timeout is the actual bound: regex resolves many classic catastrophic patterns instantly, but not all of them, and `(a|aa)+$` and `(?:a|a)*$` still need it. The budget covers a whole tool call rather than a single search, because a pipeline builds one matcher per segment and a per-search budget would multiply by segment count, and because a per-line timeout would allow timeout multiplied by line count. It is carried in a context variable so one command shares it without threading a parameter through every handler, and it is charged only for time spent inside search(), so database round-trips and other coroutines cannot consume it. Exhausting it raises, and both entry points already render that as an error for the model to read.
Note for anyone tracking search behaviour: re and the regex module define \w, \W and \b differently on non-ASCII text. re follows str.isalnum(), the regex module follows UTS#18, so \w no longer matches superscripts and fractions such as the ones in Nd-adjacent categories, and now does match combining marks. POSIX classes like [[:alpha:]] are interpreted rather than read as a literal set, and \p{...} compiles instead of erroring. Results on ASCII content are unchanged.
regex was already installed as a transitive dependency of nltk, tiktoken and transformers. It is now declared directly, pinned in pyproject.toml and requirements.txt to the version the lockfile already resolves.
On latest `dev`, the chat that is currently open is indicated **only** by a background tint: `bg-black/[0.035]` in light mode and `dark:bg-white/[0.045]` in dark.
Against the page background that is **1.07:1** in light and **1.05:1** in dark. It is close to imperceptible for sighted users, and it carries no programmatic state at all, so assistive technology has no way to tell which entry in the list is the one being viewed.
Breaks WCAG 1.4.1 Use of Color (Level A), since the state is conveyed by colour alone, and 4.1.2 Name, Role, Value (Level A), since the state is not exposed.
Fix: set `aria-current="page"` on the chat link when it is the open chat, using the same `id === $chatId` condition that already drives the visual highlight, so the two cannot drift apart. `'page'` is the correct token because the trigger is a real navigation to `/c/{id}`.
This matches the existing pattern in `routes/(app)/workspace/+layout.svelte` and `chat/Placeholder/ChatList.svelte`, which already set `aria-current` for their active entries.
Verified that the bits-ui `LinkPreview.Trigger` forwards unknown attributes to the rendered anchor and does not set `aria-current` itself, so the attribute reaches the DOM.
This does not change the visual contrast of the highlight, which is worth addressing separately.
Severity: Serious. In a long chat list there is no reliable way to tell which chat is open.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
On latest `dev`, the sidebar folder row is a bare `<div>` carrying `on:click` (navigate into the folder) and `on:dblclick` (rename). It has **no `role`, no `tabindex` and no key handler**, so opening a folder is impossible from the keyboard.
The nested chevron `<button>` is focusable, but it only expands the folder in place, it does not navigate to it, so there is no keyboard route to the folder page at all.
Breaks WCAG 2.1.1 Keyboard (Level A) and 4.1.2 Name, Role, Value (Level A). The Svelte compiler already flags this file with `a11y_click_events_have_key_events`; after this change the component compiles with zero a11y warnings.
Fix: apply the row pattern already used elsewhere in this codebase (`workspace/Prompts.svelte`, `workspace/Knowledge.svelte`, `admin/Functions.svelte`), namely `role="button"`, `tabindex="0"` and a keydown handler for Enter and Space, with the same `e.currentTarget !== e.target` guard and the same `shouldIgnoreRowClick` helper those files use.
That guard matters more here than in the files it was copied from: the rename `<input>` is rendered **inside** this row, so without it typing a space in the rename field would be swallowed and navigate away, and Enter would both save the rename and navigate.
The navigation body is extracted to `openFolderHandler` because it now has two callers. The keyboard path calls it directly rather than through the 100ms `clickTimer`, which exists only to disambiguate single from double click and has no keyboard equivalent.
A dead `(e) => e.stopPropagation();` expression statement in the click handler is removed. It allocated an arrow function and discarded it without ever calling it.
The `…` folder menu is still `invisible group-hover:visible` and therefore unreachable, so rename, share, delete, export and new subfolder remain keyboard-inaccessible until that is addressed. That is fixed repo wide in a separate PR that replaces the `invisible group-hover:visible` pattern, so it is deliberately not touched here to avoid conflicting on the same line.
Folder reparenting by drag still has no keyboard alternative, which is a separate WCAG 2.5.7 issue needing a "Move" menu action.
Severity: Critical. Folders cannot be opened without a pointing device.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
Co-authored-by: Tim Baek <tim@openwebui.com>
* fix: fetch terminal system prompt per request with TTL cache
The system prompt was only fetched once in set_terminal_servers (startup
or connection save) with a 3s timeout, using a synthetic 'system' user.
That snapshot silently stays empty when the fetch races a cold-started
orchestrator instance, and goes stale when instances are reprovisioned
with a changed OPEN_TERMINAL_SYSTEM_PROMPT — recovering only after a
restart or a manual connection re-save.
- Fetch /system during get_terminal_tools with the user's own
credentials via a central TTL-cached method (5 min per server+user;
failures cached 60s so a dead instance doesn't stall every request),
falling back to the cached snapshot.
- Raise the fetch timeout from 3s to 30s so cold-provisioned instances
can answer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjnQJNKozp47vTB13pRyYs
* refac: fetch the terminal system prompt per request without a cache
Drop the module-level TTL cache and fetch the system prompt directly in
get_terminal_tools, gathered with the existing uncached per-request cwd
fetch that already follows this pattern. The fetch uses the user's own
credentials and falls back to the set_terminal_servers snapshot, so a
cold or unreachable instance degrades to the previous behaviour instead
of needing an error cache. Also restore the 3s timeout: on the request
path a 30s wait would stall chat completions, and a cold instance is
covered by the snapshot fallback until it warms up.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: block external resource loading in Vega chart rendering to prevent client-side SSRF
renderVegaVisualization renders vega/vega-lite chart specs that appear in untrusted chat content (shared chats, channel messages, assistant/RAG/tool output) by constructing a Vega View with no restricted loader, so a crafted spec could make a viewer's browser issue arbitrary outbound requests. There are two paths: data.url (and topojson/geo data) is fetched via loader.load at view construction, and image-mark urls are resolved via loader.sanitize and emitted as <image href> into the output SVG, fetched by the browser when the SVG is displayed. Both are client-side SSRF, and against same-origin or CORS-permissive targets allow reading the response back into the page. Pass a loader that rejects external resource loads on both paths, load throws and sanitize rejects http(s)/protocol-relative URIs, so rendered charts can only use inline data. Inline data.values charts are unaffected.
Co-authored-by: Zureno <Zureno@users.noreply.github.com>
* fix: resolve Vega image urls with the URL parser before blocking external loads
The previous scheme regex could be bypassed with encodings the browser URL parser
normalizes away, such as a leading tab or newline before the scheme and backslash
variants of protocol-relative urls like /\evil.com, which would still be emitted
into the rendered SVG and fetched externally on display. Resolve the uri against
document.baseURI with the browser's own URL parser and only allow data: uris and
same-origin results, so the check cannot diverge from what the browser would
actually fetch. Also shortens the explanatory comments.
---------
Co-authored-by: Zureno <Zureno@users.noreply.github.com>
update_chat_tags_by_id runs at the end of every completion when tag generation is enabled (the default). It loaded the full chat row including the multi-megabyte blob, mutated only meta.tags, committed, then refreshed the row, which re-fetched and re-parsed the entire blob a second time, and finally validated the whole thing into a ChatModel that its only caller (the auto-tagging handler) discards. add_chat_tag_by_id_and_user_id_and_tag_name had the same shape for a one-tag append, and orphan cleanup issued one COUNT query per removed tag.
Both tag writers now select only the meta column and issue a column-level UPDATE, never touching the blob; the single-tag path also skips the write entirely when the tag is already present. Orphan detection batches all per-tag counts into one round trip using one scalar subquery per tag with the exact same dialect-specific EXISTS filters as before; the existing single-tag count delegates to the batch helper so there is one implementation.
Benchmark (real SQLite DB, 200-message chat, ~600 KB blob):
| metric | before | after |
| --- | --- | --- |
| auto-tag update, 3 tags replaced | 12.85 ms | 7.36 ms |
The absolute saving grows with chat size since the blob no longer gets fetched, parsed, re-fetched and validated at all.
Functionally verified against a fresh database: tag replacement normalizes and filters the none placeholder, creates missing tag rows and leaves the blob untouched; orphaned tags are deleted while tags still referenced by other chats survive; single-tag add is idempotent; batch counts agree with the single count including unknown tags; unknown chat ids return None.
POST /knowledge/{id}/sync/cleanup verified write access to the knowledge base in the URL but then acted on the caller-supplied file_ids and dir_ids without checking they belong to that knowledge base. A user with write access to any knowledge base could pass another knowledge base's directory id to delete its directory subtree and knowledge_file associations, or another file's id to drop its file-{file_id} vector collection. Fetch each directory and skip any whose knowledge_id does not match the URL id (matching the explicit directory-delete endpoint), and gate the per-file vector cleanup on Knowledges.has_file(id, file_id) so a foreign file id cannot trigger collection deletion. Legitimate same-knowledge-base cleanup is unchanged.
Co-authored-by: whyiug <whyiug@users.noreply.github.com>
* fix: preserve system prompt across tool calls when memories are enabled
The native tool-call loop runs generate_chat_completion with
bypass_system_prompt=True, so the provider layer does not re-apply the
model's default system prompt on tool-call iterations. It relies instead
on metadata['system_prompt'], captured in process_chat_payload, to carry
the full system prompt forward and restore it after RAG injection.
That capture read the model default system prompt from
form_data['params']['system'], but apply_params_to_form_data had already
popped 'params' from form_data, so model_system_prompt was always empty.
metadata['system_prompt'] therefore only captured whatever was already
materialized in the messages. With memories enabled, that is the injected
<memory_context> system message, so tool-call requests were restored with
memory-only system content and the model's system prompt was dropped.
Without memories there was no system message to capture at all.
Capture the model default system prompt from form_data['params'] before
apply_params_to_form_data pops it, and use that value when building
metadata['system_prompt'].
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Z4L51mvxDJCx1EDxP2vFN
* refactor: condense system prompt capture comment to a single line
Replace the four-line explanation above the model_system_prompt capture with a one-line note. The variable name and the surrounding code already convey what happens; the comment only needs to state why the capture sits before apply_params_to_form_data.
---------
Co-authored-by: Claude <noreply@anthropic.com>
The web-search-* namespace was the one collection namespace filter_accessible_collections admitted unconditionally for any non-admin user, on both read and write, unlike file-*, user-memory-* and knowledge bases which are owner-scoped. process_web_search now mints these ephemeral per-query collections as web-search-{user.id}-<hash>, and the access helper only admits web-search-{requester.id}-* names, so a web-search collection is readable and writable only by the user who created it (admins keep their bypass). The collections hold transient public web-search results and their names are non-enumerable query hashes, so there was no demonstrated cross-user access path; this removes the namespace exception so the per-user scoping the other namespaces enforce also covers web-search.
Co-authored-by: rexpository <rexpository@users.noreply.github.com>
The terminal proxy's system_oauth auth type read the OAuth access token from the client-supplied x-oauth-access-token request header and forwarded it verbatim as a Bearer token to the upstream terminal server, so an authenticated caller could substitute an arbitrary token for the one bound to their own session. Resolve the token server-side from the caller's OAuth session via oauth_manager.get_oauth_token(user.id, oauth_session_id), matching the openai.py proxy, so the forwarded token is always the one Open WebUI issued for the authenticated user and the client header is ignored.
Co-authored-by: brodmart <brodmart@users.noreply.github.com>
Previously, when web search retrieved pages successfully but saving them to the vector DB failed (for example an unreachable or misconfigured embedding endpoint), process_web_search swallowed the exception at debug log level and still returned status: True with the collection name. The chat then showed "Searched N sites" followed by "No sources found" at retrieval time, hiding the actual misconfiguration from the user and making the failure look like a search bug.
process_web_search now logs the failure at exception level and raises an HTTPException with an actionable message pointing at the embedding configuration in Admin Settings > Documents. chat_web_search_handler surfaces the detail of any HTTPException raised during the search in the emitted error status, so the real cause (embedding misconfiguration, search engine errors, no results) is shown in the chat UI instead of the generic "An error occurred while searching the web". Non-HTTP exceptions keep the generic message, so raw internal error strings are not exposed.
Ref #26750, #25038
The .msg branch routed to langchain's OutlookMessageLoader, which requires the extract_msg package. extract_msg pins beautifulsoup4<4.14, but we pin unstructured==0.22.31 (needs beautifulsoup4>=4.14.3) and beautifulsoup4==4.14.3, so extract_msg can never be installed alongside the current dependency set. As a result the .msg path could not function on any supported install: uploads failed at runtime with an ImportError, and adding the missing package broke the build with an unsatisfiable resolver error.
Switch to UnstructuredEmailLoader, which parses .msg through unstructured's partition_msg (backed by python-oxmsg). Both are already shipped, so .msg uploads work with no new dependency and no version conflict. Attachment partitioning is disabled to preserve the previous body-only extraction behaviour.
Fixes#26690
The legacy features block in process_chat_payload honoured client-supplied features.image_generation and features.web_search flags and dispatched to the image generation/edit provider and the web-search provider without re-checking the per-user permission that the direct /images routes and the native function-calling path enforce. A user denied features.image_generation or features.web_search could still trigger billable server-side image generation or web search via POST /api/chat/completions with params.function_calling set to legacy. Gate both branches on admin-or-has_permission before invoking chat_image_generation_handler / chat_web_search_handler, matching the existing code_interpreter gate, so a forged flag from an unpermitted user is ignored. Normal completions and permitted users are unaffected.
The chat completion entry point fetched the model row and then check_model_access immediately fetched the exact same row again. Inside the check, the direct grant lookup and every hop of the base-model chain each refetched the caller's group memberships, because neither call passed user_group_ids even though both AccessGrants.has_access and has_base_model_access already accept it.
check_model_access now takes an optional prefetched model_info (used only when its id matches the requested model, so stale callers cannot bypass the lookup) and resolves the caller's group ids once, sharing them across the direct check and the whole base-model chain. The group fetch is skipped entirely for the owner-with-no-base-chain case, which previously needed no groups either.
DB round trips for one completion-entry access check (non-owner model with one base-model hop):
| queries | before | after |
| --- | --- | --- |
| model row SELECTs | 3 | 2 |
| group membership SELECTs | 2 | 1 |
For deeper base-model chains the before column grows by one group SELECT per hop; the after column stays at one.
Functionally verified with stubbed model, group and grant accessors: owner fast path issues no group or grant queries; a non-owner with a base chain resolves groups once and passes the same set to every hop; a prefetched matching model_info skips the duplicate row fetch while a mismatched one is refetched; denial and unknown-model cases still raise; the arena path is unchanged.
The per-id model endpoint (GET /api/v1/models/model) strips params, the system
prompt and other curated model config, for callers who only have read access.
The list endpoint (GET /api/v1/models/list) did not: it returned each
read-accessible model's full params, so a read-shared model exposed its
params.system to non-owner read-grant holders.
Mirror the per-id behaviour: compute write_access per item and drop params
before serialising when the caller lacks write access (not the owner, not an
admin under BYPASS_ADMIN_ACCESS_CONTROL and holding no write grant). The
model-card list UI does not render params, so this does not change
functionality.
Co-authored-by: bogdancherniy11-sudo <229690748+bogdancherniy11-sudo@users.noreply.github.com>
* fix: do not expose tool source code to read-only users
The tool read endpoints build their responses from a content-bearing model via
model_dump() under ConfigDict(extra='allow'). ToolResponse deliberately omits
content (the Python source) and specs, but extra='allow' re-admits both, and the
get_tools defer_content flag was a no-op, so GET /tools/, GET /tools/list and GET
/tools/id/{id} returned a tool's full source to any caller with mere read access,
including any authenticated user for a publicly read-shared tool. Tool source
commonly embeds hard-coded credentials and internal URLs.
Strip content and specs for callers without write access across the three read
endpoints. Tool execution loads source server-side, so tool use is unaffected,
and writers still receive content where they did before. The duplicated
write-access check is extracted into a small helper.
Co-authored-by: bogdancherniy11-sudo <229690748+bogdancherniy11-sudo@users.noreply.github.com>
* fix: limit the tool source strip to the per-id endpoint
Upstream dev has since fixed the defer_content no-op in Tools.get_tools, so the list endpoints (GET /tools/ and GET /tools/list) no longer fetch tool source at all and the stripping added there is redundant. Stripping specs also broke the chat Available Tools modal, which lists a tool's functions from specs for every user who can use the tool.
Reduce the change to the one remaining leak: GET /tools/id/{id} builds its response from a full model_dump() and ConfigDict(extra='allow') re-admits content, so drop content there for callers without write access. Specs stay visible to read users as before and the helper functions are no longer needed.
---------
Co-authored-by: bogdancherniy11-sudo <229690748+bogdancherniy11-sudo@users.noreply.github.com>
convert_payload_openai_to_ollama deep-copied the entire request payload on every completion routed to an Ollama model, and again on every tool-call iteration. The cost of that copy scales with the number of messages and nested content parts in the history, so long chats pay the most, purely as CPU work before the request even leaves the server.
The function only ever mutates two things: it deletes keys on the top-level dict and on the nested options dict. convert_messages_openai_to_ollama already builds fresh message dicts. Shallow-copying exactly those two levels therefore preserves behavior while removing the whole-tree copy.
Benchmark (per conversion call):
| payload | before | after | speedup |
| --- | --- | --- | --- |
| 200-message text chat (~180 KB) | 0.22 ms | 0.057 ms | 4x |
| 20-message chat + 1 MB base64 image | 0.41 ms | 0.38 ms | 1.1x |
The image row barely moves because deepcopy shares immutable strings; the win comes from container-heavy histories, which are exactly the payloads that grow over a conversation's lifetime.
The output is byte-identical to the previous implementation (verified against it, including dict key order, root parameter hoisting, max_tokens remapping, stop handling and response_format precedence), and the caller's payload is left unmodified exactly as before.
* fix: restrict folder deletion to the owner or an admin
Deleting a folder cascades into the folder owner's chats, messages and the
entire subfolder subtree; the cascade is bound to the folder's owner, not the
caller. The delete handler only enforced owner/admin for root folders.
Subfolder deletion required merely write access, and a write grant on a shared
root folder is inherited by every descendant subfolder. A write-collaborator
could therefore permanently delete the owner's chats by deleting a subfolder of
a shared folder, data they do not own. With delete_contents=false the same path
force-moved the owner's chats out of the folder instead.
This also contradicted the documented sharing model: only the owner or an admin
may delete a shared folder, and write access covers adding and editing chats and
subfolders, not removing the folder.
Because any folder deletion cascades into the owner's data, restrict it to the
owner or an admin for root and subfolders alike, replacing the root/subfolder
split with a single check. Owners and admins are unaffected, and a
write-collaborator can still create, rename and add to shared folders and delete
subfolders they own.
Co-authored-by: legobattman <302282032+legobattman@users.noreply.github.com>
* style: condense the folder deletion authorization comment
Shorten the multi-line comment above the owner-or-admin check to a single line stating why deletion is restricted. The full rationale lives in the pull request description and does not need to be narrated in the code.
---------
Co-authored-by: legobattman <302282032+legobattman@users.noreply.github.com>
KatexRenderer rendered the raw math source through {@html} whenever renderToString threw. throwOnError only suppresses KaTeX ParseError, so a RangeError (maximum call stack size exceeded, reachable with deeply-nested brace input) escaped into the catch and re-exposed the unescaped source. Because the math tokenizer captures everything between the delimiters verbatim, that source can carry an HTML/JS payload which then executed in the viewer's browser on the application origin, a stored, cross-user XSS reachable through normal chat/channel/shared-chat rendering. Escape the fallback so the source is shown as text and is never injected as HTML. Valid math is unaffected, it still renders through the success path.
Co-authored-by: maxntv <maxntv@users.noreply.github.com>
Adds a multiselect input type for Valves and UserValves so plugin authors can let users pick multiple values from static or runtime-resolved options instead of maintaining comma-separated text fields with hardcoded allowed-value lists in the description.
ENABLED_ITEMS: list[str] = Field(
default=["foo"],
json_schema_extra={"input": {"type": "multiselect", "options": "get_item_options"}},
)
@classmethod
def get_item_options(cls):
return [{"value": "foo", "label": "Foo"}, {"value": "bar", "label": "Bar"}]
Options accept the same shapes as the existing select input: either a static list (strings or {value, label} dicts) or a classmethod name resolved at request time (including __user__ context for UserValves). No backend changes are needed because resolve_valves_schema_options already resolves options independently of the input type.
The new MultiSelect component follows the existing Select portal dropdown pattern and renders checkbox rows that stay open while toggling, with the selected labels shown in the trigger. Values bind as a real string array end to end: the array-to-comma-string conversions in the chat controls valves panel and the valves modal are skipped for multiselect fields, so the stored valve is a native list[str] validated by Pydantic.
Requested in #26848.
_sanitize_proxy_path decodes the path and then relies on posixpath.normpath plus a leading '..' check. posixpath splits on '/' only, so a backslash run is treated as part of a single path component: 'foo/..\..\etc' normalizes to itself, does not start with '..' and is forwarded unchanged, reaching the upstream as '/foo/..%5C..%5Cetc'. An upstream that treats the backslash as a separator would resolve those '..' sequences.
Reject any path containing a backslash after decoding, matching the existing fail-closed behaviour for paths that are still encoded past the decode cap. A backslash is not meaningful in the upstream API paths this route proxies, so legitimate requests are unaffected.
Co-authored-by: babakizo420 <babakizo420@users.noreply.github.com>
stream_wrapper without a content handler iterates aiohttp's response.content, which reads line by line: every line costs a buffer scan, a slice, a bytes concat, a generator resume and its own ASGI response message. A typical SSE event is two lines (the data line and the blank separator), so every upstream token event became two yields and two transport writes even on routes where the body is never inspected.
stream_wrapper now takes passthrough=True, which iterates response.content.iter_any(): the exact same bytes, one yield per network read, no line scanning. It is applied only to routes no internal consumer parses line-by-line: the ollama pull/push/create/generate proxies and its v1 completions, chat completions, messages and responses endpoints, plus the openai embeddings, responses and catch-all proxies. The two internally consumed chat routes keep line iteration, which the streaming middleware and the Ollama-to-OpenAI converter require; the ollama send_request signature documents that constraint.
Benchmark (local aiohttp SSE server, 500 events, consumed through stream_wrapper):
| metric | before (readline) | after (iter_any) |
| --- | --- | --- |
| stream consumption time | 1.46 ms | 0.62 ms |
| generator yields + response writes per stream | 1000 | 1 |
The single yield is a loopback artifact (the whole body arrives in one buffered read); over a real network it becomes one yield per TCP read instead of two per SSE event.
Functionally verified: line mode and passthrough mode produce byte-identical output for the same stream, and passthrough always yields fewer, larger chunks.
_process_picture_url validated the picture URL with validate_url() but then fetched it with a plain aiohttp session that resolves the hostname again at connect time, leaving a DNS-rebinding TOCTOU window (the same gap already closed for the RAG loader, the content probe, the image fetches and webhook delivery). Routing the fetch through get_ssrf_safe_session() pins the connect-time resolution via _SSRFSafeResolver and rejects non-global addresses, so a rebinding host can no longer redirect the fetch to loopback, RFC1918 or cloud-metadata endpoints. It also stops the forwarded OAuth access_token from leaking to a rebound internal target.
get_ollama_versions was the only Ollama route besides the static health check without an authentication dependency, so an anonymous caller could read the configured backend's version string and, by walking url_idx until the lookup raised, count the configured backends.
Nothing depends on the route being public. The frontend wrapper takes a token and sends it on every call, and its three call sites (admin model management, the model selector and the About panel) all pass an authenticated token, so the client already treats this as an authenticated route. Add the same get_verified_user dependency the sibling routes carry.
Co-authored-by: Grg0rry <Grg0rry@users.noreply.github.com>
aiohttp resolves every hostname with ThreadedResolver unless the aiodns package is importable, and ThreadedResolver runs socket.getaddrinfo on asyncio's default ThreadPoolExecutor. That executor is capped at min(32, cpu_count + 4) threads and is shared with every other piece of blocking work posted to it, so DNS is currently a bounded blocking resource sitting in front of every model call, every web search fetch, every RAG page load and every tool call. In plain terms: once that pool is busy, requests wait on name lookups that should never have occupied a thread at all.
This is a dependency-only change. aiohttp sets `DefaultResolver = AsyncResolver` as soon as aiodns is importable (aiohttp/resolver.py), so resolution moves onto the event loop via c-ares with zero application code touched. That is deliberate rather than lazy: there are 50 `aiohttp.ClientSession(...)` construction sites in the backend, most building a fresh default connector per call, and the alternative of passing `resolver=aiohttp.AsyncResolver()` explicitly would mean touching all of them and re-touching every future one. The shared pool in `utils/session_pool.py` does set `ttl_dns_cache`, but that only helps the shared pool. Every per-request session, including `SafeWebBaseLoader._fetch()` which builds a new session per URL, starts with a cold DNS cache and resolves from scratch.
It also unbreaks a code path that is dead today. `backend/open_webui/retrieval/loaders/mistral.py:480` constructs `aiohttp.AsyncResolver()` unconditionally, and `AsyncResolver.__init__` raises `RuntimeError("Resolver requires aiodns library")` when aiodns is absent, so the Mistral OCR content extraction engine fails on a stock install. This supplies the dependency that line already assumes. Once it is present that kwarg is redundant, since it now names the default, and dropping it is a reasonable follow-up. Reproduced by blocking the aiodns import:
```
aiodns importable: False
DefaultResolver: ThreadedResolver
AsyncResolver(): RuntimeError: Resolver requires aiodns library
```
## Benchmarks
Both sides run the real aiohttp resolver classes. The DNS wire time is replaced by an identical fixed 50ms delay on both sides, so the only variable measured is where that delay is spent. 24 cores, so the default executor holds 28 threads. `exec_max` is the worst latency an unrelated `run_in_executor` job suffered while the lookups were in flight.
Concurrent lookups, wall time:
| concurrent lookups | ThreadedResolver | AsyncResolver | speedup | exec_max before | exec_max after |
|---|---|---|---|---|---|
| 16 | 54.3ms | 41.0ms | 1.3x | 2.1ms | 1.9ms |
| 32 | 101.9ms | 50.6ms | 2.0x | 36.6ms | 1.8ms |
| 64 | 152.5ms | 43.2ms | 3.5x | 88.4ms | 2.0ms |
| 128 | 254.9ms | 44.5ms | 5.7x | 190.3ms | 2.2ms |
| 256 | 508.2ms | 50.7ms | 10.0x | 443.8ms | 2.4ms |
| 512 | 965.2ms | 47.8ms | 20.2x | 900.2ms | 2.6ms |
ThreadedResolver scales linearly with concurrency because it can only run 28 lookups at a time. AsyncResolver stays flat at roughly the cost of one lookup.
The reverse direction is worse and is not hypothetical. Open WebUI already posts long blocking jobs to that same executor (`retrieval/vector/dbs/pinecone.py:323` batch upserts, `retrieval/loaders/youtube.py:156` transcript loads). With 28 such jobs holding the pool, a single DNS lookup waits for them to finish:
| | one DNS lookup |
|---|---|
| ThreadedResolver | 1989.7ms |
| AsyncResolver | 58.9ms |
A Pinecone bulk upsert currently stalls name resolution for every other user on the instance. After this change it cannot.
At low concurrency on a real network the two are equivalent, as expected: 8 concurrent lookups against disjoint cold hostname sets landed within noise of each other in both directions.
## Behaviour verification
Checked against Open WebUI's own code, not in isolation:
- c-ares reads the system hosts file. Verified against a machine whose hosts file maps `adobe.io` to `0.0.0.0`, an address real DNS never returns for that name: c-ares returned `0.0.0.0`. `host.docker.internal`, compose `extra_hosts` and Kubernetes `hostAliases` keep working.
- `_SSRFSafeResolver` subclasses `aiohttp.resolver.DefaultResolver`, so this change swaps its base class from ThreadedResolver to AsyncResolver at runtime. It still resolves public hosts, still returns entries with the `host`/`port` keys the SSRF check reads, and still raises on a private address: resolving `localhost` raised `ValueError: The URL you provided is invalid.`
- A real fetch through `get_ssrf_safe_session()` returned 200.
- NXDOMAIN still surfaces as `aiohttp.ClientError` (`ClientConnectorDNSError`), not a c-ares specific exception, so existing error handling is unaffected.
Known limit: c-ares reads `/etc/resolv.conf` and the hosts file but not the rest of `nsswitch.conf`. Names served only by an NSS module, such as `.local` via avahi/mDNS, NIS/LDAP backends or Windows NBNS, will resolve differently or not at all. On a multi-homed test machine the local hostname returned two addresses through the system resolver and one through c-ares. Deployments pointing Open WebUI at an mDNS or NetBIOS hostname are the group affected. Resolver failures also arrive as plain `OSError` rather than `socket.gaierror`, which no code in this repo catches today.
Custom per-connection headers can now forward the user's groups to
upstream backends via two new template placeholders:
- {{USER_GROUPS}}: comma-separated group names
- {{USER_GROUP_IDS}}: comma-separated group ids
The group lookup is async, so get_custom_headers becomes an async
wrapper around the sync template substitution (parse_custom_headers)
and fetches groups lazily — only when a header value actually
references a groups placeholder. The external document loader path
runs in a worker thread without an event loop, so Loader.aload
prefetches the groups before offloading and passes them through to
ExternalDocumentLoader.
Claude-Session: https://claude.ai/code/session_01EbBEfTyu8fFJmC13rnQthT
Co-authored-by: Claude <noreply@anthropic.com>
Chat search built each result row with ChatTitleIdResponse(**chat.model_dump(), ...), which recursively copies the entire chat blob per row only for the constructor to ignore everything except id, title and timestamps: a 60-row search page deep-copied up to 60 full conversations. The folder listing, archived and export endpoints and every single-chat response did the same dump-and-revalidate dance via ChatResponse(**chat.model_dump()).
Search rows are now built from the five fields the response actually has (the snippet helper receives the blob by reference as before), and all 18 ChatResponse constructions use ChatResponse.model_validate(chat, from_attributes=True), which reads the fields off the already-validated ChatModel without copying the blob.
Benchmark (~500 KB chat blob):
| metric | before | after |
| --- | --- | --- |
| search result row | 0.05 ms | 0.003 ms |
| ChatResponse construction | 0.05 ms | 0.003 ms |
| per search page (60 rows) | 3 ms | 0.2 ms |
Beyond CPU, each converted row also stops materializing a second full copy of the conversation in memory while the page is being built.
Functionally verified: both construction styles produce identical model_dump() output for ChatResponse (including defaulted fields absent on ChatModel) and for search rows including the snippet.
DELETE /api/v1/chats/{id} called stop_item_tasks(id) before checking the
caller's chat.delete permission or ownership of the target chat. An
authenticated user who knew another user's chat id could therefore cancel that
chat's in-flight generation (streaming response, title or tag generation) even
though the deletion was then rejected. The chat id is discoverable through
legitimate read-only access to a shared chat or folder.
Reorder the handler to authorize first (admin, or owner holding chat.delete) and
only then cancel tasks and delete, matching the dedicated task-stop endpoint.
Legitimate deletions are unchanged; an unauthorized caller now returns 404 or 401
before any cancellation. The duplicated tag-cleanup and event-publish blocks are
merged.
Co-authored-by: GabrielGomesAL <193945687+GabrielGomesAL@users.noreply.github.com>
Completes the contrast set after #27555, #27554 and the gray-500 branch, which between them cover text-gray-400, text-gray-500, dark:text-gray-600 and placeholders. This is everything still under 4.5:1 after those.
The grey scale in src/tailwind.css is achromatic oklch(L 0 0), so relative luminance is exactly L³. What is left:
- text-gray-300 dark:text-gray-700, the lightest muted pair, at 1.58:1 in light and 2.14:1 in dark. Used for inactive tab labels across the admin, workspace and playground layouts, breadcrumb separators and empty state hints. It resolves to gray-600 in light and gray-400 in dark.
- text-gray-400/70 on the embedded chat history dropdown icon, 2.07:1 against the 3:1 that WCAG 1.4.11 requires of icons.
- Hover states that land lighter than the new resting colour. Once the resting state is gray-600, an element hovering to gray-500 gets less readable on interaction rather than more, so hover and group-hover targets of gray-500 resolve to gray-800. Sidebar/Section.svelte and the citation modal links are the sites this affects.
- The autocompletion ghost text in src/app.css, hardcoded #a0a0a0, 2.65:1 in light. The dark canvas already passes.
- The shimmer used for loading text, a #b4b4b4 gradient clipped to the glyphs at 2.10:1 in light. There is no solid colour to raise, so with the setting on it renders as flat gray-700 text instead.
Everything above is gated on the existing High Contrast Mode setting and changes nothing when it is off. No markup is touched, so this is src/app.css only.
Deliberately left alone: disabled: variants, since WCAG 1.4.3 exempts inactive components; the FileNav breadcrumb ancestors, which are non-clickable; decorative folder icons; and text-gray-100, dark:text-gray-800 and dark:text-gray-900, which are inverse text on filled buttons and already high contrast against their own backgrounds. The ad-hoc dark:text-gray-800 pairs in ChannelModal.svelte and automations/+layout.svelte stay as they are; dark:text-gray-800 doubles as the inverse text on the white buttons in Message.svelte, ResponseMessage.svelte and UserMessage.svelte, so it cannot be remapped in CSS without breaking those.
Not fixed here, and a genuine follow-up: .hljs-comment in src/app.css is #616161, roughly 3:1 on the dark code background. It sits outside the Tailwind grey scale and needs a highlight.js theme override rather than a utility remap.
Verified in a browser against Tailwind's emitted rules and layer order: with the setting on, the lightest pair resolves to gray-600 in light and gray-400 in dark, the hover and group-hover targets to gray-800, ghost text to gray-600 and the shimmer to solid gray-700, while inverse button text and every dark hover variant stay where they are; with the setting off nothing changes in either theme.
On latest `dev`, the `Select` trigger sets `aria-label={placeholder}`. In the accessible name computation `aria-label` is evaluated before the element's contents, so on a button that renders visible text it **replaces** that text instead of adding to it.
The trigger's content is `selectedLabel`, which resolves to the selected item's label and only falls back to `placeholder` when nothing is selected. So a control visually reading "Week" is exposed to assistive technology as "Select view", permanently, no matter what is selected. The dropdown items expose no `aria-selected` either, the current one is marked with a check icon only, so there is no path by which a screen reader user can find out what the control is set to.
Breaks WCAG 2.5.3 Label in Name (Level A), because the accessible name does not contain the visible label, so voice control cannot target the control by what it says on screen. Also 4.1.2 Name, Role, Value (Level A), because the value is never exposed.
Fix: drop the overriding `aria-label` so the name is computed from the visible text, and expose `aria-expanded` so the open state is conveyed. All 7 call sites plus the `DropdownOptions` wrapper override `slot="trigger"` and every one of them renders `selectedLabel` (or `placeholder`) as text, so no trigger is left unnamed. The two call sites that pass no `placeholder` were already emitting an empty `aria-label`, which is skipped by the name computation, so they are unaffected.
`aria-haspopup` is deliberately not added: the popup is a plain `DropdownMenu` of buttons with no `listbox` role, so claiming one would misdescribe it.
`placeholder` is still used, it drives the `selectedLabel` fallback and `TagSelector` renders it directly.
Severity: Serious. Affects every custom select in Admin Settings, Workspace and the calendar and automations pages.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
On latest `dev`, the five sortable column headers in the admin Users table are click handling `<th>` elements:
```svelte
<th scope="col" class="px-2.5 py-1.5 font-normal cursor-pointer select-none" on:click={() => setSortKey('name')}>
```
A `<th>` is not interactive. There is no `<button>`, no `tabindex`, no `role` and no key handler, so **sorting the user list is impossible without a mouse**. The sort direction is also conveyed only by an 8×8 pixel chevron, with no programmatic state, so assistive technology cannot report which column is sorted or in which direction.
Breaks WCAG 2.1.1 Keyboard (Level A) and 4.1.2 Name, Role, Value (Level A).
Fix: move the click handler onto a real `<button>` inside the header, which brings native focus, Enter and Space activation and the correct role, and add `aria-sort` to the `<th>`, which already carries `scope="col"` and therefore the implicit `columnheader` role. Only the active column reports a direction, since `orderBy` is a single value; the non sortable actions column deliberately gets no `aria-sort` at all rather than `none`, so it is not advertised as sortable.
The cell padding moves from the `<th>` onto the button so the whole header stays clickable. Left on the `<th>`, the padding ring would have become a dead zone, shrinking the hit target and flipping the cursor at an invisible boundary inside the header.
`cursor-pointer` is dropped from the `<th>` because `src/tailwind.css` already applies it to every `button`.
The repeated `aria-sort` ternary is extracted to a small `sortState` helper rather than pasted five times.
The same mouse only `<th on:click>` pattern still exists in the Analytics, Evaluations and Groups tables and is not touched here.
Severity: Serious. A core admin function is unreachable without a pointing device.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
`_normalize_token_expiry()` derived the session expiry from the access token alone, and that value is what `oauth_session.expires_at` stores and what both `get_oauth_token()` implementations check to decide whether to refresh five minutes ahead. Providers that issue a shorter-lived id_token than access token (Microsoft Entra ID: roughly 60 minutes against 75) therefore left a window where the session still looked valid while the id_token had already expired, so pipes and tools reading `__oauth_token__["id_token"]` forwarded a dead JWT and downstream services rejected it with 401.
The stored expiry is now capped at the id_token's `exp` claim whenever that JWT expires first, which moves the refresh ahead of the earliest expiring token in the set. This is applied in the single function every session write already passes through, so it covers both the SSO manager and the MCP client manager on their callback and refresh paths alike. Sessions without an id_token, with an opaque one, or with no `exp` claim are unaffected.
Fixes#27066