Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
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
When an audio file is split into multiple chunks for transcription, transcribe() collected the per-chunk results with asyncio.as_completed(), which yields results in completion order rather than submission order. Whenever a later chunk finished transcribing before an earlier one, the assembled transcript was scrambled, for example the second half of a recording appearing before the first, and the stored file content plus everything downstream (file preview, full-context retrieval) read out of chronological order. This change awaits the chunk tasks with asyncio.gather() instead, which runs them just as concurrently but returns the results in the order the tasks were created, i.e. chunk_paths order. The existing error handling and chunk cleanup are unchanged: an HTTPException from a chunk is re-raised as is and any other error is wrapped in a 500. Fixes#27143
* feat: prevent duplicate auth form submissions while one is pending
When a sign in, sign up or LDAP request is slow, the auth form can be submitted again and every extra click or Enter press starts another concurrent authentication request. The form never tracked a pending state, so submitHandler dispatched a new API call on every submit event. This adds a submitting flag that makes submitHandler ignore re-entrant submits, disables both submit buttons with a dimmed style while a request is in flight and resets the flag in a finally block so the form recovers after a failed attempt. Guarding submitHandler covers button clicks and Enter key submits for sign in, sign up and LDAP alike since all of them flow through the single form submit handler.
Fixes#27264
* feat: show a spinner while an auth request is pending
Disabling the submit button stops a second submission but gives no positive sign that the first one is still running, so on a slow identity provider the form looks unresponsive rather than busy. Both submit buttons now render the existing Spinner next to their label while submitting is set, following the same in-button pattern used by the workspace editors.
When a filter's outlet() modified the structured assistant output in place, the change was shown immediately but lost after reload. outlet_filter_handler built its outlet payload with a shallow reference to the message's output list from messages_map, so the filter mutated the stored baseline itself and the subsequent output comparison compared the object against itself, never detecting a change and never persisting it. The same aliasing corrupted originalContent for messages whose text lives only in output. Deepcopy the output when building the outlet payload so messages_map stays a pristine pre-filter baseline and the existing change detection persists outlet-modified output through the existing upsert path. Fixes#27017.
* fix: show download preparation toast and prevent duplicate zip jobs
Downloading a file or folder from the file navigator gave no feedback while the server prepared the response, which can take 30 seconds or more for large folders that are zipped server-side. Users assumed the click did nothing and pressed Download again, starting additional zip jobs on the server. Both downloadFile and bulkDownload, the two functions every download control funnels through, now show a persistent "Preparing download..." loading toast while the request is in flight and dismiss it once the download starts or fails. A shared downloading flag makes repeated clicks no-ops until the current download finishes, so a single click starts exactly one server-side job. Fixes#27055
* fix: report terminal download failures instead of dismissing the toast
A failed download dismissed the preparation toast without saying anything, which reads as the download silently disappearing. Both download paths now report the failure. The two helpers also declare a nullable return but could still reject once the response body started streaming, so an interrupted transfer escaped as an unhandled rejection and left the same silent dismissal. They now return null in that case, which also stops an interrupted preview from leaving its spinner running.
Add self-hosted OpenSERP as a web search engine option. OpenSERP
provides browser-rendered search across Google, Bing, Yandex, Baidu,
DuckDuckGo, and Ecosia with no API keys required.
- New module: retrieval/web/openserp.py (async, uses aiohttp session pool)
- Config: OPENSERP_BASE_URL env var (defaults to http://localhost:7070)
- Routing: search_web() dispatch for 'openserp' engine
- Follows existing patterns (searxng, brave)
Co-authored-by: crustopher-lgtm <crustopher-lgtm@users.noreply.github.com>
Every other navigation entry in the user menu (Settings, Admin Panel,
Archived Chats, Workspace, Notes, Automations, Playground, Sign Out)
collapses the sidebar on mobile after navigating, but the Calendar entry
was missing this handling, leaving the sidebar open over the Calendar
page on mobile. Add the same mobile guard used by the sibling entries.
When the same model is selected multiple times in a side-by-side chat,
each response is created with a distinct modelIdx (0,1,2,3) that
identifies its column. The backend now owns message persistence, but it
built the assistant placeholders without modelIdx, so the field was
never saved. On reload MultiResponseMessages groups responses by
modelIdx and falls back to grouping by model id when modelIdx is
missing; with duplicate models that fallback lumps every response into
each column, so all columns render the first response (and show a bogus
'1/N' pager).
Send modelIdx with each message_ids entry from the frontend and persist
it on the assistant placeholders in both the new-chat and existing-chat
paths. The message_ids list is now forwarded for every send (not just
multi-model ones) so single-column regenerations in a duplicate-model
chat also keep their column identity across reloads.
The automation create/edit modal footer laid the schedule/model dropdowns
and the Cancel/Create actions in a single justify-between row. On narrow
modals the fixed-width actions left too little room for the dropdowns,
which wrapped to two stacked lines with Cancel squeezed in the middle.
Stack the footer vertically on mobile (dropdowns row, then a right-aligned
actions row) and restore the horizontal layout at sm and up.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The OAuth / OIDC section in Admin Settings > Authentication had no
enable/disable switch, unlike the LDAP section above it. Add one that
persists via the existing Save flow and actually gates OAuth sign-in,
mirroring how the LDAP toggle works.
- config: new ENABLE_OAUTH persistent config ('oauth.enable'), defaulting
to True so existing deployments with a provider configured keep working.
- oauth: expose ENABLE_OAUTH via the OAuth runtime config and reject the
login and callback handlers with 404 when it is disabled.
- /api/config: report no OAuth providers when disabled so the login page
hides the OAuth buttons (and cannot auto-redirect), without clearing the
admin's provider configuration.
- auths: expose ENABLE_OAUTH through the admin OAuth config get/update
endpoints (OAuthConfigForm + OAUTH_CONFIG_KEYS).
- Authentication.svelte: bind the OAuth / OIDC header Switch to the
persisted oauthConfig.ENABLE_OAUTH and collapse the section when off,
matching the LDAP header (size, weight, alignment).
Follow-up to #27495, reopened as a high contrast mode change. Builds on the `high-contrast` class landed in #27555.
Muted UI text is written as text-gray-400 dark:text-gray-600. The grey scale in src/tailwind.css is achromatic oklch(L 0 0), so relative luminance is exactly L³: text-gray-400 is 2.07:1 on white and dark:text-gray-600 is 3.12:1 on #171717. The pair is effectively inverted, and both halves fail the 4.5:1 required by WCAG 1.4.3, with the light value also failing the 3:1 required of icons under 1.4.11. This is the text used for settings and admin section headings, field descriptions, sidebar labels, timestamps and counters, all at 10px to 12px, so the large-text exemption does not apply.
Rather than rewriting the class literal at 251 sites, the remap is two CSS rules that only apply when the existing High Contrast Mode setting is on, so the default theme is untouched:
- text-gray-400 resolves to gray-600 (5.75:1) in light mode
- dark:text-gray-600 resolves to gray-400 (8.65:1) in dark mode
dark:text-gray-500 already passes at 6.46:1 and is left alone.
The rules live in `@layer utilities` and use `:where()` to stay at low specificity: they outrank the base utility but lose to `hover:` and `dark:hover:` variants, so hover feedback keeps working. Verified in a browser against Tailwind's emitted rules and layer order: with the setting on, resting text resolves to gray-600 in light and gray-400 in dark, hover still resolves to its own value, and with the setting off nothing changes in either theme.
One component change is required alongside it. In admin/Settings/Audio.svelte the help text puts links inside the muted block via `[&_a]:text-gray-600`; once the surrounding prose resolves to gray-600 the link becomes the same colour as the text it sits in, and it has no resting underline, which would be a new WCAG 1.4.1 failure. The link moves to gray-900. This is the only place in the codebase where a link colour is nested inside muted text.
Letting the variants win has one edge: a few elements hover to a grey lighter than their new resting colour, so hovering would have lowered contrast instead of raising it. A light-mode hover landing on gray-500 now resolves to gray-800, which keeps the hover darker than the gray-600 resting state. Sidebar/Section.svelte is the site this branch would otherwise break.
Not covered here: text-gray-500 dark:text-gray-400 (2.77:1 in light), which is a separate branch.
With ENABLE_PERSISTENT_CONFIG=False the admin's model order is reset on every restart because ui.model_order_list falls back to its DEFAULT_CONFIG default, and unlike every other Models setting (DEFAULT_MODELS, DEFAULT_PINNED_MODELS, DEFAULT_MODEL_METADATA and DEFAULT_MODEL_PARAMS) that default was hardcoded to an empty list with no environment variable to source it from. This adds a MODEL_ORDER_LIST environment variable parsed as a JSON array using the same guarded pattern as the neighbouring DEFAULT_MODEL_METADATA and DEFAULT_MODEL_PARAMS defaults, falling back to an empty list on parse errors. Behaviour when the variable is unset is unchanged.
Fixes#27206
`_detect_text_encoding()` hands the complete file to `chardet.detect()`. chardet is pure Python and costs roughly 1.3 seconds per megabyte, so uploading a large non-UTF-8 text file stalls for seconds inside encoding detection alone. A 4 MiB Shift-JIS file spends 6.4 seconds there. The UTF-8 fast path above it means only non-UTF-8 files reach this, which in practice are exactly the CJK documents the surrounding code was written to handle, so the slow case and the case that matters are the same case.
Detection does not need the whole file. It needs the bytes that are actually not UTF-8, and `UnicodeDecodeError.start` from the fast-path decode already says where those begin, so this samples a 256 KiB window around that offset.
Two things make that safe rather than merely fast.
Centring the window on the first non-UTF-8 byte instead of the file head is what keeps the common case correct. A plain head sample makes chardet report ascii for a file that is ASCII for its first few hundred KiB and only turns CJK later, and the method then falls through to latin-1 instead of the right codec.
The window still cannot help when a stray byte, a pasted Windows-1252 artifact for example, sits hundreds of KiB ahead of the real payload: the sample is then almost pure ASCII and carries no signal. So when the sample holds almost no non-ASCII bytes and is a strict subset of the file, detection falls back to the whole buffer. That case pays the old cost, which is the right trade, because it is precisely the case where sampling would otherwise be wrong. Without this guard a Cyrillic document with a stray leading byte was detected as ISO-8859-1 rather than windows-1251, which is silent mojibake.
Measured, with the encoding returned identical in every case:
| file | before | after |
|---|---|---|
| shift_jis 4 MiB | 6402ms | 755ms |
| gb18030 4 MiB | 3199ms | 449ms |
| big5 4 MiB | 2926ms | 413ms |
| euc-jp 4 MiB | 2456ms | 413ms |
| euc-kr 4 MiB | 2382ms | 468ms |
| latin-1 4 MiB | 1902ms | 394ms |
| gb18030 1 MiB | 807ms | 376ms |
| ascii head then gb18030 tail | 533ms | 294ms |
| stray byte then cp1251 payload | 496ms | 1051ms |
| any UTF-8 file | 8ms | 0ms |
29 cases, all returning an identical encoding before and after: six encodings at 100 KiB, 1 MiB and 4 MiB, three layouts where the non-UTF-8 bytes only begin beyond the window, four where a stray byte is separated from the payload, plus plain UTF-8, UTF-8 CJK and an empty file. The stray-byte rows are slower than before because they scan twice, once over the window and once over the whole buffer. They are the pathological shape, and correctness wins there.
The residual time is now the decode-and-validate loop below, which walks the file once per candidate codec, and `_has_cjk_characters`, which is a per-character Python loop over the decoded text. Both are the same "full scan for a detection decision" pattern and could take a bounded prefix too. That is left alone here.
`alazy_load()` builds every BeautifulSoup tree inline in an async function, so a web search that pulls in ten pages stops the entire worker for the whole time it spends parsing. Nothing else on that worker runs during it: not other users' token streams, not health checks, not socket.io traffic. Parsing is CPU work and it belongs in a thread.
Measured over 37 real pages, 13.5 MiB total, with a 5ms ticker sampling event-loop lag:
| | wall | worst loop stall | ticker fired |
|---|---|---|---|
| inline, html.parser (today) | 1793.8ms | 1788.8ms | 1 time |
| offloaded, html.parser | 1872.9ms | 82.9ms | 88 times |
| inline, lxml | 1346.7ms | 1341.8ms | 1 time |
| offloaded, lxml | 1445.4ms | 37.0ms | 118 times |
Today the loop is not merely slow during a batch, it is gone: a 5ms timer fired exactly once across 1.8 seconds. After the change it fires normally and the worst single stall drops by a factor of 20 to 36. The cost is 4 to 7 percent more wall time for the batch itself, from the thread handoffs, which is the right trade for a server handling more than one user.
Three details behind the shape of the change:
`get_text()` is only 2 percent of the cost (34ms against 1706ms of parsing over the corpus), so the whole per-page unit moves into the thread rather than the parse alone. Splitting them measured worse on both axes.
The offload is per page, not per batch. Handing the whole batch to one thread measured worse than either (2081ms wall, 235ms worst stall), so the loop is yielded to between pages.
The metadata block in `alazy_load()` was a duplicate of the module-level `extract_metadata()`, field for field, and `lazy_load()` was already using the shared helper. The new helper calls it too, which is why the diff removes more lines than it adds. The `ascrape_all()` override goes with it: it was a verbatim copy of the inherited implementation and `alazy_load()` was its only caller, so anything still calling it now gets the identical parent method, which resolves `self._unpack_fetch_results` to the override this class keeps.
Verified by feeding the real loader a 37 page corpus and comparing every resulting Document against the implementation this replaces:
```
PASS one Document per url (37)
PASS every Document identical to the pre-change implementation (0 differ)
PASS parsing ran off the main thread
PASS event loop kept running during parsing (90 ticks)
```
Both `page_content` and `metadata` are byte-identical on all 37 pages. This is independent of the parser in use and composes with switching the default parser to lxml: that change makes the stalls shorter, this one takes them off the loop.
Regenerated with the current uv so `uv lock --check` passes again. The committed lockfile was written by an older uv and every run since has reported it as needing an update, which makes it impossible to tell real drift apart from format drift.
No resolved dependency changes: all 354 packages keep their versions, and no existing artifact URL or hash changes. The diff is almost entirely `upload-time` annotations added per artifact. The remaining changes are the `revision = 3` format marker, a `provides-extras` entry on the project stanza, additional GraalPy wheel URLs for already-locked versions of jiter, pybase64, pydantic-core and ujson, and the removal of the hardcoded `version = "0.10.2"` from the project's own stanza, which was stale metadata since pyproject.toml declares `dynamic = ["version"]`.
Nothing that gets installed changes: the Dockerfile installs from backend/requirements.txt and no workflow runs `uv lock` or `uv sync`.
OpenAPI specs with circular schema references, such as Mealie's where Recipe and RecipeCategory reference each other through properties and array items, crashed convert_openapi_to_tool_payload with a RecursionError, so the tool server produced no specs and the integration never appeared in the model or tool selection. resolve_schema already had a visited-set guard against circular references, but the recursive calls for properties and items dropped the set, so cycles running through those edges were never detected. This threads the visited set through those calls and passes a per-path copy when following a $ref, so only true ancestor cycles are pruned to an empty schema while sibling references to the same schema still resolve fully. Fixes#27239.
Every page pulled in by web search and web RAG is parsed with BeautifulSoup's `html.parser`, a pure-Python parser. It is the slowest option bs4 offers, and it is being handed 300 KiB to 1.5 MiB documents, several per query. `SafeWebBaseLoader` inherits `default_parser = "html.parser"` from langchain's `WebBaseLoader` and never overrides it, so this is an upstream default carried by accident, not a decision anyone made for Open WebUI.
`default_parser` is the single chokepoint for both the sync `_scrape()` path and the async `ascrape_all()` path, so one `setdefault` covers everything and an explicit caller override still wins.
lxml is already in the tree as a transitive hard dependency of ddgs, python-pptx and unstructured, so nothing new enters the image and `uv.lock` already resolves it at 6.1.1. The pin makes it explicit and closes a latent failure: bs4's `"xml"` feature, already used for `.xml` URLs in `_unpack_fetch_results()`, requires lxml and would raise `FeatureNotFound` the day that transitive dependency moves.
## Benchmarks
37 real pages, 13.8 MiB of HTML, median of 5 runs each. The timed operation is `BeautifulSoup(html, parser)` plus `get_text()` plus `extract_metadata()`, which is exactly what the loader does per page. bs4 4.14.3, lxml 6.1.1, CPython 3.12.
| | html.parser | lxml | |
|---|---|---|---|
| 37 pages, 13.8 MiB total | 1611.0ms | 1151.3ms | 1.4x faster, 460ms saved |
Largest pages:
| page | size | html.parser | lxml | speedup |
|---|---|---|---|---|
| pypi.org/project/aiohttp/ | 1259 KiB | 243.75ms | 180.51ms | 1.4x |
| gnu.org/software/bash/manual/bash.html | 1017 KiB | 257.97ms | 178.99ms | 1.4x |
| rfc-editor.org/rfc/rfc9110.html | 1157 KiB | 205.94ms | 154.87ms | 1.3x |
| docs.aiohttp.org/en/stable/client_reference.html | 403 KiB | 108.62ms | 84.93ms | 1.3x |
| ollama.com/library | 779 KiB | 117.55ms | 73.64ms | 1.6x |
| theregister.com | 1052 KiB | 88.32ms | 60.12ms | 1.5x |
| kubernetes.io/docs/concepts/services-networking/service/ | 563 KiB | 72.18ms | 43.43ms | 1.7x |
| docs.python.org/3/library/socket.html | 301 KiB | 71.88ms | 49.04ms | 1.5x |
Ranges from 1.1x to 1.7x, and the win grows with page size. A ten result web search sheds roughly 125ms of parsing. Because the async path builds its soups inline in `_unpack_fetch_results()`, that is 125ms the event loop spends parsing HTML instead of serving other users' streams. Pages under about 10 KiB are marginally slower under lxml due to fixed setup cost, which is worth nothing either way.
## Output verification
The risk in changing parser is silently different extracted text, so that was measured rather than assumed. Across all 37 real pages:
- **Zero characters of text were lost.** Every diff opcode against html.parser output was an insertion. Not one page dropped content under lxml.
- 659 characters were added, all on one page (docs.docker.com), where an inline Alpine.js `@click` handler containing a regex confuses libxml2's attribute handling and leaks a 73-character JS fragment into the text nine times. That is 659 characters of script noise in 27,206 characters of extracted text, with no content affected.
- Metadata (`title`, `description`, `language`) was identical on 35 of 37 pages. The two exceptions are 141-byte Wikipedia bot-block stubs with no `<html>` element, where lxml's fragment auto-wrapping adds `language: "No language found."`. Both parsers extract the same text from them.
Large documents were checked separately because libxml2 carries internal size caps. A 12 MiB single text node, 12 MiB spread across 400k nodes, a 3 MiB attribute value and 50k sibling elements with a trailing marker all produced byte-identical text under both parsers, with no truncation.
Malformed markup was checked too. lxml and html.parser diverge on unterminated comments, bare CDATA and duplicated `<html>` elements, all cases where both parsers are guessing and neither is correct. None of those shapes appeared in the 37 page corpus.
`backend/open_webui/env.py:184` also uses `html.parser`, on the local CHANGELOG at import time. That is trivial input on a startup path and is deliberately left alone.
The European Portuguese (pt-PT) catalogue mixes Brazilian and European forms
for the same concepts. In every case below the file ALREADY uses the European
term elsewhere, so these read as inconsistencies within the locale.
Corrects 74 string values. No keys are added, removed, renamed or reordered,
and no empty strings are filled (982 before, 982 after).
Genuinely Brazilian forms (Priberam tags these [Brasil] explicitly):
arquivo -> ficheiro (file sense only; Priberam: "Equivalente no portugues
de Portugal: ficheiro")
usuario -> utilizador (Priberam sense 3: "[Brasil] ... = UTILIZADOR")
acessar -> aceder (Priberam: entire entry tagged [Brasil])
equipe -> equipa (Priberam: "Grafia em Portugal: equipa")
midia -> multimedia (Priberam: every sense [Brasil])
"em um" -> "num"
"esta digitando" -> "esta a digitar": European Portuguese prefers
estar a + infinitive over estar + gerund (Microsoft pt-PT Style Guide p.34)
European UI convention, where the file is inconsistent with itself. These
words are NOT Brazilian; the change is for consistency, not correctness:
senha -> palavra-passe (0 vs 12 in this file) salvar -> guardar (3 vs 19)
compartilhar -> partilhar (6 vs 29) excluir -> eliminar (4 vs 19)
conexao -> ligacao (3 vs 27) gerenciamento -> gestao
desenvolvedor -> programador
Settings -> "Definicoes" (Settings label sense only, 15 vs 3)
the explicit pronoun "voce" -> European formal verb forms
Outright defects:
"Confirme sua a nova palavra-passe" -> "Confirme a sua nova palavra-passe"
"e sua especificacao" -> "e a sua especificacao" (missing article)
"Compartilhamento Publico" -> "Partilha Publica" (gender agreement)
"Archive" -> "Arquivar", not "Arquivo". All four call sites are button
labels/tooltips invoking archiveChatHandler(), so this is an action and
needs the verb, matching its siblings "Archive All" -> "Arquivar Tudo".
Deliberately NOT changed:
- 12 strings whose English key says "Config"/"configuration". "Configuracao"
is correct European Portuguese; only the Settings label becomes
"Definicoes".
- "You" -> "Voce" (the chat label), the string referencing that label, and
the default system prompt.
- "confiavel" is left alone: Priberam gives it no regional label and
professionally translated pt-PT (GNOME) ships it.
The migration to joserfc completed the job but left the old dependency pinned. `python-jose` now has zero imports anywhere in the backend: the only `jose` references left are `joserfc` in `utils/oauth.py`, and a repo-wide search for `from jose`, `import jose` or `python_jose` returns nothing outside the three pin files.
Removing it also removes `ecdsa` and `rsa` from the image, which were pulled in only by python-jose. `uv lock` confirms that: it drops exactly those three packages and nothing else, because google-auth 2.55 depends on cryptography and pyasn1-modules rather than rsa. That is worth having beyond the size saving, since `ecdsa` ships a documented Minerva-style timing side-channel in its P-256 signing path that upstream has declined to fix, so keeping it in the image means shipping a flagged crypto library that nothing calls.
Verified by blocking the `jose` module at import time and importing the backend anyway:
```
PASS import open_webui.utils.auth
PASS import open_webui.utils.oauth
PASS import open_webui.main
jose in sys.modules: False
PASS create_token/decode_token round trip
```
One user-visible consequence worth stating: Tools and Functions run in the same interpreter, so a third-party plugin that imports `jose` directly stops working after this. Nothing in Open WebUI itself does, and PyJWT remains a dependency, but a plugin relying on a library the application never declared for that purpose is the only thing this can break.
`uv.lock` was edited surgically rather than regenerated, to avoid the unrelated whole-file churn a newer uv version introduces. The result was diffed against real `uv lock` output and matches it exactly apart from that version's cosmetic fields.
The events:chat socket handler called the ownership-checked update for last_read_at, discarded the boolean it returns, and then cancelled the chat's pending timers regardless of the answer. cancel_timers_for_chat selected on the internal marker, the type, the parent chat id and the status, and never on the owner, so it matched rows belonging to any user. An authenticated user who knew another user's chat id could mark that chat read over their own socket session and silently cancel the owner's pending timers, and the owner got no notification: the scheduled action simply never fired.
The missing owner predicate also cut the other way in ordinary use. Because the query matched every timer sharing a parent chat id, one user reading a chat cancelled the timers of anyone else holding one on the same chat, so this was collateral damage as much as an attack.
cancel_timers_for_chat now requires a user_id and filters on it, which is the durable fix, and the socket handler returns early unless the ownership-checked update reports that the caller owns the chat. The parameter is required rather than defaulted so a later caller cannot reintroduce the unscoped query by omission. Both existing call sites already know the acting user. Timer rows are created with the same owner as the parent chat and the execution path already refuses to run one whose owner does not match, so scoping the cancellation the same way cannot strand a timer that would otherwise have fired.
One behaviour change worth noting: an administrator posting into another user's chat no longer cancels that user's chat.user_message timers, because the acting user is the administrator. The timer fires instead of being cancelled, which is the safe direction.
* refac: use MilvusClient instead of deprecated ORM-style PyMilvus APIs
PyMilvus 2.6 emits a PyMilvusDeprecationWarning for every ORM-style call (`connections.connect`, `utility.*`, `Collection` and its methods) and will remove those APIs in PyMilvus 3.1. Both Milvus backends still used them, so a running instance floods its logs with deprecation warnings during indexing and retrieval, and would break outright once PyMilvus 3.1 lands.
Both vector clients now go through `MilvusClient`:
- `milvus_multitenancy.py`: collection creation, index creation, has_collection, insert, search, query iteration, delete and reset.
- `milvus.py`: the remaining ORM calls in `query()` (`connections.connect`, `Collection(...).load()`, `Collection.query_iterator`), plus the now-unused `FieldSchema` import.
Behaviour is unchanged: same schema, same index parameters and the same two-step scalar-index fallback, same filter expressions, same result shapes. Verified against embedded Milvus (milvus-lite, pymilvus 2.6.14) with a functional harness over both clients: insert, get, query by string/int/bool metadata filters, vector search, tenant isolation, oversized-text truncation, delete by id and by filter, delete_collection and reset all return identical results before and after, while the deprecation warnings drop from 57 to 0 for the multi-tenancy client and from 16 to 0 for the standard one.
One Milvus Lite nuance worth recording: `MilvusClient` sends index build parameters (`M`, `efConstruction`, `nlist`) as flat keys rather than as a nested `params` blob. A Milvus server accepts both forms, Milvus Lite only reads the nested one, so those tuning values are ignored on Lite. `MilvusClient` offers no way to send the nested form, and `milvus.py` already built its index parameters this way, so both backends are now consistent.
Fixes#26978
* refac: correct the Milvus scalar-index comment
The comment claimed that embedded Milvus Lite requires an explicit scalar index type. It does not: Milvus Lite rejects `create_index` on a VARCHAR field outright ("create_index only supports vector fields"), for every index type and with or without a metric type, so neither the parameterless call nor the explicit INVERTED fallback can succeed there. Filtered queries on `resource_id` still work on Lite, just unindexed.
Only the accurate half is kept, which is the reason the parameterless call is deliberate rather than an omission.
Follow-up to #27497, reopened as a high contrast mode change, and the third and last of the contrast set after #27554 and #27555.
The grey scale in src/tailwind.css is achromatic oklch(L 0 0), so relative luminance is exactly L³ and text-gray-500 is 2.77:1 on white, against the 4.5:1 required by WCAG 1.4.3 and the 3:1 required of icons by 1.4.11. Around 290 sites use text-gray-500 dark:text-gray-400 for secondary labels, descriptions, counters and icons. Dark mode already passes at 6.46:1 and is left alone.
Rather than rewriting the class literal at every site, the remap is two CSS rules that only apply when the existing High Contrast Mode setting is on, so the default theme is untouched. Light mode resolves to gray-600 (5.75:1).
The split is not cosmetic. The `text-gray-500` utility is overridden inside `@layer utilities` with `:where()` so the rule sits below `hover:text-gray-*` and `dark:hover:text-gray-*` in specificity and hover feedback keeps working. The `.app-muted`, `.app-icon-muted` and `.tiptap table` classes in src/app.css are declared unlayered, which means no layered rule can reach them, so their override is unlayered too. They are `@apply text-gray-500 dark:text-gray-400` and fail identically, so leaving them out would have left the slash command menu and tiptap tables below 4.5:1 with the setting on.
Verified in a browser against Tailwind's emitted rules and layer order: with the setting on, the utility, .app-muted and .tiptap table all resolve to gray-600 in light while a focusable element carrying hover:text-gray-700 still resolves to gray-700 on interaction; dark mode and the setting-off case are unchanged in both themes.
One site is deliberately left out: EmbeddedChatHistoryDropdown.svelte uses text-gray-500/70, a separate class token that the selector does not match.
Follow-up to #27496, reopened as a high contrast mode change.
Placeholder text is the lowest contrast text in the product. The grey scale in src/tailwind.css is achromatic oklch(L 0 0), so relative luminance is exactly L³, and placeholder:text-gray-300 is 1.58:1 on white against the 4.5:1 required by WCAG 1.4.3. The large text exemption does not apply, the largest of these is text-lg. Placeholders are frequently the only format hint a field gives, for example admin/Settings/General.svelte uses e.g.) "http://localhost:3000".
Rather than deleting the ~200 per-component placeholder utilities and rewriting the base rule, the remap now happens in two CSS rules that only apply when the existing High Contrast Mode setting is on, so the default theme is untouched:
- placeholders resolve to gray-600 (5.75:1) in light mode
- placeholders resolve to gray-500 (6.46:1) in dark mode
The rules sit in `@layer utilities` and are anchored on `input`/`textarea`, which puts them above both the base rule in src/tailwind.css and every per-component `placeholder:text-*` utility, so no call site has to change. Placeholders stay distinguishable from real input values, which are text-gray-700 (8.46:1) in light and dark:text-gray-300 (11.39:1) in dark.
The chat composer placeholder is a tiptap ::before, so neither the base rule nor any utility reaches it. It is hardcoded #676767, which is 5.66:1 in light but only 3.17:1 on the dark canvas, so only the dark side is remapped, to gray-500. That rule stays outside the layer because the rule it overrides is unlayered too.
The root layout toggles a `high-contrast` class on documentElement from `$settings.highContrastMode`, alongside the existing theme classes, so every route is covered and the class is removed again when the setting is turned off.
Verified in a browser against Tailwind's emitted rules and layer order, on inputs both with and without per-component placeholder utilities: with the setting on, placeholders resolve to gray-600 in light and gray-500 in dark, the composer placeholder resolves to gray-500 in dark even with the prefers-color-scheme override treated as unconditional, and with the setting off nothing changes in either theme. Also checked against the oled-dark theme (gray-500 on #000 is 7.57:1) and the dark:bg-white/[0.03] input surface (6.01:1).
Note: the `high-contrast` class toggle is the same hunk as in the muted text contrast branch. Whichever lands first, the other rebases cleanly by dropping it.