Compare commits

..

1124 commits

Author SHA1 Message Date
Tim Baek
d3e8bf3405
Merge pull request #29027 from open-webui/dev
Some checks failed
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Release to PyPI / release (push) Has been cancelled
Release / publish (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
0.11.1
2026-08-25 17:17:36 -04:00
Timothy Jaeryang Baek
db22d89cab refac 2026-08-25 16:57:22 -04:00
Timothy Jaeryang Baek
5c62cc0517 chore: format 2026-08-25 16:53:53 -04:00
Timothy Jaeryang Baek
f71e9570c0 refac 2026-08-25 16:52:31 -04:00
Classic298
0366f5d3d8
chore: Update CHANGELOG.md (#27839)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* doc: changelog entries for the terminal preview same-origin setting and the automations bulk toggle
2026-08-25 16:43:59 -04:00
Timothy Jaeryang Baek
54d7a22370 refac 2026-08-25 16:34:49 -04:00
Timothy Jaeryang Baek
f4a0d3c973 refac 2026-08-25 16:33:03 -04:00
Timothy Jaeryang Baek
1d6d4e6e66 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-25 16:27:17 -04:00
Timothy Jaeryang Baek
067114c280 refac 2026-08-25 16:04:43 -04:00
Classic298
b1bfc18762
perf: cache the serialized builtin tool spec instead of deep-copying it per request (#28860)
Every chat request hands each builtin tool a fresh copy of its cached spec, because callers mutate what they get. That copy was a full deepcopy of a nested dict, repeated per tool per message.

The builder now caches the spec already serialized, so a request only parses it back. Parsing is what produces the independent tree callers mutate, and the cached value becomes an immutable string, so a request can no longer reach the cached object at all.

Measured on CPython 3.12 with a 1.1 KB spec and 20 builtin tools per request:

| | before | after |
|---|---|---|
| stdlib json, the default | 276.2 us | 66.4 us |
| orjson | 279.5 us | 37.5 us |

Builtin specs are plain JSON by construction: pydantic normalizes every default before it reaches the schema, so a tuple, set, enum or datetime cannot appear in one, and an unserializable default is dropped rather than embedded.
2026-08-25 16:00:34 -04:00
Classic298
45f4a87e85
fix: bound extracted document metadata by the upload size limit by default (#29025)
"RAG_METADATA_MAX_VALUE_CHARS" ships unset, and unset means no bound at all, so the limit only protects the deployments that already knew to configure it. A small Office document is a zip archive, and one crafted to expand enormously during extraction can turn a few hundred kilobytes into gigabytes of metadata held in memory; uploading it a handful of times is enough to exhaust a server and take Open WebUI down with it.

When no explicit limit is configured, the bound now follows "RAG_FILE_MAX_SIZE" instead of being absent, on the reasoning that a document cannot legitimately carry more metadata than the file itself is allowed to be. That keeps the number from being an arbitrary guess: it is whatever the administrator already decided an upload may weigh. Setting "RAG_METADATA_MAX_VALUE_CHARS" explicitly still wins, and a deployment that leaves both unset is unchanged, which is the same posture the upload limit itself takes.

"RAG_FILE_MAX_SIZE" is in MB and is treated as unset when it is zero, matching how the document loader already reads it.
2026-08-25 15:50:27 -04:00
Timothy Jaeryang Baek
6dcc2d5269 refac 2026-08-25 15:48:30 -04:00
Timothy Jaeryang Baek
140d2cf4b5 refac 2026-08-25 15:47:55 -04:00
Classic298
d198d950c6
perf: stop re-copying the response text on every stream save (#28821)
Every streamed delta saves a snapshot of the in-progress response so a reconnecting client can resume it, and each save rebuilt the assistant text from scratch. On the Chat Completions path that re-joined every accumulated chunk, including on saves carrying no new text, so a long answer followed by a large tool call re-joined the whole answer once per argument chunk. The Responses API path never collects those chunks and reads the text back out of the output items instead, where the blank check copied it in full every time.

The joined string is now kept and reused until another chunk arrives, since content_parts is only ever appended to; the nonlocal declaration that suggested otherwise was already dead and is dropped, and inlining the single-use helper removes an unreachable branch with it. The blank check in get_output_text now tests the text rather than allocating a stripped copy of it, which is equivalent for all twelve of its callers. Text streaming on the Chat Completions path is unchanged, since a text delta always appends before it saves.

| stream | before | after |
| --- | --- | --- |
| 20k-char answer, 2000 tool-argument chunks | 21.4 ms | 0.06 ms |
| Responses API, 40k deltas, 200k chars | 80.7 ms | 50.5 ms |

Without Redis nothing extra is retained, since the snapshot store already held that string; with Redis one copy of the response text stays alive while the stream runs.
2026-08-25 15:41:54 -04:00
Classic298
ac85b0f2a2
refac: gate code interpreter tag detection to legacy tool-calling mode (#29024)
Tag detection for the code interpreter ran regardless of the tool-calling mode, so a model in Native (Agentic) Mode that emitted <code_interpreter> blocks in ordinary reply text had that code sent to the executor. Native mode never teaches the tag format and exposes execute_code as a builtin tool, so the parser had nothing legitimate to pick up there.

Gates detection on the legacy mode, matching the condition that already decides whether the tag prompt is injected at all. The five authorization checks are unchanged, and native mode keeps executing through the tool.

Deployments on native mode whose models emit the tags unprompted will now see them rendered as text.
2026-08-25 15:33:55 -04:00
Timothy Jaeryang Baek
28f2965934 refac 2026-08-25 15:26:45 -04:00
Timothy Jaeryang Baek
e3a7a64d82 refac 2026-08-25 15:25:07 -04:00
Timothy Jaeryang Baek
278e97589e refac 2026-08-25 15:22:19 -04:00
Timothy Jaeryang Baek
02400c7a50 refac 2026-08-25 15:15:18 -04:00
Timothy Jaeryang Baek
f158f892f4 refac 2026-08-25 15:14:11 -04:00
Timothy Jaeryang Baek
bc4c91e6d6 refac 2026-08-25 15:11:06 -04:00
Timothy Jaeryang Baek
a610d77137 refac
Co-Authored-By: Fares <26122914+faqeel@users.noreply.github.com>
2026-08-25 15:05:02 -04:00
Timothy Jaeryang Baek
35fbde0a3f refac 2026-08-25 15:00:53 -04:00
Timothy Jaeryang Baek
684111715f refac 2026-08-25 14:56:23 -04:00
Timothy Jaeryang Baek
a21c8d15ee refac 2026-08-25 14:48:38 -04:00
Timothy Jaeryang Baek
20fe43d9da refac 2026-08-25 14:48:01 -04:00
Timothy Jaeryang Baek
3374b21a7d refac 2026-08-25 14:39:25 -04:00
Timothy Jaeryang Baek
8be4c5fa6a refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-25 14:35:22 -04:00
Timothy Jaeryang Baek
92a1502126 refac 2026-08-25 14:22:20 -04:00
Timothy Jaeryang Baek
5ce198b1d7 refac 2026-08-25 14:20:28 -04:00
Timothy Jaeryang Baek
21366fa1b1 refac 2026-08-25 14:19:58 -04:00
Timothy Jaeryang Baek
20f35d157b refac 2026-08-25 14:14:13 -04:00
Timothy Jaeryang Baek
c4b3e6840f refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-25 14:07:50 -04:00
Timothy Jaeryang Baek
7198e9d0df refac 2026-08-25 14:06:25 -04:00
Timothy Jaeryang Baek
2e6d61e6d0 refac 2026-08-25 13:51:58 -04:00
Timothy Jaeryang Baek
d241df8d79 refac 2026-08-25 13:39:54 -04:00
Timothy Jaeryang Baek
1c13fedb16 refac 2026-08-25 13:18:38 -04:00
Timothy Jaeryang Baek
a4738e0459 refac 2026-08-25 12:45:12 -04:00
Timothy Jaeryang Baek
3c1017f6c3 refac 2026-08-25 12:22:19 -04:00
Classic298
2d2bcb5332
fix: long streamed lines no longer abort the response (#28114)
Some providers send one very large piece of a streamed answer in a single go: a long reasoning trace, a code execution result, a turn with many tool calls, or a response echo carrying a big tool list. Anything past 128 KB in one line killed the chat mid-answer with a misleading `400, message: Got more than 131072 bytes when reading`. Nothing was rejected upstream, that is our own reader giving up on an oversized line.

Open WebUI already had code that assembles lines itself with no such limit, but it only ran when CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE was set. Unset is the default, and in that case the raw capped reader was used instead, so a default install always broke. That path now always assembles lines, and the setting goes back to being what its name says: an optional cap, off by default. It applies to the Ollama stream as well, since both now share the same reader.

The assembly loop only splits once a line actually completes, because the old one re-concatenated and re-split the whole buffer on every network chunk. Without that, allowing long lines would have traded an error for multi-second event loop stalls.

| | 20 MB in one line | 200k small lines |
| --- | --- | --- |
| before | 4249 ms | 27.3 ms |
| after | 37 ms | 25.2 ms |
2026-08-25 12:16:37 -04:00
Timothy Jaeryang Baek
beb3c114d3 chore: python-docx dep 2026-08-25 11:59:37 -04:00
Classic298
e3e4bd87df
refac: consolidate the web fetch address checks onto the request path (#27823)
* fix: apply the SSRF checks to redirect targets on every web fetch path

Two guards protect server-side fetches: a private-IP check and the operator's `WEB_FETCH_FILTER_LIST`. Neither reached a redirect hop on the aiohttp paths, and the filter list never reached one on the requests paths either.

aiohttp answers IP-literal hosts itself without consulting a resolver, so `_SSRFSafeResolver` was never invoked for a hop such as `http://169.254.169.254/` and the private-IP check simply did not run. With redirect following enabled, a submitted public URL that redirects to an IP literal reached loopback, RFC1918 and cloud-metadata addresses, and the response body was returned to the caller. The filter list was consulted only in `validate_url`, on the originally submitted URL, so a redirect to a filter-listed host was fetched without it ever being applied.

`_SSRFSafeResolver` is replaced by `_SSRFSafeConnector`, which hooks `_resolve_host` so the IP check also covers the IP-literal shortcut and both DNS cache paths. The filter list moves to a per-request hook on each transport, `connect()` for aiohttp and `send()` for the requests adapter, because those see the request destination: at the connection layer a proxied request presents the proxy's host, and a pooled connection skips resolution entirely. This covers every hop, including redirects, on all five aiohttp call sites and both requests sessions. The Playwright loader already validated each hop and is unchanged.

Both gaps required `AIOHTTP_CLIENT_ALLOW_REDIRECTS=true`, which is not the default.

Two behaviour changes for operators. The filter list now applies to redirect targets rather than only to submitted URLs. Under a forward proxy it is evaluated against the request destination instead of the proxy, which also fixes allowlist entries rejecting every fetch in proxied deployments.

* refac: match the web fetch filter list against resolved addresses

The filter list is now evaluated against the hostname together with the addresses it resolves to, at URL validation and on each connection, on both transports. An IPv6 address is also matched by the IPv4 address it carries.

* refac: screen outbound fetch addresses against reserved ranges ipaddress misses

`ipaddress.is_global` was the only test behind the web-fetch address check, and it answers a narrower question than "may we fetch this". Several special-purpose ranges are globally routable by registry while nothing on them is a legitimate destination, so they passed. Classification now screens those ranges on top of `is_global`, and applies the same screen to the IPv4 address embedded in an IPv6 transition encoding rather than only to the literal. All three checkpoints share the predicate, so they all inherit it.

The range list is the exact complement of what CPython's `ipaddress` already models, checked entry by entry against both IANA special-purpose registries. Prefixes IANA marks globally reachable are deliberately left out, so no real destination changes behaviour. Verified against 31 addresses covering every entry, their transition-encoded forms, and public controls in both families: 31/31 expected after, 18/31 before.

* refac: match web fetch filter entries that name an address or a range

A filter entry that parses as an address or a CIDR range is matched by containment rather than by DNS label suffix, so a range covers the addresses inside it and an address matches however it is spelled. A range entry previously matched nothing at all, silently.

The built-in list gains the special-purpose networks that ipaddress.is_global reports as reachable while nothing on them is a legitimate destination, so taking an address out of reach is a WEB_FETCH_FILTER_LIST change rather than a release. Those entries hold whether or not local web fetch is enabled; the private-address rule still follows the toggle.
2026-08-25 11:15:48 -04:00
Timothy Jaeryang Baek
ca4e07a40b refac 2026-08-25 11:07:54 -04:00
Timothy Jaeryang Baek
6330350a40 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-25 10:39:49 -04:00
Timothy Jaeryang Baek
176fa46212 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-25 10:03:53 -04:00
Timothy Jaeryang Baek
2f97c9fce3 refac 2026-08-25 10:00:43 -04:00
Timothy Jaeryang Baek
aa3d569610 refac 2026-08-25 09:59:07 -04:00
Timothy Jaeryang Baek
eadce55e34 refac 2026-08-25 09:55:18 -04:00
Timothy Jaeryang Baek
5078d987f8 refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
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
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 / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-08-24 21:15:54 -04:00
Timothy Jaeryang Baek
2a0274a0a0 refac 2026-08-24 20:34:59 -04:00
Timothy Jaeryang Baek
f3f7659da7 refac 2026-08-24 19:56:07 -04:00
Timothy Jaeryang Baek
170ad0595d refac 2026-08-24 19:48:49 -04:00
Timothy Jaeryang Baek
a9a802d95f refac 2026-08-24 19:48:12 -04:00
Timothy Jaeryang Baek
18bf0ade7b refac 2026-08-24 19:25:03 -04:00
Timothy Jaeryang Baek
11db926a7b refac 2026-08-24 19:20:24 -04:00
Timothy Jaeryang Baek
0169b10979 refac 2026-08-24 19:15:10 -04:00
Classic298
dc03e7e595
refac: keep external connections until their last knowledge base is removed (#28113)
Deleting an external knowledge base now clears its connection only when an admin removes the last knowledge base referencing it, matching the connection delete route.
2026-08-24 19:11:40 -04:00
Classic298
6b438f1a79
refac: stream web-fetched documents to disk instead of buffering them (#28945)
The binary branch of the web fetch read the entire response body into memory
before writing it out. It now streams in blocks, applies the configured file
size limit the same way the sibling URL endpoint already does, and removes the
temporary file when a download fails partway instead of leaving it behind.
2026-08-24 19:06:13 -04:00
Timothy Jaeryang Baek
06e7aac219 refac 2026-08-24 19:05:21 -04:00
G30
2978a03c68
fix: restore the model avatar reset option in the model editor (#29007) 2026-08-24 19:02:22 -04:00
Timothy Jaeryang Baek
d5b66533e7 refac 2026-08-24 18:56:39 -04:00
G30
e6648aefb5
fix: honor the terminal file browser filesystem root instead of clamping to home (#29006) 2026-08-24 17:55:51 -05:00
Sven Horvath
5735123f50
fix: keep the pending note save when an update carries no content snapshot (#28669)
stop_item_tasks() ran unconditionally while create_task() only ran when the
update carried data, so an update without a content snapshot cancelled the
pending save without scheduling a replacement and the edits were never
written.
2026-08-24 18:47:43 -04:00
Classic298
bf08835d6f
perf: allow disabling websocket per-message-deflate (#28613)
With delta streaming most websocket frames are tiny per-token deltas, and
per-message-deflate pays zlib work on every outgoing frame per subscriber for
near-zero gain there; under heavy streaming that shows up as measurable server
CPU. The frames that still benefit are the rare large ones (final message,
sources), and even a 100k token message is only a few hundred KB uncompressed,
which any network delivers without noticeable delay.

UVICORN_WS_PER_MESSAGE_DEFLATE=false (default true, current behavior) disables
the extension in every entry point: open-webui serve and dev, start.sh both
invocations, start_windows.bat and dev.sh. Verified against a running
instance: with the flag off the server declines the client-offered
permessage-deflate extension, with defaults it still negotiates it.
2026-08-24 18:46:07 -04:00
Timothy Jaeryang Baek
97466deea1 refac 2026-08-24 18:38:29 -04:00
Timothy Jaeryang Baek
9dff5e9327 refac 2026-08-24 18:37:39 -04:00
Timothy Jaeryang Baek
536b9edec0 refac 2026-08-24 18:35:04 -04:00
Timothy Jaeryang Baek
b96d2b12da refac 2026-08-24 18:29:36 -04:00
Timothy Jaeryang Baek
e3e82b1471 refac 2026-08-24 18:29:19 -04:00
Timothy Jaeryang Baek
e9efb95a9c refac 2026-08-24 18:17:11 -04:00
Timothy Jaeryang Baek
7a533d0d5b refac 2026-08-24 18:14:30 -04:00
Timothy Jaeryang Baek
6cb2449ab7 refac 2026-08-24 18:10:08 -04:00
Timothy Jaeryang Baek
cf4ac9c8db refac 2026-08-24 18:07:03 -04:00
Timothy Jaeryang Baek
fd8cc2ba4a refac 2026-08-24 18:06:59 -04:00
Timothy Jaeryang Baek
98ee2bdfd3 refac 2026-08-24 17:56:11 -04:00
Classic298
043cf330d2
perf: throttle last_active_at writes by default (#28177)
Presence tracking writes each user's last_active_at on every authenticated request, every API key request and every websocket heartbeat. The throttle for it already exists but ships unset, and unset means no throttle at all, so a stock deployment pays one UPDATE plus COMMIT per user per request. The 30 second frontend heartbeat alone is 2 write transactions per minute per open tab, before any actual UI traffic.

Defaulting the throttle to 60 seconds collapses that to at most one write per user per worker per minute. Presence is only ever read at minute granularity, so nothing visible changes.

60 rather than the 300 to 500 the docs currently suggest, because a user counts as active for 3 minutes after their last write and that window is hardcoded in the backend and again in the frontend. Any interval at or above 180 seconds makes people who are actively using the instance drop out of the active user count. Letting the window follow the interval instead would need the value shipped to the client, so that is a separate change.

0 still disables the throttle, and now costs nothing at all: the decorator returns the undecorated function instead of a wrapper that re-checks a constant on every call.

Closes #28165
2026-08-24 17:53:31 -04:00
G30
da9245626e
fix: keep the viewport in place when older messages load above it (#28657) 2026-08-24 17:51:20 -04:00
G30
683c92d064
fix: fetch sidebar folders once per refresh instead of three times (#28662) 2026-08-24 17:50:46 -04:00
Timothy Jaeryang Baek
a6834f089b refac 2026-08-24 17:47:10 -04:00
Timothy Jaeryang Baek
9e7c9360b7 refac 2026-08-24 17:43:43 -04:00
Timothy Jaeryang Baek
8c1f3d3824 refac 2026-08-24 17:39:30 -04:00
G30
f2313d0c72
fix: keep the workspace tab counts in sync with each section's list (#28983) 2026-08-24 17:36:12 -04:00
Timothy Jaeryang Baek
a914868e3c refac 2026-08-24 17:25:33 -04:00
G30
83d049a465
fix: skip the sidebar refresh when a chat is dropped back where it already is (#28664) 2026-08-24 17:20:44 -04:00
Sebastian
4d5084025f
fix: download nltk data somewhere a non-root UID can read (#28866)
nltk.download picks the first entry of nltk.data.path that already exists and is
writable. None do here, so punkt_tab lands in /root/nltk_data, and /root is mode
0700. The corpus is then unreachable whenever the container does not run as
root:

    nltk.data.find('tokenizers/punkt_tab')
    LookupError: Resource punkt_tab not found.

/usr/local/share/nltk_data is already on nltk.data.path, so nothing changes at
the read side.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:19:15 -04:00
Timothy Jaeryang Baek
ecad20b77f refac 2026-08-24 17:17:53 -04:00
Timothy Jaeryang Baek
91917b2395 refac 2026-08-24 17:16:01 -04:00
Timothy Jaeryang Baek
363ad352fe refac 2026-08-24 17:12:56 -04:00
Classic298
23b3a69bc2
fix: keep folder parent references acyclic (#28748)
Moving a folder under one of its own subfolders was accepted. A folder in a parent loop is never a root, so it and everything under it silently disappeared from the sidebar, and there was no way to get it back from the UI.

The move is now rejected with a 400, folders whose parent chain loops are put back at the root on the next folder list, and the folder tree traversals skip ids they have already visited so existing data in that state stays workable.
2026-08-24 17:06:19 -04:00
G30
18edfff2d6
fix: discard unsaved group settings and key the groups list (#28076)
* fix: stop unsaved group settings from persisting into the group list state

* fix: key the groups list so an open modal cannot rebind to another group
2026-08-24 16:31:39 -04:00
Timothy Jaeryang Baek
aeda6ff13a refac 2026-08-24 16:29:57 -04:00
G30
336d8841f4
fix: make embedded message content inert in the sidebar chat hover preview (#27770) 2026-08-24 07:36:44 -04:00
G30
fd7024f198
fix: log tool server connectivity failures without a traceback (#27757) 2026-08-24 07:35:57 -04:00
G30
6b4131d1d7
fix: log terminal proxy connectivity failures as single lines and handle client disconnects (#27755) 2026-08-24 07:35:38 -04:00
Classic298
baeb2dfb83
fix: apply RDS IAM token auth to the pgvector engine (#27754)
With `DATABASE_ENABLE_IAM_TOKEN_AUTH=true` and `VECTOR_DB=pgvector`, startup failed at vector store initialisation with `fe_sendauth: no password supplied`, so the two features could not be used together.

`PgvectorClient` builds its own engine and never got the `do_connect` listener that refreshes the RDS IAM token, and the `ScopedSession` branch that would have reused the instrumented main engine is unreachable because `PGVECTOR_DB_URL` defaults to `DATABASE_URL` and is therefore never falsy.

The pgvector engine now goes through `enable_iam_token_auth()` like the main and Alembic engines. Since a token authenticates exactly one host/port/user, that function now attaches the listener only to engines pointing at the same target, so a `PGVECTOR_DB_URL` aimed at a separate database keeps the password from its own URL instead of having it overwritten; the skip is logged with both identities.

Fixes #27752
2026-08-24 07:35:19 -04:00
G30
16b20c651d
fix: restore per-item retrieval settings in the model editor knowledge section (#27686) 2026-08-24 07:34:30 -04:00
G30
01452ff62f
fix: mount the code editor in its own container so duplicated message renders don't collide (#27740) 2026-08-24 07:34:07 -04:00
Classic298
9cf1a07960
fix: use the pooled client timeout for the Anthropic Messages passthrough (#27675)
* fix: use the pooled client timeout for the Anthropic Messages passthrough

The native `/api/v1/messages` passthrough still referenced `openai.AIOHTTP_CLIENT_TIMEOUT`, which stopped existing when `routers/openai.py` moved onto `session_pool.get_client_timeout()`. Every passthrough request therefore raised `AttributeError: module 'open_webui.routers.openai' has no attribute 'AIOHTTP_CLIENT_TIMEOUT'` before it was sent, and the surrounding handler turned that into a 502 "Open WebUI: Server Connection Error", so Anthropic-format clients such as Cline could not reach any model at all.

Use `get_client_timeout(stream=...)` like the OpenAI and Ollama proxies do, so the configured `AIOHTTP_CLIENT_TIMEOUT` applies and streaming requests additionally get the idle-read timeout.

Fixes #27595

* fix: authenticate native Anthropic requests with x-api-key

The Anthropic Messages passthrough and the token-count forwarding both build their upstream request through `get_anthropic_request_target`, which sends the connection key as `Authorization: Bearer <key>`. Anthropic's OpenAI-compatible `/chat/completions` endpoint accepts that, which is why the model works in the chat UI, but the native `/v1/messages` and `/v1/messages/count_tokens` endpoints do not: they require the key in `x-api-key` and reject a bearer token with 401 `Invalid bearer token` (and `jwt auth is not yet supported on count_tokens`). They also require an `anthropic-version` header, which was never sent.

For `api.anthropic.com` connections, send `anthropic-version` and move the key into `x-api-key`, dropping the bearer header. Connections using session, OAuth or Entra ID auth keep their token untouched, LiteLLM passthrough connections are unaffected, and admin-configured custom headers still win over both defaults.

Fixes #27695
2026-08-24 07:33:33 -04:00
Timothy Jaeryang Baek
8a170897ba refac 2026-08-24 07:28:09 -04:00
Timothy Jaeryang Baek
495296346e refac 2026-08-24 06:26:49 -04:00
Timothy Jaeryang Baek
ef455fcef9 refac 2026-08-24 06:25:09 -04:00
Classic298
091c44c621
perf: stop rescanning the whole response for tag boundaries on every streamed chunk (#28861)
Streamed responses are scanned for reasoning and code interpreter tags. To work out where the last complete tag ended, the scanner searched backwards from the start of the accumulated text on every chunk, once per tag set. Ordinary prose contains no angle bracket, so that search never stopped early and read the entire response back every time. The cost grows with the square of the response length, and this scanning is on unless a model turns it off.

The two positions are now carried forward as the text grows, so each chunk only scans the characters it added.

Measured on CPython 3.12, a 270 KB response streamed in 27000 chunks:

| response text | before | after |
|---|---|---|
| no newlines | 7690 ms | 40.6 ms |
| with newlines | 5695 ms | 41.7 ms |

The carried positions match a full rescan at every step of 36282 randomized replays, covering text with no markers, newlines only, dense markers, real tags and truncation part way through.
2026-08-24 05:11:32 -05:00
Timothy Jaeryang Baek
978d257214 refac 2026-08-24 05:40:53 -04:00
Classic298
16c2a9eda4
fix: index the chat queries that make large SQLite instances unusable (#27663)
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
The timer scheduler polls once a second and cancels on every message send and chat open, the sidebar lists chats ordered by `updated_at`, and the folder badges count unread chats per folder. None of those could be served by an index, so each call read most of the `chat` table, and because `meta` sits after the chat payload column SQLite had to walk every row's overflow pages to get there. On a large history that stalls the sidebar, every chat switch and every send, and the idle poll alone burns about a quarter of a CPU core.

Timers now keep their due time in a dedicated `chat.timer_at` column behind a partial index, and the chat list, unread and unfinished-reply queries each get an index matching their filter and ordering. Existing pending timers are backfilled from their meta by the migration. Dropping the `internal` and `type` checks also makes a forked timer chat inert, where a fork used to copy `meta` verbatim and become a second claim target that could fire a duplicate timer.

Measured on SQLite, same rows returned:

| query | before | after |
|---|---|---|
| idle timer poll (2000 chats, 0.43 GB) | 170 ms | 0.04 ms |
| cancel on send and chat open (4000 chats, 377 MB) | 200 ms | 0.04 ms |
| sidebar chat list (15000 chats, 1.26 GB) | 157 ms | 1.8 ms |
| folder unread badges (15000 chats, 1.4 GB) | 54 ms | 0.2 ms |

PostgreSQL 17 serves all of them as index-only scans with no sort node. Exercised through fresh install, upgrade with seeded data, downgrade and re-upgrade on SQLite and PostgreSQL 17.

Fixes #27622
2026-08-23 16:11:54 -05:00
enixCode
29e8d7db67
i18n: fill missing fr-FR translations (#28951)
75 entries in the French locale had an empty value. i18next falls back to the
key when a value is empty, so French users were shown raw English strings
across permission settings, empty states, form placeholders and error toasts.

- fill those 75 entries; no key is added, removed or reordered
- follow the conventions already present in the file: "Entrez ..." for input
  hints, "Échec de ..." for failures, Chat -> Conversation, Token left as is
- interpolation placeholders preserved; no existing translation modified
2026-08-23 16:02:27 -04:00
Classic298
f73f09a3e0
refac: drop the redundant .keys() from two dict membership tests (#28859)
`x in d` and `x in d.keys()` are identical for a plain dict, so the `.keys()` call builds a throwaway view and reads as if it were doing something. Both sites operate on a plain dict: `combined` in `merge_and_sort_query_results` is a local `dict()`, and `ui_settings` comes from `UserSettings.model_dump()` where `ui` is annotated `dict | None` and is already guarded against None on the preceding line.

No behaviour change, and no measurable speedup either, so this is a readability cleanup rather than a performance one.

Sites where `.keys()` is load-bearing are left alone: the `list(d.keys())` snapshots taken before mutating during iteration, and the places where `.keys()` is the iteration or comprehension source rather than a membership test.
2026-08-23 16:02:09 -04:00
Timothy Jaeryang Baek
b30b11d4c9 refac 2026-08-23 16:01:36 -04:00
Classic298
ac091273b7
fix: keep streamed text when a filter or provider sends non-string content (#28840)
A stream filter function, or a provider that puts something other than a string in a delta, makes the streaming handler concatenate a string with a non-string. That raises TypeError, and the broad handler wrapped around the whole per-chunk block swallows it at debug level and moves on. The chunk's text never reaches the message the user sees, and nothing above debug level says why.

The content and reasoning fields are now coerced to text once, where they are read off the delta, ahead of every consumer. The coercion is guarded on truthiness, so falsy values such as an empty list still skip the block exactly as before, and the accumulated content receives byte for byte what it received previously.

Checked against 14 delta shapes covering strings, empty values, numbers, booleans, None, lists, dicts and a content array: the truthiness gate and the accumulated content are identical before and after.
2026-08-23 15:35:53 -04:00
G30
603e85c569
fix: apply connection prefix id to the model display name as well as the id (#28950) 2026-08-23 15:27:09 -04:00
Timothy Jaeryang Baek
f64c0c87e8 refac 2026-08-23 15:14:43 -04:00
Timothy Jaeryang Baek
e623c02acc refac 2026-08-23 15:06:30 -04:00
Timothy Jaeryang Baek
78f48a21ee refac 2026-08-23 14:40:48 -04:00
Timothy Jaeryang Baek
fb4f476316 refac 2026-08-23 13:49:50 -04:00
Timothy Jaeryang Baek
2578174637 refac 2026-08-23 13:47:02 -04:00
Solaris-star
5ee7140b4c
fix: render code editor drawer above settings modal (#27648)
The ComfyUI workflow.json Edit drawer (CodeEditorModal -> Drawer) used
z-999, while the admin Settings dialog (Modal) uses z-9999. Since both
are appended to <body>, the drawer rendered behind the settings dialog
and appeared to open 'in the background' (#27647).

Add an optional zIndexClass prop to Drawer (default z-999, preserving
existing behaviour for all other callers) and pass z-99999 from
CodeEditorModal so the editor surfaces above any enclosing modal.
2026-08-23 13:44:41 -04:00
Timothy Jaeryang Baek
bf3a58dbcd refac 2026-08-23 13:40:13 -04:00
Timothy Jaeryang Baek
5093a99389 refac 2026-08-23 13:36:53 -04:00
Timothy Jaeryang Baek
886248de36 refac 2026-08-23 13:33:51 -04:00
Classic298
d16d62d1f1
fix: duplicate checkbox markers when serializing note task lists (#27671)
Task lists in Notes serialized to markdown as `- [ ] [ ]` with the item text pushed onto a separate line after a blank line, so previewing or downloading a note produced a broken checklist, and checking an item left the second `[ ]` behind as plain text.

TipTap renders each task item as a checkbox inside a label plus a block-wrapped body. The GFM turndown plugin matches that checkbox and emits its own `[ ]`, which landed next to the marker the task item rule already writes, and the block wrapper left blank lines around the text that the old leading-whitespace strip could not remove.

Register a rule that drops the checkbox so the task item rule is the only source of the marker, and trim the block wrapper while indenting continuation lines so nested lists and code fences stay inside the item.

Fixes #26067
2026-08-23 13:31:46 -04:00
Classic298
945c521ed2
refac: make the web search error message a plain constant (#28948)
The web search error message was a lambda with a passthrough branch that returned whatever it was handed. Since #28942 both call sites pass no arguments, so that branch is unreachable, and it is the trap that let a caller drop a raw exception object into an HTTP response body and turn an intended 400 into an unserialisable 500.

A plain string constant removes the trap and lines the message up with every other fixed message in that file. Behaviour is unchanged: the response detail comes out byte for byte identical, because the enum already overrides __str__ to render members as their value. Verified on Python 3.11 and 3.12, both producing the same string and the same JSON body.
2026-08-23 13:27:02 -04:00
Timothy Jaeryang Baek
842c1f9d67 refac 2026-08-23 13:10:07 -04:00
Timothy Jaeryang Baek
0fb542b376 refac 2026-08-23 12:59:59 -04:00
Classic298
fca3be5416
fix: web search failures return HTTP 500 with an empty body instead of 400 (#28942)
Any failure during a web search comes back to the client as a bare HTTP 500 with nothing in it. The handler tries to build a 400 whose detail is the caught exception object itself, FastAPI cannot serialise that into a response body, so rendering the error response fails and the request falls through to the generic 500 handler. In chat this surfaces as a web search that fails with no explanation at all, and the most common trigger is simply selecting a search engine without configuring its API key.

This routes the failure through the standard error formatter, which is what the sibling handler for content loading failures in the same function already does. Web search failures now return 400 with a readable message, and the exception itself keeps going to the server log exactly as before.

Passing str(e) into the response was the other option and was rejected: the rest of the backend deliberately keeps provider exception text out of client responses and in the log, and provider exceptions here can carry request details that should not be echoed back.
2026-08-23 12:59:25 -04:00
Classic298
069f49fcd2
refac: remove unreachable rate limit handler from DuckDuckGo web search (#28943)
The DuckDuckGo search path catches RatelimitException from the ddgs library. That exception is defined by the library but never raised anywhere in it, checked against the pinned 9.14.4 and against 9.11.3, so the handler could never run. The two fallbacks around it were dead for the same reason: ddgs.text() returns a non-empty list or raises, so None and an empty list are not outcomes it can produce.

Removing all three leaves one call and changes nothing observable. A refused or rate limited search already came out as a failed search, with the error shown to the user and the traceback in the log, and it still does.

The backend argument is now passed as backend or 'auto' rather than conditionally omitted, because 'auto' is the library's own default for that parameter, so every configured value including unset and empty resolves exactly as before. Verified by running the old and the new function side by side against a stubbed library covering normal results, the domain filter, all four backend settings and a failing search, with identical results in every case.
2026-08-23 12:59:05 -04:00
Timothy Jaeryang Baek
3c66d639e3 refac 2026-08-23 12:33:34 -04:00
Timothy Jaeryang Baek
c1c81f8127 refac 2026-08-23 03:40:40 -04:00
Timothy Jaeryang Baek
f3f76095d1 refac 2026-08-23 02:34:08 -04:00
Timothy Jaeryang Baek
4807866a1c refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-23 01:31:30 -04:00
Timothy Jaeryang Baek
7abe11346a refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
2026-08-22 09:13:13 -04:00
Timothy Jaeryang Baek
d17f06a235 refac 2026-08-22 08:43:46 -04:00
Timothy Jaeryang Baek
1b3b9375bb refac 2026-08-22 08:21:53 -04:00
Timothy Jaeryang Baek
d7d935275a refac 2026-08-22 08:18:38 -04:00
G30
302ffc8b7e
fix: skip the autocompletion debounce when the document shrank past the captured position (#28824) 2026-08-22 08:12:40 -04:00
G30
d1c207d091
fix: give docx and pptx previews in the file modal a definite frame height (#28878) 2026-08-22 08:11:29 -04:00
Timothy Jaeryang Baek
ccbb3303f2 refac 2026-08-22 08:05:52 -04:00
G30
883c7434fb
fix: tolerate reasoning items without started_at when closing them at stream end (#28872)
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-08-21 15:51:21 -07:00
Timothy Jaeryang Baek
dcff244f9e refac 2026-08-21 15:50:50 -07:00
Aleix Dorca
6a999f357b
i18n: Update catalan translation.json (#28826) 2026-08-21 12:08:53 -07:00
G30
d2bc98eaeb
fix: let the composer's model selector shrink so narrow containers keep every control visible (#28912) 2026-08-21 12:04:59 -07:00
Classic298
7229fac0c4
refactor: remove the orphaned admin settings components (#28855)
Some checks are pending
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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
Two components under the admin settings area are not imported by any route or component. The admin settings shell went dead when the admin settings route became a redirect into the settings modal, which imports every admin tab directly and carries its own tab list and search. The model selector beside it lost its last importer in a separate models refactor. Every child component the shell used is still imported by the settings modal, so nothing goes with them.

This removes around 590 lines that still turn up in every search across the admin area.
2026-08-20 13:37:53 -07:00
Classic298
c7f306031d
refactor: remove the unreachable async half of the Mistral loader and an unused Datalab helper (#28839)
The Mistral OCR loader has a full async pipeline beside its synchronous one: an async load, its own upload, signed URL, OCR, delete and retry helpers, a pooled session and a batch loader on top. The only way in was the batch loader, which nothing calls, so the entire async half was unreachable. Everything that loads documents goes through the synchronous path, and the shared loader entry point runs it in a worker thread. The Datalab loader carries a public request status poller with no caller either, since its own load inlines the polling it needs.

With the async half gone, the retry classifier's two aiohttp branches can no longer be reached, since the only retried calls are synchronous, so those go with it along with the aiohttp import that existed solely to feed them, and a timeout attribute that nothing reads any more. The class docstring loses the three bullets that only described the removed pipeline, and four docstrings stop calling themselves the sync version of something that no longer has an async counterpart.

This removes around 350 lines and leaves one code path per loader instead of one live path and one that cannot be entered.
2026-08-20 13:14:14 -07:00
Timothy Jaeryang Baek
7d4747dfd7 refac 2026-08-20 13:13:51 -07:00
Classic298
18c604baa9
fix: voice mode produces no audio when the task model returns empty content (#28724)
With "show emoji in call" enabled, voice mode stayed completely silent and no request ever reached the configured TTS server. Reasoning models served with a reasoning parser return `message.content` as null and put the text in `reasoning_content`, and the emoji helper called `.replace()` on that null value and threw.

The call overlay ran the emoji request first, inside the same `try` block as speech synthesis, so that error skipped the entire TTS section. The audio cache was never filled, and the playback loop kept re-queueing the same content every 200 ms without ever playing it. Read aloud was unaffected because it synthesizes speech directly, which is why the failure looked specific to voice mode.

Fixed on both sides: the optional chain in `generateEmoji` now covers `content`, and the emoji request in the call overlay gets its own catch, matching the speech synthesis call directly below it. An emoji failure now costs the emoji instead of the whole reply.
2026-08-20 13:03:55 -07:00
Classic298
5586964bb2
fix: keep the usage pool cleanup task alive across lock loss and Redis errors (#28834)
With WEBSOCKET_MANAGER=redis on a multi-node deployment, the usage pool cleanup task could stop permanently for the whole cluster. Nodes that lost the startup lock race gave up for good after three attempts, and the winner died on a single failed renew or on any Redis connection error, releasing the lock with nobody left to take it over. From then on expired entries accumulated in the usage pool until a node restarted, so /api/usage over-reported models in use and every disconnect handler walked an ever-growing pool.

The task now retries lock acquisition forever like the session pool cleanup does, and any error is logged and answered by releasing the lock and returning to acquisition, so a transient failure costs one cleanup cycle and every node stays a takeover candidate. The delete of an emptied model entry is KeyError-guarded because a disconnect handler on another node can remove the same key between the sweep's snapshot and its delete; unguarded, that race was a permanent task killer that needed nothing rarer than a chat finishing while its tab closed.
2026-08-20 12:59:38 -07:00
Classic298
b0fdc00452
perf: write task payloads to Redis as bytes (#28833)
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.
2026-08-20 12:58:52 -07:00
G30
a0e7d0e3a4
fix: send the model id when ejecting from the model selector (#28766) 2026-08-20 12:58:14 -07:00
G30
528259695c
fix: key selected item lists in model editor selectors to stop checkbox state reuse (#28837) 2026-08-20 12:57:39 -07:00
Timothy Jaeryang Baek
f822605b35 refac 2026-08-20 12:56:45 -07:00
Timothy Jaeryang Baek
8a42aa53e8 refac 2026-08-19 22:48:32 -07:00
Timothy Jaeryang Baek
46dce79eb0 refac 2026-08-19 22:28:41 -07:00
Classic298
4df2d9a7aa
perf: filter workspace models by access in SQL instead of loading every model (#28795)
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (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
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
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 / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
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.
2026-08-19 18:15:01 -07:00
Classic298
ce22e0bb15
perf: stop rescanning the whole chat JSON on every streamed event write (#28820)
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.
2026-08-19 17:52:05 -07:00
Timothy Jaeryang Baek
33dff414e8 refac 2026-08-19 16:35:49 -07:00
G30
dd7158b45f
fix: clear the integrations search when leaving a section (#28812) 2026-08-19 16:14:53 -07:00
Timothy Jaeryang Baek
ea55d38793 refac 2026-08-19 16:14:27 -07:00
Classic298
7cf6051a74
perf: resolve group membership once per folder listing instead of once per entry (#28810)
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.
2026-08-19 11:16:29 -07:00
Classic298
767a1157f1
refac: bind tool server cookies per connection (#28707)
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.
2026-08-19 11:16:16 -07:00
Classic298
abc69000b3
fix: treat a non-numeric calendar alert_minutes as unset (#28790)
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.
2026-08-19 11:16:00 -07:00
Classic298
2e7df54673
fix: surface attached chat references in <attached_files> (#28788)
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>
2026-08-19 11:15:22 -07:00
Classic298
ebd4d9c6cc
fix: honor bypass_system_prompt on the pipe route (#28739)
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.
2026-08-19 11:08:02 -07:00
Classic298
4f98a5184f
perf: resolve model-attached file access with a targeted query (#28802)
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.
2026-08-19 11:07:43 -07:00
Classic298
dbf715cb63
perf: stop scanning every skill on each listing and chat turn (#28798)
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.
2026-08-19 11:07:33 -07:00
Classic298
9bdb072690
refac: remove unused knowledge base accessors (#28794)
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.
2026-08-19 11:07:21 -07:00
Classic298
284da2ae49
perf: reuse the already loaded chat when assembling builtin tools (#28809)
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.
2026-08-19 11:06:49 -07:00
Classic298
6db64c4855
perf: batch the shared folder listing instead of fetching one folder at a time (#28804)
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.
2026-08-19 11:06:37 -07:00
Classic298
81fe43f210
perf: write a chat's messages in one transaction instead of one per message (#28806)
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.
2026-08-19 12:46:49 -05:00
G30
bcb50fe7b0
fix: derive integrations toggle state from the selected ids (#28807) 2026-08-19 12:46:17 -05:00
Timothy Jaeryang Baek
5ea9ff3ed9 refac 2026-08-18 22:09:42 -07:00
Timothy Jaeryang Baek
3fdfbd5138 refac
Some checks are pending
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
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 / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-08-18 19:37:20 -07:00
Timothy Jaeryang Baek
b838860dc0 refac 2026-08-18 19:30:42 -07:00
Classic298
21e390561d
fix: revoke existing sessions when a password changes (#28725)
Some checks failed
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
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 / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
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.
2026-08-17 13:56:29 -07:00
Classic298
3fc491d22f
chore: drop test-only dependencies from the Docker image and the published package (#28726)
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
2026-08-17 13:56:08 -07:00
Timothy Jaeryang Baek
ffda8aea80 refac 2026-08-17 01:34:37 -07:00
Timothy Jaeryang Baek
f67875ec57 refac 2026-08-17 01:30:21 -07:00
G30
88c55b86b1
feat: emit auth.login on SSO logins and attribute SSO logouts (#27619)
* feat: emit the auth.login event on SSO logins

* feat: attribute SSO logouts in the auth.logout event payload
2026-08-17 02:22:25 -06:00
Timothy Jaeryang Baek
a3a81fee03 refac 2026-08-17 01:21:58 -07:00
Classic298
646a568ae6
fix: enforce global web search and image generation switches on the legacy function-calling path (#27669)
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.
2026-08-17 02:15:05 -06:00
G30
7ea46a37d0
fix: clip settings modal contents to its rounded corners (#27617) 2026-08-17 02:12:26 -06:00
Timothy Jaeryang Baek
b1dc945bd6 refac 2026-08-17 00:59:13 -07:00
Timothy Jaeryang Baek
0b27fa5e87 refac 2026-08-17 00:57:57 -07:00
Classic298
017075a2d7
perf: drop unused database session dependencies from seven endpoints (#28178)
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.
2026-08-17 01:53:00 -06:00
Timothy Jaeryang Baek
87d9b7e84e refac 2026-08-17 00:51:04 -07:00
Timothy Jaeryang Baek
6e468c5b95 refac 2026-08-17 00:50:54 -07:00
Timothy Jaeryang Baek
4ec6ee1441 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-17 00:47:32 -07:00
Classic298
ba0c4b3932
fix: don't hold a database connection for the lifetime of an SSE stream (#28183)
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.
2026-08-17 01:46:54 -06:00
Timothy Jaeryang Baek
e968445812 refac 2026-08-17 00:43:47 -07:00
Timothy Jaeryang Baek
d799e81edb refac 2026-08-17 00:42:16 -07:00
G30
c0d09a5de9
fix: list publicly shared read-only notes in the Read Only view (#27637) 2026-08-17 01:40:56 -06:00
James Kerrane
44f4d5b94f
chore: refresh outdated version examples in bug report template (#28188)
* refactor: remove unused optional assignees key

According to the GitHub docs (https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms#top-level-syntax) this key is optional. Since it's unused, it is fine to remove.

* chore: bump version examples for software

Older versions might confuse people filing new issues, so newer versions of mentioned software are used as examples.
2026-08-17 01:39:13 -06:00
Timothy Jaeryang Baek
f1a64ccfc2 refac 2026-08-17 00:35:12 -07:00
Timothy Jaeryang Baek
76d0160295 refac 2026-08-17 00:31:01 -07:00
Timothy Jaeryang Baek
9550731cc1 refac 2026-08-17 00:24:47 -07:00
Classic298
189c14fc4d
fix: match both JSON text spellings when searching serialised JSON columns (#28399)
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>
2026-08-17 01:24:05 -06:00
G30
695d33aa7c
fix: let the chat column shrink so the collapsed sidebar rail is not pushed off screen (#28501) 2026-08-17 01:23:25 -06:00
G30
f5a5a434b9
fix: record an error state when a timer's chat completion raises (#27785) 2026-08-17 01:22:47 -06:00
G30
31897b7e34
fix: stop channel message hover actions overlapping code and table toolbars (#27737) 2026-08-17 01:20:54 -06:00
Timothy Jaeryang Baek
ad8c79f686 refac 2026-08-17 00:18:35 -07:00
Lin Junrong
211906d799
fix: keep references to lifespan background tasks (#28053)
periodic_usage_pool_cleanup, periodic_session_pool_cleanup and
scheduler_worker_loop were started with asyncio.create_task and their
handles discarded. The event loop keeps only a weak reference to a task,
so a task with no other referent can be garbage collected while it is
suspended at an await. All three are while True loops meant to run for
the process lifetime, and if one is collected the failure is silent:
pool entries stop being cleaned up, or automations and calendar alerts
stop firing, with nothing logged.

Six lines above, redis_task_command_listener is already stored on
app.state and cancelled on shutdown. This applies the same treatment to
the other three.

Closes #28052
2026-08-17 01:18:22 -06:00
Timothy Jaeryang Baek
0007369f4e refac 2026-08-17 00:16:11 -07:00
Timothy Jaeryang Baek
b6dc70c93b refac 2026-08-17 00:16:07 -07:00
Timothy Jaeryang Baek
d2af19ae3c refac 2026-08-17 00:15:36 -07:00
Classic298
3d630491c6
fix: re-syncing an existing model no longer fails silently (#28036)
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
2026-08-17 01:14:11 -06:00
Classic298
b933292d63
refactor: track visited ids when resolving a chat's current message (#28035)
`delete_message_from_history` follows `childrenIds` down to the deepest leaf without recording where it has been. Record it.
2026-08-17 01:13:51 -06:00
Timothy Jaeryang Baek
f100edb708 refac 2026-08-17 00:12:13 -07:00
Classic298
27402ff210
refac: issue Playwright web loader requests from the shared HTTP clients (#28634)
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.
2026-08-17 01:06:12 -06:00
Classic298
73c1f5806a
refactor: match provider identity lookups via JSON subscript (#28624)
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.
2026-08-17 01:05:16 -06:00
G30
90724cdee0
fix: drop the white backdrop behind model icons in the admin Models list (#27612) 2026-08-17 01:03:37 -06:00
G30
8fc5ffe26e
fix: persist the Open Sharing permission in default user permissions (#27609) 2026-08-17 01:03:07 -06:00
Timothy Jaeryang Baek
0480ca9653 refac
Co-Authored-By: Solaris-star <67425364+solaris-star@users.noreply.github.com>
2026-08-17 00:01:38 -07:00
Timothy Jaeryang Baek
927ce0eae6 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-16 23:58:56 -07:00
Damien S
54cefd2b99
fix: preserve complete user context in agentic retrieval (#27642)
* fix: preserve user info in agentic RAG tools

* fix: preserve user info in file access checks

---------

Co-authored-by: Damien SPINELLI <damien.spinelli@external.list.lu>
2026-08-17 00:56:48 -06:00
xyonium
686d8dc54c
fix: strip prefix id from model name in /responses endpoint (#28575)
The /openai/responses endpoint forwarded the prefixed model id (e.g.
"myprovider.gpt-4o") to the upstream provider instead of the stripped
native name, causing "model not found" errors when a connection has a
Prefix ID configured.

generate_chat_completion() already strips the prefix before forwarding;
apply the same strip_provider_model_prefix() call in responses() after
the urlIdx routing (which needs the prefixed id) and re-serialize the
body afterwards.

Also fixes the Azure non-v1 deployment path, which built the deployment
URL from the prefixed model name.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-17 00:53:00 -06:00
Classic298
3df485582d
fix: reject skill IDs that are not URL path safe (#27660)
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
2026-08-17 00:52:16 -06:00
Timothy Jaeryang Baek
954613944b refac 2026-08-16 23:51:38 -07:00
Classic298
805bfca5af
feat: emit group events on OAuth group sync (#27657)
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.
2026-08-17 00:50:47 -06:00
Timothy Jaeryang Baek
d5d50169f4 refac 2026-08-16 23:48:05 -07:00
Timothy Jaeryang Baek
75df30c0ea refac 2026-08-16 23:47:02 -07:00
Timothy Jaeryang Baek
3e186abdd9 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-16 23:41:58 -07:00
G30
2813eb44f2
fix: stop the What's New modal drawing two bullets per changelog entry (#28676) 2026-08-17 00:40:57 -06:00
G30
ad72dc6658
fix: scroll to the top of a chat on the first click of Scroll to Top (#28659) 2026-08-17 00:40:14 -06:00
Timothy Jaeryang Baek
16f118d77a refac 2026-08-16 23:38:34 -07:00
G30
884388cac3
fix(search): wire up mark as unread in the search chats modal (#28136) 2026-08-17 00:34:49 -06:00
Timothy Jaeryang Baek
736e38338e refac 2026-08-16 23:32:23 -07:00
Timothy Jaeryang Baek
a40f6f2860 refac 2026-08-16 23:28:48 -07:00
Timothy Jaeryang Baek
b5da50f3df refac 2026-08-16 23:26:24 -07:00
Classic298
cd9db21c52
refac: bind tool server cookies per connection (#28630)
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.
2026-08-17 00:25:07 -06:00
Classic298
7d392bedc9
refac: align the channel completion gate with the message update route (#28631)
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.
2026-08-17 00:24:49 -06:00
Classic298
1756c9d5d2
fix: default pinned models stop applying after a user's first page load (#28069)
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.
2026-08-17 00:22:51 -06:00
Timothy Jaeryang Baek
1a376ac17f refac 2026-08-16 23:21:00 -07:00
Timothy Jaeryang Baek
b211c407f4 refac 2026-08-16 23:14:08 -07:00
G30
1d1c14bd77
fix: compare folder names for uniqueness with lower() equality instead of a LIKE pattern (#28695) 2026-08-17 00:00:02 -06:00
Timothy Jaeryang Baek
e4694d6c3f refac 2026-08-16 22:59:10 -07:00
Timothy Jaeryang Baek
f7a533cda6 refac 2026-08-16 22:57:01 -07:00
Timothy Jaeryang Baek
3258330729 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-16 22:56:19 -07:00
Timothy Jaeryang Baek
62fc436999 refac 2026-08-16 22:52:24 -07:00
Timothy Jaeryang Baek
31d08d592c refac
Some checks failed
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
2026-08-15 01:10:14 -06:00
Timothy Jaeryang Baek
467be93e6d refac 2026-08-15 01:09:39 -06:00
Timothy Jaeryang Baek
7fc5fa1ff3 refac 2026-08-15 01:02:06 -06:00
Timothy Jaeryang Baek
3eb65f4715 refac 2026-08-15 00:06:38 -06:00
Timothy Jaeryang Baek
30f82788bc refac 2026-08-15 00:05:45 -06:00
Timothy Jaeryang Baek
bbfdbd59f2 refac 2026-08-15 00:05:37 -06:00
Timothy Jaeryang Baek
98b9df0398 refac 2026-08-15 00:05:11 -06:00
Timothy Jaeryang Baek
0f821398ca refac 2026-08-14 23:52:13 -06:00
Timothy Jaeryang Baek
1b39ff352a refac 2026-08-14 23:50:34 -06:00
Timothy Jaeryang Baek
a5ea732c1e refac 2026-08-14 23:47:00 -06:00
Timothy Jaeryang Baek
516cf1a9a6 refac 2026-08-14 21:44:13 -06:00
Timothy Jaeryang Baek
d02b6a21fc refac
Some checks failed
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
2026-08-14 00:55:21 -06:00
Timothy Jaeryang Baek
c755ef60c6 refac 2026-08-14 00:54:33 -06:00
Timothy Jaeryang Baek
a1579a01ff refac 2026-08-14 00:22:17 -06:00
Timothy Jaeryang Baek
f7767d6be7 refac 2026-08-13 22:08:42 -06:00
Timothy Jaeryang Baek
d9014b3483 refac 2026-08-13 22:01:33 -06:00
Timothy Jaeryang Baek
57bd08304e refac 2026-08-13 22:01:17 -06:00
Timothy Jaeryang Baek
14e4d72d9a refac 2026-08-13 22:01:15 -06:00
Timothy Jaeryang Baek
256cce505b refac 2026-08-13 21:51:04 -06:00
Timothy Jaeryang Baek
55c202e841 refac 2026-08-13 21:48:01 -06:00
Timothy Jaeryang Baek
fa94a5ab24 refac 2026-08-13 21:36:41 -06:00
Timothy Jaeryang Baek
b4738d1a2e refac 2026-08-13 21:31:49 -06:00
Timothy Jaeryang Baek
653562d660 refac 2026-08-13 21:15:26 -06:00
Timothy Jaeryang Baek
a32a17965c refac 2026-08-13 21:06:51 -06:00
Timothy Jaeryang Baek
083e351441 refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (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
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 / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-08-13 21:02:17 -06:00
Timothy Jaeryang Baek
ec36972c2b refac 2026-08-13 20:02:08 -06:00
Timothy Jaeryang Baek
7d99b2716a refac 2026-08-13 19:59:11 -06:00
Timothy Jaeryang Baek
b018feb741 refac 2026-08-13 18:33:51 -06:00
Timothy Jaeryang Baek
133549a87e refac 2026-08-13 18:19:25 -06:00
Timothy Jaeryang Baek
4465f52a3e refac 2026-08-13 18:13:43 -06:00
Timothy Jaeryang Baek
2c01d59335 refac 2026-08-13 17:26:38 -06:00
Timothy Jaeryang Baek
7593698080 refac 2026-08-13 16:42:51 -06:00
Timothy Jaeryang Baek
2649e3305c refac 2026-08-13 16:42:10 -06:00
Timothy Jaeryang Baek
794671a988 refac 2026-08-13 16:16:10 -06:00
Timothy Jaeryang Baek
b7292890cc refac 2026-08-13 16:13:47 -06:00
Timothy Jaeryang Baek
f96b717566 refac 2026-08-13 16:04:53 -06:00
Timothy Jaeryang Baek
b3a5fd3875 refac 2026-08-13 15:57:42 -06:00
Timothy Jaeryang Baek
76583749ed refac 2026-08-13 15:52:44 -06:00
Timothy Jaeryang Baek
ec9bf5a64f refac 2026-08-13 15:49:50 -06:00
Timothy Jaeryang Baek
52c5e3b20d refac 2026-08-13 15:43:27 -06:00
Timothy Jaeryang Baek
c93c6d6fc4 refac 2026-08-13 15:42:54 -06:00
Timothy Jaeryang Baek
c1f914a626 refac 2026-08-13 15:38:25 -06:00
Timothy Jaeryang Baek
bfb68feea7 refac 2026-08-13 15:33:42 -06:00
Timothy Jaeryang Baek
2befa8f796 refac 2026-08-13 15:26:19 -06:00
Timothy Jaeryang Baek
7dfbdd221a refac 2026-08-13 15:20:59 -06:00
Timothy Jaeryang Baek
5c05608e3a refac 2026-08-13 15:02:18 -06:00
Timothy Jaeryang Baek
8d25ad00e2 refac 2026-08-13 14:52:30 -06:00
Timothy Jaeryang Baek
60feca71a6 refac 2026-08-13 14:50:43 -06:00
Timothy Jaeryang Baek
a17cb174ad refac 2026-08-13 14:46:08 -06:00
Classic298
5cb87e6630
fix: announce toggle state of integrations menu rows to screen readers (#27667)
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
2026-08-13 13:04:13 -06:00
Timothy Jaeryang Baek
ba885d0026 refac 2026-08-13 13:03:56 -06:00
G30
5c8d9e69c4
ci: auto-label feature requests with the enhancement label (#28531) 2026-08-13 12:55:03 -06:00
Timothy Jaeryang Baek
17c190bf99 refac 2026-08-13 02:07:02 -06:00
Timothy Jaeryang Baek
f8c5fda283 refac 2026-08-13 02:03:13 -06:00
Timothy Jaeryang Baek
31c1ffd55a refac 2026-08-13 00:45:02 -06:00
Timothy Jaeryang Baek
e1acd7e7ca refac 2026-08-13 00:38:32 -06:00
Timothy Jaeryang Baek
25802c048e refac 2026-08-13 00:34:10 -06:00
Timothy Jaeryang Baek
8260d527ee refac 2026-08-13 00:22:58 -06:00
Timothy Jaeryang Baek
85c3d0ae2f refac 2026-08-13 00:07:31 -06:00
Timothy Jaeryang Baek
3813fd4cdc refac 2026-08-12 23:20:19 -06:00
Timothy Jaeryang Baek
4e03d89414 refac 2026-08-12 23:17:37 -06:00
Timothy Jaeryang Baek
90bb94abf9 refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Python CI / Ruff Format (3.12) (push) Waiting to run
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-08-12 18:13:47 -06:00
Timothy Jaeryang Baek
86b7bf1f7e refac 2026-08-12 18:10:51 -06:00
Timothy Jaeryang Baek
2e93987490 refac 2026-08-12 17:54:10 -06:00
Timothy Jaeryang Baek
939bcdb79e refac
Co-Authored-By: G30 <50341825+silentoplayz@users.noreply.github.com>
2026-08-12 01:13:38 -06:00
G30
1deeaf71da
fix: stop the calendar update tool from nulling every omitted field (#27777) 2026-08-12 01:09:07 -06:00
G30
2ab0311b99
fix: reject COUNT rules without DTSTART so limited automations cannot run forever (#27781) 2026-08-12 01:08:55 -06:00
G30
783e87c0c5
fix: dock the thread reply input below the scroll area instead of inside it (#27768) 2026-08-12 02:06:20 -05:00
G30
104a0f2f11
fix: remove vestigial api_base_url param that broke the Tavily web loader (#27636) 2026-08-12 02:05:26 -05:00
Timothy Jaeryang Baek
e44e16cb59 refac 2026-08-12 01:02:02 -06:00
Timothy Jaeryang Baek
c086b80313 refac 2026-08-12 00:51:12 -06:00
Timothy Jaeryang Baek
ab41dcc487 refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (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
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 / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-08-11 17:44:34 -06:00
Timothy Jaeryang Baek
9c21d4ed3b refac 2026-08-11 17:42:25 -06:00
G30
e17dfae72e
fix: enforce the image generation flag on the legacy chat feature path (#27759)
* fix: enforce the image generation flag on the legacy chat feature path

* fix: refresh the config store when image generation is disabled in admin settings

* fix: hide active feature pills when the feature is no longer available
2026-08-11 17:38:10 -06:00
Timothy Jaeryang Baek
1744b63f16 refac 2026-08-11 17:35:24 -06:00
Timothy Jaeryang Baek
4f9a0ebf71 refac 2026-08-11 17:35:05 -06:00
Timothy Jaeryang Baek
65c3539666 refac 2026-08-11 15:13:59 -06:00
Timothy Jaeryang Baek
0a598e6ff7 refac 2026-08-11 15:09:48 -06:00
Timothy Jaeryang Baek
1674e5a9ef refac 2026-08-11 12:50:50 -06:00
Timothy Jaeryang Baek
724d2ebbf1 refac 2026-08-11 11:46:12 -06:00
Timothy Jaeryang Baek
ac0368b4ab refac 2026-08-11 11:40:29 -06:00
Timothy Jaeryang Baek
f79b443c22 refac 2026-08-11 11:40:24 -06:00
G30
e58245d5b4
fix: place model editor capability checkboxes directly before their labels (#27788) 2026-08-11 11:13:04 -06:00
G30
25455e943d
fix(ui): resolve owner avatars against the API base url and add a fallback (#28272) 2026-08-11 11:05:35 -06:00
Timothy Jaeryang Baek
29541cbb52 refac 2026-08-11 01:27:50 -06:00
Timothy Jaeryang Baek
c8f8fa451a a11y
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-11 01:25:20 -06:00
Timothy Jaeryang Baek
865c80c160 refac 2026-08-11 01:17:19 -06:00
Timothy Jaeryang Baek
f0bfcd4097 refac 2026-08-11 01:15:05 -06:00
Timothy Jaeryang Baek
be4afd7545 refac 2026-08-11 01:11:56 -06:00
Timothy Jaeryang Baek
e963d36e39 refac 2026-08-11 01:08:58 -06:00
KastenMonster
2ac01a42be
feat: Add openserp as a provider in the ui (#27594)
* add OpenSerp Option

* fix: change input label to the docs label

---------

Co-authored-by: KastenMonster <kastenmonster@groundonline.de>
2026-08-11 00:21:05 -06:00
Timothy Jaeryang Baek
9122c24ea2 refac 2026-08-11 00:06:21 -06:00
G30
79382d1b19
fix: stop profile preview avatars from squashing in narrow flex rows (#28000) 2026-08-10 23:49:45 -06:00
Timothy Jaeryang Baek
68c67a1e0f refac
Co-Authored-By: G30 <50341825+silentoplayz@users.noreply.github.com>
2026-08-10 23:48:02 -06:00
Timothy Jaeryang Baek
5cd9a39534 refac 2026-08-10 23:40:31 -06:00
Timothy Jaeryang Baek
bd250a0e24 refac 2026-08-10 23:37:45 -06:00
Classic298
934802e186
fix: enforce features.memories permission on the legacy memory context path (#27668)
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.
2026-08-10 23:36:27 -06:00
G30
d959e3e312
fix: scale the settings modal height with tall viewports instead of capping at 54rem (#27615) 2026-08-10 23:34:04 -06:00
Classic298
4eb0394511
fix: merged response receiving empty model responses after reload (#27673)
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
2026-08-10 23:31:21 -06:00
Classic298
fcc130c9bb
fix: send stream_options.include_usage for backend-initiated chats (#27661)
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
2026-08-10 23:30:46 -06:00
G30
085f1b5ca4
feat: add a user setting to toggle sidebar chat hover previews (#27632)
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-08-10 23:29:51 -06:00
G30
8e83d83324
fix: left-edge clipping of the terminal cloud icon and account profile avatar (#27691) 2026-08-10 23:28:17 -06:00
Solaris-star
2387ce63cb
fix(chat): keep regenerate button visible in High Contrast mode (#27644)
Every action button on a response message gates its visibility on
'isLastMessage || ($settings?.highContrastMode ?? false)' so that buttons
stay visible (not hover-only) when High Contrast mode is on. The two
Regenerate buttons — the one inside RegenerateMenu and the fallback in the
{:else} branch — were missed and still gated on isLastMessage alone, so on
any non-last message the Regenerate icon was invisible until mouse-over even
with High Contrast enabled (#27638).

Add the same highContrastMode clause to both buttons, matching the other
action buttons in this file.
2026-08-10 23:27:42 -06:00
Classic298
be9af1653e
fix: anchor chat input expand button to the input row (#27676)
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
2026-08-10 23:26:53 -06:00
G30
b91bb67b55
fix: hide chat fork actions when chat import permission is disabled (#27711)
Co-authored-by: Nameless-Monster-Nerd <Nameless-Monster-Nerd@users.noreply.github.com>
2026-08-10 23:24:59 -06:00
Classic298
5c79ccc9e5
refactor: walk chat message history by map key (#28034)
`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.
2026-08-10 23:24:41 -06:00
G30
80d2f4154a
fix: align public_tools and public_notes sharing defaults with config (#27716)
The SharingPermissions model defaulted public_tools and public_notes to
True while the config defaults (USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING
and USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING) are both False.

On an instance whose stored user.permissions config predates these keys,
GET /api/v1/users/default/permissions fills the gap from the model and
reports both as enabled, while has_permission fills it from
DEFAULT_USER_PERMISSIONS and denies. Saving any unrelated permission then
persists the model's True, granting public tool and note sharing the admin
never enabled.
2026-08-10 23:24:18 -06:00
G30
a3d33b4cf3
fix: hide chat delete actions when the chat delete permission is disabled (#27714)
The chat delete endpoints gate on chat.delete for non-admins, but none of
the UI affordances that reach them were conditioned on it, so a user
without the permission is offered controls that always fail with
"Access prohibited".

Gates every entry point on the same condition the backend enforces,
matching the existing chat.share / chat.export / chat.import gates in the
same components:

- Sidebar chat menu (ChatMenu) Delete item — also covers the Search
  modal's menu, which reuses ChatMenu
- Sidebar Shift+hover inline Delete button (ChatItem)
- Sidebar hidden #delete-chat-button keyboard-shortcut target (ChatItem)
- Chat navbar menu Delete item (Navbar/Menu)
- Search modal Shift-held inline Delete button (SearchModal)
- Settings -> Data Controls -> Delete All Chats
- Settings -> Archived Chats per-row Delete
2026-08-10 23:24:02 -06:00
G30
fe62934be7
fix: derive overview node spacing from rem so branches never overlap (#27995) 2026-08-10 23:23:31 -06:00
Classic298
2207876ae7
fix: generate valid WEBUI_SECRET_KEY in start_windows.bat (#28061)
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>
2026-08-10 23:22:43 -06:00
Timothy Jaeryang Baek
bd8378f643 refac 2026-08-10 23:22:08 -06:00
Classic298
1f22cccd22
perf: stop formatting every exported log record twice under OTEL log export (#27840)
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.
2026-08-10 23:13:52 -06:00
Timothy Jaeryang Baek
ce3c175e26 refac 2026-08-10 23:13:10 -06:00
Timothy Jaeryang Baek
de289eb1aa refac 2026-08-10 23:07:47 -06:00
Timothy Jaeryang Baek
d8ae7ed405 refac 2026-08-10 23:01:41 -06:00
Cyp
bee1ded5ab
i18n: update Korean translations (#27681)
Co-authored-by: bglee <bglee@hct.co.kr>
2026-08-10 23:01:24 -06:00
Timothy Jaeryang Baek
629cdcb530 refac 2026-08-10 22:57:21 -06:00
Timothy Jaeryang Baek
89922cc9d5 refac 2026-08-10 22:53:37 -06:00
Timothy Jaeryang Baek
2a6e671f54 refac 2026-08-10 22:47:39 -06:00
Timothy Jaeryang Baek
c2107e5bb3 refac 2026-08-10 22:36:42 -06:00
Kylapaallikko
b7de04da14
Update fi-FI translation.json (#27700)
Added missing translations and improved existing ones.
2026-08-10 22:27:44 -06:00
G30
38a03830c2
fix: show a placeholder when an image cannot be loaded (#27730)
A message referencing a file that no longer exists rendered a broken
image, because nothing anywhere noticed the failed load. What the user saw
depended on the caller's alt text: in a response the alt is the message
content, so the entire reply was rendered inside the image frame, and the
preview still opened full-screen on a dead image.

Image.svelte now tracks a failed load and renders an 'Image unavailable'
placeholder instead, suppressing the preview for a source that cannot be
shown. The failed state resets when the source changes, so a replaced or
corrected URL is retried. An onError callback lets a caller react without
changing behaviour for the eight existing call sites, which are untouched.

Responses now pass the file name (or 'Generated Image') as alt rather than
the message content, which was never a description of the image.
2026-08-10 22:27:13 -06:00
Timothy Jaeryang Baek
0b4b7ae5ff refac 2026-08-10 22:20:53 -06:00
G30
04c22f0c41
fix: use the local date for the event modal's default start and end (#27779) 2026-08-10 22:19:54 -06:00
Classic298
3dbb4078b3
fix: repair two broken logging calls, one of which makes VECTOR_DB=opengauss unusable (#27838)
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.
2026-08-10 22:19:41 -06:00
Timothy Jaeryang Baek
3793b0c886 refac 2026-08-10 22:15:54 -06:00
Timothy Jaeryang Baek
3e9b075954 refac 2026-08-10 22:11:05 -06:00
Timothy Jaeryang Baek
2a45fa04cb refac 2026-08-10 21:58:12 -06:00
Teitur Bendtsen
b74c144c96
i18n: add Faroese translation (#28159)
Co-authored-by: T209211 <tbe@betri.fo>
2026-08-10 21:51:45 -06:00
Adam I. Horvath
cbd47eaa67
i18n: improve hungarian translations (#28214)
adds missing hungarian translations and improves existing hungarian ui labels.
2026-08-10 21:44:40 -06:00
Classic298
38fcee7f21
Update pull_request_template.md (#28246) 2026-08-10 21:43:33 -06:00
developersorli
f44647e251
feat(i18n): add complete Slovenian (sl-SI) translations (#28268) 2026-08-10 21:43:02 -06:00
Classic298
a680f21e12
feat: make OAuth admin settings read-only when ENABLE_OAUTH_PERSISTENT_CONFIG is off (#28276)
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>
2026-08-10 21:41:07 -06:00
G30
a3e5d0b362
fix(chat): attribute shared chat messages to their author, not the viewer (#28274) 2026-08-10 21:40:54 -06:00
Timothy Jaeryang Baek
8edab5020e refac 2026-08-10 21:39:27 -06:00
Timothy Jaeryang Baek
3c010951db refac 2026-08-10 21:21:52 -06:00
Timothy Jaeryang Baek
48a5696042 refac 2026-08-10 21:06:40 -06:00
Timothy Jaeryang Baek
d6679082e5 refac 2026-08-10 21:00:01 -06:00
Timothy Jaeryang Baek
943294df9a refac 2026-08-10 20:46:38 -06:00
G30
d661fb49b8
ci: relabel bug reports when the title is corrected after opening (#28333)
The labeler only listened for issues.opened, so it judged a title exactly
once. Reporters who omit the prefix are asked by the triage bot to add one,
and the title they then fix is never looked at again, leaving a genuine bug
report unlabelled until somebody notices it by hand.

It now also runs on issues.edited. Body only edits return immediately, so
the extra runs are limited to titles actually changing, and a bug label a
maintainer has already removed is not restored: on an edit the issue events
are checked for a previous removal first. The opened path is unchanged and
makes no additional API call.

The title pattern also required a delimiter after the bracket, so the
bracketed form the comment advertises, "[Bug] something is broken", never
matched unless it happened to be written "[Bug]: something is broken".
Both forms match now, while "[bug/perf]" keeps matching and titles that
merely mention the word, such as "[UI Bug]" or "fixed a typo", still do not.
2026-08-10 20:45:02 -06:00
G30
d10d552117
fix(chats): surface the error message instead of [object Object] (#28260) 2026-08-10 20:44:32 -06:00
Timothy Jaeryang Baek
178ccb30e1 refac 2026-08-10 20:37:53 -06:00
Timothy Jaeryang Baek
5cecb7dbfa refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-08-10 20:30:56 -06:00
G30
148283f974
fix: release a queued message once an attached URL finishes (#28381)
A message sent while an attachment is still uploading is held in the chat
queue until the file settles. For files that release works, because the
message input calls onUpdate once the upload completes, which refreshes the
queued entry and asks the queue to run.

Attaching a URL takes a different path. uploadWeb sets the item to uploaded
but calls neither onUpdate nor processNextInQueue, and the queue is
otherwise only revisited when a generation finishes or when the chat is
mounted while idle. In a new chat with nothing generating, none of those
happen, so a message queued behind a URL waits with no event able to
release it until the chat is reloaded.

uploadWeb now asks the queue to run when it is done, the same way the file
path already does.
2026-08-10 20:24:35 -06:00
Timothy Jaeryang Baek
b20bcdbba7 refac 2026-08-10 20:22:29 -06:00
Timothy Jaeryang Baek
5ec16e76e6 refac 2026-08-10 20:13:03 -06:00
Classic298
5462c02af0
fix: OIDC login fails when the provider adds a private JOSE header (#28065)
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
2026-08-10 20:06:31 -06:00
Timothy Jaeryang Baek
a41faa3c22 refac 2026-08-10 20:00:43 -06:00
Classic298
c5ec01b1f9
fix: make the aiodns resolver opt-in and pin aiodns to 3.6.1 (#28242)
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.
2026-08-10 19:52:29 -06:00
Timothy Jaeryang Baek
8d1c205d8e refac 2026-08-10 19:46:46 -06:00
Timothy Jaeryang Baek
b606e13da3 refac 2026-08-10 19:44:56 -06:00
Classic298
eff5c4a2d9
feat: parse :::writing block metadata and use the subject as the block title (#28280)
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.
2026-08-10 19:44:06 -06:00
Timothy Jaeryang Baek
385d08bea5 refac 2026-08-10 19:42:05 -06:00
Timothy Jaeryang Baek
11739a2de8 refac 2026-08-10 19:41:05 -06:00
Classic298
92f9f36c69
Update CODE_OF_CONDUCT.md (#28349) 2026-08-10 19:34:33 -06:00
Timothy Jaeryang Baek
e4dd6c4bf1 refac 2026-08-10 19:33:48 -06:00
Timothy Jaeryang Baek
d22bb6703f refac 2026-08-10 19:28:21 -06:00
Classic298
e5b24a22d0
fix: model ID whitelists accepting duplicate entries (#28251)
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
2026-08-10 19:27:12 -06:00
Timothy Jaeryang Baek
ff74bfa6a1 refac 2026-08-10 19:25:26 -06:00
Timothy Jaeryang Baek
f8ac75d188 refac 2026-08-10 19:21:52 -06:00
G30
8836dcb59f
fix(ui): fall back to the default avatar when a profile image fails to load (#28270) 2026-08-10 19:18:32 -06:00
G30
121f2404ee
fix(retrieval): report why a URL could not be read instead of blaming the knowledge base (#28362)
Fetching a URL and saving it were reported as one thing. Everything from
reading the URL to writing the vector database sat inside a single try,
whose handler blamed the knowledge base, so a page that could not be
fetched, parsed or resolved was reported as a knowledge base error even
though nothing had reached the knowledge base yet. Reading the URL now has
its own handler that names the URL, and the knowledge base message is left
to the step that actually touches it.

When YouTube refused a transcript the reason was discarded earlier still:
the loader caught the error, logged it, and returned an empty document
list, so the empty result failed downstream and even the salvageable
explanation was gone before a message was produced. The loader now raises
YoutubeTranscriptError carrying a readable reason, mapped from the
transcript library's own exception types. Blocked requests mention that a
proxy can be configured, and disabled, age restricted, unavailable and
missing language cases each say what actually happened.

URLs that attach successfully are unaffected.
2026-08-10 19:18:04 -06:00
Classic298
d9e23b90c1
refac: share one folder write-access check across chat folder_id paths (#28366)
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.
2026-08-10 19:17:18 -06:00
Timothy Jaeryang Baek
72a909fd2f refac 2026-08-10 19:16:21 -06:00
Timothy Jaeryang Baek
ec03e88144 refac 2026-08-10 19:08:46 -06:00
Timothy Jaeryang Baek
b5f86e6a43 refac 2026-08-10 19:08:06 -06:00
Timothy Jaeryang Baek
30d08a42f8 refac 2026-08-10 18:52:58 -06:00
Timothy Jaeryang Baek
407c40f72c refac 2026-08-10 18:52:34 -06:00
Timothy Jaeryang Baek
b4d3b27caf refac 2026-08-10 18:52:27 -06:00
Timothy Jaeryang Baek
d4461bd6f3 refac 2026-08-10 18:52:18 -06:00
Timothy Jaeryang Baek
eeaf1a1df0 refac 2026-08-10 18:51:38 -06:00
Timothy Jaeryang Baek
90a0e61cef refac 2026-08-10 18:50:43 -06:00
Timothy Jaeryang Baek
37f2548155 refac 2026-08-10 18:46:36 -06:00
Timothy Jaeryang Baek
13346c5f16 refac 2026-08-10 18:43:55 -06:00
Classic298
8d6a7c8308
perf: route native JSON columns through JSONCodec instead of stdlib json (#28396)
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>
2026-08-10 19:39:49 -05:00
Timothy Jaeryang Baek
8fbfd14a8b refac 2026-08-10 01:38:32 -06:00
Timothy Jaeryang Baek
060648f939 refac 2026-08-10 01:36:09 -06:00
Timothy Jaeryang Baek
61110677d4 refac 2026-08-10 01:32:34 -06:00
Timothy Jaeryang Baek
f9cd49443c refac 2026-08-10 01:32:29 -06:00
Timothy Jaeryang Baek
4e69166017 refac 2026-08-10 01:32:11 -06:00
Timothy Jaeryang Baek
048c063993 refac 2026-08-10 01:26:04 -06:00
Timothy Jaeryang Baek
ff7467b4c5 refac 2026-08-10 01:14:53 -06:00
Timothy Jaeryang Baek
8dd23f74c9 refac 2026-08-10 00:52:14 -06:00
Timothy Jaeryang Baek
1b72899f24 refac 2026-08-10 00:26:44 -06:00
Timothy Jaeryang Baek
a33fa05adc refac 2026-08-10 00:19:52 -06:00
Timothy Jaeryang Baek
5b8975b7da refac 2026-08-10 00:05:55 -06:00
Timothy Jaeryang Baek
2dadc5435a refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (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_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
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 / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
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 / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-08-09 13:22:46 -06:00
Timothy Jaeryang Baek
5caa91a493 refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-08-08 18:47:57 -06:00
Classic298
74a7902821
fix: apply response.output_item.done instead of ignoring it (#28310)
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.
2026-08-08 18:33:39 -06:00
Classic298
a39126c27c
fix: catch the socket.io timeout in the event caller (#28311)
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.
2026-08-08 18:33:12 -06:00
Classic298
fc8a9b8ed6
fix: stop the Responses delta handler falling through to a crash (#28312)
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.
2026-08-08 18:33:00 -06:00
Timothy Jaeryang Baek
009999f363 refac 2026-08-08 15:47:10 -06:00
Timothy Jaeryang Baek
8faaf2cd1e refac
Some checks failed
Python CI / Ruff Format (3.11) (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
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) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
2026-08-05 10:37:38 -05:00
Timothy Jaeryang Baek
c1c07cbe0f refac 2026-08-05 07:57:55 -05:00
Timothy Jaeryang Baek
6c4d0ace16 refac 2026-08-05 07:44:06 -05:00
Timothy Jaeryang Baek
29eeda9f9a refac 2026-08-05 07:12:28 -05:00
Timothy Jaeryang Baek
9c7ce154e7 refac 2026-08-05 07:04:30 -05:00
Timothy Jaeryang Baek
cbb3aade2b refac 2026-08-05 06:41:30 -05:00
Timothy Jaeryang Baek
0800c21c64 refac 2026-08-05 00:47:49 -05:00
Timothy Jaeryang Baek
8dbbc206c5 refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
2026-08-04 00:41:26 -05:00
Classic298
2d18727ab8
perf: build info log messages lazily so raising the log level actually saves work (#27837)
Some checks failed
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
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.
2026-08-02 15:39:10 -05:00
Classic298
52145eede9
perf: take the orjson fast path for ensure_ascii=False callers (#27841)
Some checks are pending
Python CI / Ruff Format (3.12) (push) Waiting to run
Python CI / Ruff Format (3.11) (push) Waiting to run
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
2026-07-31 20:39:10 -05:00
Classic298
615807ad0b
chore: remove unused json import in models/chats.py (#27796)
Some checks failed
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
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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/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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
`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.
2026-07-31 19:09:41 -05:00
Classic298
798f3935ae
fix: keep streamed Responses output when response.completed reports an empty output array (#27800)
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
2026-07-31 19:09:32 -05:00
Classic298
ac8af4996c
perf: index group_member on (user_id, group_id) (#27822)
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.
2026-07-31 19:09:15 -05:00
Classic298
52cfb02c72
perf: build debug log messages lazily so disabled debug logs cost nothing (#27834)
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.
2026-07-31 19:09:01 -05:00
G30
b67804f2b6
fix: do not auto-open artifacts from the sidebar chat hover preview (#27773) 2026-07-31 17:58:02 -04:00
Timothy Jaeryang Baek
78ed5a0235 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-07-31 17:45:07 -04:00
Timothy Jaeryang Baek
bb0f898b43 refac 2026-07-31 17:41:14 -04:00
Timothy Jaeryang Baek
3becec6ccf refac 2026-07-31 17:35:34 -04:00
Timothy Jaeryang Baek
5b333d75c6 refac 2026-07-31 17:34:39 -04:00
Classic298
ec0e60033b
perf: use the orjson codec for the permission deep copy (#27807)
`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.
2026-07-31 17:32:14 -04:00
Classic298
e2221fb662
perf: use the orjson codec to parse Jupyter kernel messages (#27812)
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.
2026-07-31 17:32:05 -04:00
Classic298
d03e9af0b7
perf: use the orjson codec to parse Oracle vector metadata (#27813)
`_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.
2026-07-31 17:31:14 -04:00
Timothy Jaeryang Baek
d721b0d196 refac 2026-07-31 17:30:47 -04:00
Classic298
ace84b4ae9
perf: use the orjson codec for outbound Ollama request bodies (#27811)
`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.
2026-07-31 17:26:38 -04:00
Classic298
006a63e641
perf: use the orjson codec for the Anthropic passthrough request body (#27810)
`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.
2026-07-31 17:26:07 -04:00
Classic298
243a39dc9d
perf: read the model pool with one HGETALL instead of one HGET per model (#27821)
`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.
2026-07-31 17:25:53 -04:00
Classic298
6be11d4fc9
chore: remove dead json imports (#27815)
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.
2026-07-31 17:25:40 -04:00
Classic298
1d6735ff0b
fix: escape line separators in orjson output (#27819)
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.
2026-07-31 17:25:30 -04:00
joaoback
8d333335b9
i18n: add pt-BR translations for newly added UI items and consistency pass (#27814)
New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.
2026-07-31 17:25:10 -04:00
Classic298
f4c6a76651
perf: use the orjson codec for Valkey vector metadata (#27805)
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.
2026-07-31 17:24:57 -04:00
Classic298
466e05801b
perf: stop query_collection blocking the event loop (#27824)
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.
2026-07-31 17:24:31 -04:00
Timothy Jaeryang Baek
810378c0b8 refac
Some checks failed
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
2026-07-27 19:39:36 -04:00
Timothy Jaeryang Baek
b6b16d5871 refac 2026-07-27 19:24:03 -04:00
Tim Baek
01f4282f1f
Merge pull request #27590 from open-webui/dev
Some checks failed
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Release to PyPI / release (push) Has been cancelled
Release / publish (push) Has been cancelled
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
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) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
refac
2026-07-27 05:47:11 -05:00
Timothy Jaeryang Baek
483adf7040 refac 2026-07-27 06:46:42 -04:00
Tim Baek
f9590b8017
Merge pull request #27362 from open-webui/dev
0.11.0
2026-07-27 04:30:03 -05:00
Timothy Jaeryang Baek
faeba7c17a refac 2026-07-27 05:28:33 -04:00
Timothy Jaeryang Baek
2beddbe49f refac 2026-07-27 04:58:46 -04:00
Timothy Jaeryang Baek
abc8889681 refac 2026-07-27 04:51:08 -04:00
Timothy Jaeryang Baek
58dc25125b refac 2026-07-27 04:50:07 -04:00
Classic298
70c04eb675
changelog: Update CHANGELOG.md (#27060)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2026-07-27 04:47:26 -04:00
Timothy Jaeryang Baek
965ef909d7 chore: format 2026-07-27 04:46:45 -04:00
Timothy Jaeryang Baek
39206602ac refac 2026-07-27 04:46:12 -04:00
Timothy Jaeryang Baek
50e050e195 refac
Co-Authored-By: djedi-knight <943190+djedi-knight@users.noreply.github.com>
2026-07-27 04:43:51 -04:00
Timothy Jaeryang Baek
c004b4ecb5 chore: format 2026-07-27 04:38:46 -04:00
Timothy Jaeryang Baek
c4332be71e refac 2026-07-27 04:36:56 -04:00
Timothy Jaeryang Baek
b190dcf3ca refac 2026-07-27 04:36:51 -04:00
Timothy Jaeryang Baek
56183fcb17 refac 2026-07-27 04:27:13 -04:00
Timothy Jaeryang Baek
4eab2550a0 refac 2026-07-27 04:17:03 -04:00
Timothy Jaeryang Baek
4493b56e42 refac 2026-07-27 04:17:00 -04:00
Timothy Jaeryang Baek
8ab44ed3b1 refac 2026-07-27 04:11:48 -04:00
Timothy Jaeryang Baek
977c793062 refac 2026-07-27 04:09:02 -04:00
Timothy Jaeryang Baek
c182a95ffd refac 2026-07-27 04:05:51 -04:00
Timothy Jaeryang Baek
11e61b69eb refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-07-27 04:00:46 -04:00
Timothy Jaeryang Baek
e8fda1c7a0 refac 2026-07-27 03:59:12 -04:00
Timothy Jaeryang Baek
a9a3e5b95c refac 2026-07-27 03:54:27 -04:00
Timothy Jaeryang Baek
2d928df304 refac 2026-07-27 03:54:05 -04:00
Timothy Jaeryang Baek
b7394c63fa refac 2026-07-27 03:53:54 -04:00
Timothy Jaeryang Baek
c4ae8c8678 refac 2026-07-27 03:51:32 -04:00
Timothy Jaeryang Baek
48ee357156 refac 2026-07-27 03:50:18 -04:00
Timothy Jaeryang Baek
7e31f64bc8 refac 2026-07-27 03:50:14 -04:00
Classic298
72fdf238a8
perf: optional orjson JSON codec behind ENABLE_ORJSON (#27583)
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
2026-07-27 03:45:37 -04:00
Timothy Jaeryang Baek
602004dd5f refac 2026-07-27 03:44:13 -04:00
Timothy Jaeryang Baek
7537989235 refac 2026-07-27 03:41:08 -04:00
G30
867006acce
fix: keep admin access to connections without access grants when admin bypass is disabled (#27581) 2026-07-27 03:39:40 -04:00
Timothy Jaeryang Baek
be1b811ce5 refac 2026-07-27 03:34:26 -04:00
Timothy Jaeryang Baek
db2d24896b refac 2026-07-27 03:32:21 -04:00
Classic298
3ab2026262
fix: bound knowledge-search matching so one pattern cannot stall the worker (#27471)
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.
2026-07-27 03:22:26 -04:00
Classic298
147c3b6ac8
fix: mark the open chat with aria-current in the sidebar (WCAG 1.4.1, 4.1.2) (#27502)
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.
2026-07-27 03:20:32 -04:00
Timothy Jaeryang Baek
2f2bf38e34 refac 2026-07-27 03:19:10 -04:00
Timothy Jaeryang Baek
f59d86a10c refac 2026-07-27 03:18:12 -04:00
Classic298
71511ccd5a
fix: make sidebar folder rows keyboard operable (WCAG 2.1.1, 4.1.2) (#27509)
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>
2026-07-27 03:16:25 -04:00
Timothy Jaeryang Baek
9707d3a5c2 refac 2026-07-27 03:09:25 -04:00
Classic298
bb928b0dfe
fix: fetch the terminal system prompt per request (#27242)
* 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>
2026-07-27 03:07:47 -04:00
Timothy Jaeryang Baek
69e449e318 refac 2026-07-27 03:05:26 -04:00
Classic298
5278eb906e
fix: block external resource loading in Vega chart rendering to prevent client-side SSRF (#26806)
* 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>
2026-07-27 03:03:30 -04:00
Timothy Jaeryang Baek
4c2d864b3f refac 2026-07-27 03:01:19 -04:00
Timothy Jaeryang Baek
44f4f9dce4 refac 2026-07-27 03:00:37 -04:00
Timothy Jaeryang Baek
15688686af refac 2026-07-27 03:00:04 -04:00
Timothy Jaeryang Baek
93a34bb25b refac 2026-07-27 02:59:58 -04:00
Timothy Jaeryang Baek
70549c5c8a refac 2026-07-27 02:59:07 -04:00
Timothy Jaeryang Baek
ba556bd8f0 refac 2026-07-27 02:49:08 -04:00
Timothy Jaeryang Baek
7d77efe0f1 refac 2026-07-27 02:47:09 -04:00
Timothy Jaeryang Baek
8e74cac8de refac 2026-07-27 02:44:40 -04:00
Timothy Jaeryang Baek
f1409266fe refac 2026-07-27 02:44:21 -04:00
Timothy Jaeryang Baek
0576e8eeb5 refac 2026-07-27 02:42:36 -04:00
Timothy Jaeryang Baek
12974c9e4e refac 2026-07-27 02:41:05 -04:00
Timothy Jaeryang Baek
3fe03583a3 refac 2026-07-27 02:39:11 -04:00
Timothy Jaeryang Baek
d727ee4d1f refac 2026-07-27 02:38:33 -04:00
Timothy Jaeryang Baek
9acbe3aa0f refac 2026-07-27 02:36:15 -04:00
Timothy Jaeryang Baek
76aae64c7b refac 2026-07-27 02:34:04 -04:00
Timothy Jaeryang Baek
e28b391e51 refac 2026-07-27 02:29:56 -04:00
Timothy Jaeryang Baek
98656b7c5e refac 2026-07-27 02:28:21 -04:00
Timothy Jaeryang Baek
f578d8d67e refac 2026-07-27 02:27:27 -04:00
Timothy Jaeryang Baek
6aebfd88e9 refac 2026-07-27 02:24:41 -04:00
Timothy Jaeryang Baek
498cdab9a5 refac 2026-07-27 02:23:33 -04:00
Classic298
2e4c232807
perf: update chat tags via the meta column instead of round-tripping the blob (#27382)
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.
2026-07-27 02:21:59 -04:00
Classic298
707efeaed7
fix: scope knowledge sync cleanup deletions to the target knowledge base (#26722)
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>
2026-07-27 02:18:38 -04:00
Timothy Jaeryang Baek
8710c448a9 refac 2026-07-27 02:17:16 -04:00
Timothy Jaeryang Baek
312d8a8e7f refac
Co-Authored-By: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
2026-07-27 02:17:11 -04:00
Naytip
3fff80ad2f
fix: resolve MCP OAuth authorize_url discovery and fail clearly (#26647) (#26654) 2026-07-27 02:15:28 -04:00
Classic298
d3cfcd801e
fix: preserve system prompt across tool calls when memories are enabled (#26857)
* 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>
2026-07-27 02:13:28 -04:00
Timothy Jaeryang Baek
f91ac068d0 refac 2026-07-27 02:12:40 -04:00
Timothy Jaeryang Baek
6c59ef313f refac 2026-07-27 02:11:10 -04:00
Classic298
6d4c02a89e
refac: owner-bind ephemeral web-search RAG collections (#26706)
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>
2026-07-27 02:08:04 -04:00
Classic298
3a9b9a1a74
fix: resolve terminal system_oauth token server-side instead of trusting a client header (#26719)
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>
2026-07-27 02:07:34 -04:00
Classic298
6c7478c1c9
fix: surface web search embedding failures in the chat UI instead of silently returning an empty collection (#26883)
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
2026-07-27 02:07:14 -04:00
Timothy Jaeryang Baek
3492021361 refac 2026-07-27 02:04:44 -04:00
Classic298
e17db990af
fix: parse .msg uploads via unstructured instead of extract_msg (#26704)
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
2026-07-27 02:01:04 -04:00
Timothy Jaeryang Baek
304cbe4569 refac 2026-07-27 02:00:04 -04:00
Timothy Jaeryang Baek
c4f5ac65ee refac 2026-07-27 01:59:17 -04:00
Sebastian
8f9e9398f8
fix(docker): make open_webui/static writable by an arbitrary UID (OpenShift) (#26664)
The backend rewrites its bundled static assets under open_webui/static on
startup. Under OpenShift's restricted SCC the container runs as a random UID
(member of GID 0), which cannot write to the root-owned static dir, so boot
logs fill with '[Errno 13] Permission denied: .../static/*'.

Give GID 0 the owner's permissions on that directory (chgrp 0 + chmod g=u),
the standard Red Hat arbitrary-UID idiom. Applied unconditionally since the
app writes there on every start; complements the opt-in USE_PERMISSION_HARDENING.
2026-07-27 01:55:53 -04:00
Classic298
897d69a35c
fix: enforce feature permissions on the legacy chat-features block (image_generation, web_search) (#26703)
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.
2026-07-27 01:54:00 -04:00
Classic298
f65f893ff1
perf: stop refetching the model row and user groups in the completion access check (#27378)
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.
2026-07-27 01:52:04 -04:00
Classic298
3fe829acc2
fix: strip model params for read-only callers in the model list endpoint (#27004)
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>
2026-07-27 01:51:29 -04:00
Classic298
c05de13b4f
fix: do not expose tool source code to read-only users (#27005)
* 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>
2026-07-27 01:51:01 -04:00
catty42
9562f1a67d
fix: honor grep -c and -l flags for piped input in kb_exec (#26721)
Co-authored-by: yuki4266 <258261435+yuki4266@users.noreply.github.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-07-27 01:49:02 -04:00
Classic298
d29685275b
perf: drop the full-payload deepcopy in the OpenAI to Ollama conversion (#27371)
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.
2026-07-27 01:47:23 -04:00
Classic298
915ef7d079
fix: restrict folder deletion to the owner or an admin (#27003)
* 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>
2026-07-27 01:46:58 -04:00
Timothy Jaeryang Baek
305880f2e2 refac 2026-07-27 01:46:10 -04:00
Timothy Jaeryang Baek
7801909d27 a11y
Co-Authored-By: Vince Castillo, PhD <154394560+professorcastillo@users.noreply.github.com>
2026-07-27 01:43:58 -04:00
Classic298
bc600d3f08
fix: escape KaTeX render-error fallback to prevent XSS via {@html} (#26718)
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>
2026-07-27 01:36:41 -04:00
Timothy Jaeryang Baek
067cf31f40 refac 2026-07-27 01:32:41 -04:00
Timothy Jaeryang Baek
8a90bf6256 chore: format 2026-07-27 01:22:08 -04:00
G30
cce3b68265
fix: enforce a single open user profile preview across ProfilePreview instances (#27578) 2026-07-27 01:21:57 -04:00
Timothy Jaeryang Baek
def26ce266 refac 2026-07-27 01:21:32 -04:00
Timothy Jaeryang Baek
75e54bf46b refac 2026-07-27 01:21:00 -04:00
Timothy Jaeryang Baek
89caa7c849 refac 2026-07-27 01:19:34 -04:00
Vince Castillo, PhD
6379d37863
fix: expose ConfirmDialog with dialog role and label its input (WCAG 4.1.2, 3.3.2) (#26769)
ConfirmDialog trapped focus and closed on Escape but its container was a plain
  div, so screen readers did not announce it as a modal dialog. Its text input
  also had only a placeholder, giving no persistent accessible name. Add
  role=dialog / aria-modal / aria-label / tabindex to the dialog surface and an
  aria-label to the textarea.

  Relates to #2790

Co-authored-by: Tim Baek <tim@openwebui.com>
2026-07-27 01:15:12 -04:00
Timothy Jaeryang Baek
e8f2c123e6 refac 2026-07-27 01:13:09 -04:00
Classic298
7e96c53a20
feat: multiselect valve input type with static or dynamic options (#26884)
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.
2026-07-27 01:11:38 -04:00
Timothy Jaeryang Baek
051a1f6c41 refac 2026-07-27 01:10:58 -04:00
Timothy Jaeryang Baek
6732852ce6 refac 2026-07-27 01:05:52 -04:00
Timothy Jaeryang Baek
de681aa543 refac 2026-07-27 01:03:10 -04:00
Classic298
b40b6fd698
fix: reject backslash in the terminal proxy path sanitizer (#27198)
_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>
2026-07-27 01:01:10 -04:00
Classic298
e30ed01b05
perf: stream pure passthrough proxy responses by network chunk instead of by line (#27384)
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.
2026-07-27 00:58:24 -04:00
Classic298
5dcca59aee
fix: route OAuth profile-picture fetch through the SSRF-safe session (#26699)
_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.
2026-07-27 00:56:24 -04:00
Timothy Jaeryang Baek
dd86b984bd refac 2026-07-27 00:55:16 -04:00
Timothy Jaeryang Baek
1717b493d8 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-07-27 00:54:28 -04:00
Timothy Jaeryang Baek
5c505c1119 refac 2026-07-27 00:48:30 -04:00
G30
085d11eef2
chore: drop redundant background repaints so surfaces inherit their parent (#27576)
* chore: drop redundant background repaints so surfaces inherit their parent

Four spots repaint the exact color their parent surface already provides
(bg-white / dark:bg-gray-900 rows inside same-colored pages and modals,
and the selectClass dark repaint inside the connection modals — the
sibling input const is already fully transparent). Visually identical in
stock light and dark; removing them lets instance theming show through
instead of leaving opaque boxes:

- .tiptap tr (app.css) — table rows in notes/editors
- Edit User Group Users tab body rows (common Modal surface)
- AddToolServerModal + AddTerminalServerModal selectClass dark repaint

The matching repaints inside the ModelUsage/UserUsage components are
not part of this change — those files were dead code and were removed
entirely in #27574.

* chore: catch remaining redundant surface repaints missed in the first pass

Same rule as the previous commit — every one of these repaints the exact
color its parent surface already provides, so removal is stock-identical
in light and dark while letting instance theming show through:

- Analytics Dashboard's inline Model Usage / User Activity row markup
  (the Analytics tab renders these tables from Dashboard.svelte itself;
  the unreferenced ModelUsage/UserUsage component files were removed
  in #27574)
- Evaluations Feedbacks + Leaderboard body rows (settings modal surface)
- admin UserList body rows (app page surface)
- chat markdown tables (MarkdownTokens): thead and body rows — unlike
  the tiptap header (gray-850 contrast, untouched), this thead painted
  the page's own color
- CitationsModal source rows (common Modal surface)
- AddConnectionModal selectClass dark repaint — third copy of the same
  const already fixed in AddToolServerModal / AddTerminalServerModal
2026-07-27 00:44:51 -04:00
Classic298
41573d52f1
fix: require an authenticated user on the Ollama version route (#27199)
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>
2026-07-27 00:44:31 -04:00
G30
8295f2dacc
chore: remove dead admin Analytics ModelUsage and UserUsage components (#27574)
Nothing in the tree imports either component; the admin Analytics tab
renders its own inline copies of both tables directly from
Dashboard.svelte. Both files landed with the dashboard in a4ad34841
(feat: analytics frontend dashboard) but were never wired into it.

The remaining name matches elsewhere (the getUserUsage API and
UserUsage* types in src/lib/apis/users/index.ts, consumed by
chat/Settings/Usage.svelte, plus the backend usage endpoints) belong to
the unrelated per-user usage feature and are untouched.
2026-07-27 00:35:08 -04:00
Timothy Jaeryang Baek
55e0801dab refac 2026-07-27 00:34:25 -04:00
EntropyYue
f21d7947f9
fix: Set default Redis socket timeout to None (#27104) 2026-07-27 00:30:00 -04:00
Timothy Jaeryang Baek
57e60423b9 refac 2026-07-27 00:27:38 -04:00
Timothy Jaeryang Baek
20647bd2d5 chore: format 2026-07-27 00:12:47 -04:00
Timothy Jaeryang Baek
e53ff57fb5 refac 2026-07-27 00:12:16 -04:00
Timothy Jaeryang Baek
c727643e05 refac 2026-07-27 00:11:59 -04:00
Timothy Jaeryang Baek
4a7d4ebada refac 2026-07-27 00:10:36 -04:00
Timothy Jaeryang Baek
8ddf119570 refac 2026-07-26 23:55:37 -04:00
Timothy Jaeryang Baek
e5a08d5220 refac 2026-07-26 23:54:16 -04:00
Timothy Jaeryang Baek
ba7c95f7ef refac 2026-07-26 23:50:09 -04:00
G30
cb64068893
feat: add default file upload mode user setting (#20900)
* feat: add default upload mode setting

Add user setting to configure the default upload mode for files, allowing users to choose between "Using Entire Document" (full context) and "Using Focused Retrieval" (RAG processing) as the default behavior.

* i18n: sync locale catalogs for the new upload mode strings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: re-trigger CI (previous run hit the pre-existing Node heap OOM, see #27254)

* fix: apply the default upload mode at upload time so the payload carries it

The previous approach only pre-set the modal toggle's visual state on
mount; item.context is written solely by the Switch's on:change, so the
sent files kept context: undefined and the backend never saw 'full'. It
also showed a misleading ON state for legacy context-less files, since
FileItemModal mounts with every FileItem chip render.

Stamp context on the fileItem in uploadFileHandler instead (before
...itemData, so callers passing an explicit context still win) and
revert the FileItemModal hunk — the modal already renders from
item.context alone.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:50:03 -05:00
Timothy Jaeryang Baek
6f93ecd4fd refac 2026-07-26 23:49:03 -04:00
Classic298
2196b4e1ff
perf: stop resolving DNS on the thread pool (add aiodns) (#27440)
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.
2026-07-26 23:29:52 -04:00
Classic298
f32b19c1f6
feat: add {{USER_GROUPS}} and {{USER_GROUP_IDS}} placeholders for custom forwarded headers (#27236)
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>
2026-07-26 23:21:26 -04:00
Timothy Jaeryang Baek
3cd72ee6a8 refac 2026-07-26 23:19:20 -04:00
Timothy Jaeryang Baek
b7489bbc6c refac 2026-07-26 23:16:58 -04:00
Timothy Jaeryang Baek
ed663f16ec refac 2026-07-26 23:09:22 -04:00
Timothy Jaeryang Baek
95d590b360 refac 2026-07-26 23:03:32 -04:00
Timothy Jaeryang Baek
8b206de48e refac 2026-07-26 22:59:23 -04:00
Timothy Jaeryang Baek
bf35f64a7f refac 2026-07-26 22:45:11 -04:00
Timothy Jaeryang Baek
fc4906c9e9 refac 2026-07-26 22:32:17 -04:00
Timothy Jaeryang Baek
aadab2f480 refac 2026-07-26 22:32:06 -04:00
Timothy Jaeryang Baek
846ba80a9d refac 2026-07-26 22:32:03 -04:00
Timothy Jaeryang Baek
d94d36ad72 refac 2026-07-26 22:16:31 -04:00
Timothy Jaeryang Baek
d14fddf254 refac 2026-07-26 21:55:13 -04:00
Timothy Jaeryang Baek
0cbf337679 refac 2026-07-26 21:54:06 -04:00
Timothy Jaeryang Baek
85c47fb467 refac 2026-07-26 21:51:35 -04:00
Timothy Jaeryang Baek
1de36d600f refac 2026-07-26 21:35:56 -04:00
Timothy Jaeryang Baek
71c4da8c06 refac 2026-07-26 21:12:14 -04:00
Timothy Jaeryang Baek
f867825bf3 refac 2026-07-26 21:08:49 -04:00
Timothy Jaeryang Baek
b45c020f68 refac 2026-07-26 21:08:44 -04:00
Timothy Jaeryang Baek
d2936c880c refac 2026-07-26 21:07:27 -04:00
Timothy Jaeryang Baek
d484a2a99e refac 2026-07-26 21:07:20 -04:00
Timothy Jaeryang Baek
bab71ed08b refac 2026-07-26 21:06:18 -04:00
Timothy Jaeryang Baek
db5c092299 refac 2026-07-26 21:02:31 -04:00
Timothy Jaeryang Baek
f798d05586 refac
Some checks are pending
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-07-26 19:34:41 -04:00
Timothy Jaeryang Baek
94a60b0457 refac 2026-07-26 19:10:41 -04:00
Classic298
54f06d8c53
perf: build chat responses without deep-copying the blob through model_dump (#27388)
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.
2026-07-26 18:57:52 -04:00
Classic298
4f93c3e36c
fix: authorize before cancelling tasks in the chat delete endpoint (#27006)
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>
2026-07-26 18:57:35 -04:00
Classic298
1ac8ef7853
fix: gate the remaining text contrast failures behind High Contrast Mode (#27558)
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.
2026-07-26 18:57:00 -04:00
Classic298
5b035ea52b
fix: let Select announce its selected value and open state (WCAG 2.5.3, 4.1.2) (#27492)
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.
2026-07-26 18:56:49 -04:00
Classic298
4650f64c1e
fix: make admin user table sortable by keyboard (WCAG 2.1.1, 4.1.2) (#27501)
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.
2026-07-26 18:56:38 -04:00
Classic298
c055203f29
fix: refresh OAuth session before the id_token expires (#27520)
`_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
2026-07-26 18:56:18 -04:00
Classic298
99da2324e3
fix: preserve chunk order when assembling multi-chunk transcriptions (#27417)
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
2026-07-26 18:55:52 -04:00
Classic298
30be10f968
feat: prevent duplicate auth form submissions while one is pending (#27416)
* 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.
2026-07-26 18:55:41 -04:00
Classic298
381149ea5e
fix: persist filter outlet() changes to structured message output (#27414)
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.
2026-07-26 18:55:21 -04:00
Classic298
69f8be4cf9
fix: show download preparation toast and prevent duplicate zip jobs (#27421)
* 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.
2026-07-26 18:55:09 -04:00
Timothy Jaeryang Baek
bef63a2ae9 refac 2026-07-26 18:54:17 -04:00
Timothy Jaeryang Baek
df94268e89 refac 2026-07-26 18:54:07 -04:00
crustopher-lgtm
5efe0951d5
feat: add OpenSERP self-hosted web search backend (#27437)
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>
2026-07-26 18:52:08 -04:00
Timothy Jaeryang Baek
42ea8a5a2f refac 2026-07-26 18:50:22 -04:00
G30
bda49ccdb6
fix(ui): close sidebar on mobile when opening Calendar from user menu (#26979)
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.
2026-07-26 18:47:12 -04:00
Timothy Jaeryang Baek
b81627b2c9 refac 2026-07-26 18:46:39 -04:00
G30
11d72c1ce2
fix(ui): replace history entry on redirect so browser back navigation works (#27478) 2026-07-26 18:44:23 -04:00
G30
79695a1d14
fix: persist modelIdx so duplicate side-by-side models don't collapse on reload (#26980)
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.
2026-07-26 18:44:03 -04:00
G30
86bf927d08
fix(ui): stack automation modal footer on mobile (#27027)
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>
2026-07-26 18:43:45 -04:00
G30
db92ef292f
fix: unarchive chats moved into folders and refresh sidebar folders after menu moves (#27485) 2026-07-26 18:43:34 -04:00
G30
71f8b6d5b4
feat: add a master OAuth / OIDC enable toggle in Authentication settings (#26988)
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).
2026-07-26 18:43:21 -04:00
Classic298
18ca19044c
fix: gate muted text contrast fix behind High Contrast Mode (#27554)
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.
2026-07-26 18:37:49 -04:00
Timothy Jaeryang Baek
453b9fb029 refac 2026-07-26 18:36:49 -04:00
Classic298
50afbc5319
fix: allow setting model order via MODEL_ORDER_LIST env var (#27420)
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
2026-07-26 18:34:18 -04:00
Classic298
0116c6e1b9
perf: stop running chardet over entire uploaded files (#27445)
`_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.
2026-07-26 18:34:02 -04:00
Classic298
bc948f8f22
perf: parse scraped web pages off the event loop (#27446)
`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.
2026-07-26 18:33:27 -04:00
Classic298
b9cfba62d7
chore: regenerate uv.lock (#27557)
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`.
2026-07-26 18:31:12 -04:00
Timothy Jaeryang Baek
4856afcef8 refac 2026-07-26 18:30:58 -04:00
Timothy Jaeryang Baek
dcc7fb1e8e refac 2026-07-26 18:26:15 -04:00
Classic298
301bf519ab
fix: resolve circular OpenAPI schema refs in tool server specs (#27413)
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.
2026-07-26 18:20:10 -04:00
G30
771540f3de
feat: sortable title and updated at columns on the knowledge workspace page (#27457) 2026-07-26 18:19:31 -04:00
Classic298
fb1f1a3c92
perf: parse scraped web pages with lxml, not html.parser (#27439)
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.
2026-07-26 18:19:09 -04:00
Paulo Rodrigues
9c352e37b8
i18n: align the pt-PT locale with European Portuguese conventions (#27408)
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.
2026-07-26 18:18:39 -04:00
Classic298
18d004cabe
chore: drop python-jose, nothing imports it (#27444)
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.
2026-07-26 18:18:17 -04:00
G30
c9c97835bf
fix(ui): keep dropdowns positioned and stable as their content resizes (#27460) 2026-07-26 18:17:53 -04:00
G30
527b0d3b7f
fix(ui): stop the chat input attach menus from fetching twice on open (#27461) 2026-07-26 18:14:06 -04:00
Classic298
e140d8f3cc
fix: scope timer cancellation to the timer's owner (#27472)
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.
2026-07-26 18:12:44 -04:00
Classic298
a15e44a5ff
refac: use MilvusClient instead of deprecated ORM-style PyMilvus APIs (#27521)
* 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.
2026-07-26 18:12:08 -04:00
Classic298
f2ff310b2a
fix: gate gray-500 muted text contrast behind High Contrast Mode (#27556)
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.
2026-07-26 18:11:47 -04:00
Timothy Jaeryang Baek
0e0d08382a refac 2026-07-26 18:11:12 -04:00
Timothy Jaeryang Baek
1f0dc90abe refac 2026-07-26 18:06:03 -04:00
Classic298
e3cce68ef2
fix: gate placeholder contrast fix behind High Contrast Mode (#27555)
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.
2026-07-26 18:02:57 -04:00
Timothy Jaeryang Baek
7bfc4bb2c2 refac 2026-07-26 18:02:33 -04:00
Timothy Jaeryang Baek
0671b7aa2b refac 2026-07-26 17:58:01 -04:00
G30
202f47ece8
fix(ui): stop sidebar chat rows flickering while the pointer moves across them (#27474) 2026-07-26 17:57:44 -04:00
Kylapaallikko
8c9c64250d
Update fi-FI translation.json (#27469)
Added missing translations.
2026-07-26 17:57:00 -04:00
Classic298
076a84e3f0
fix: enforce automation limits in the builtin automation tools (#27523)
The `create_automation` and `update_automation` builtin tools wrote straight to `Automations.insert` / `Automations.update_by_id`, skipping the limit checks that `/api/v1/automations/create` and `/api/v1/automations/{id}/update` run through `check_automation_limits`. A non-admin user could therefore ask the model to create automations indefinitely, ignoring `AUTOMATION_MAX_COUNT`, and could schedule them below `AUTOMATION_MIN_INTERVAL`, on both create and update.

Both tools now call the same `check_automation_limits` helper the routers use, so the limits and the admin bypass cannot drift between the chat path and the HTTP path. A rejection is returned to the model as a plain error message instead of raising. `update_automation` also gained the missing user lookup guard, since the helper needs the user's role.

The `automations.enable` toggle and the `features.automations` user permission were already enforced when the tool set is assembled, so they are unaffected.

Fixes #27121
2026-07-26 17:55:56 -04:00
Classic298
3ce734c6c6
fix: bump uvicorn to 0.51.0 to move off the legacy websocket implementation (#27553)
Uvicorn's `--ws auto` selected its `websockets_impl` protocol on 0.41.0, which is built on `websockets.legacy`. That module raises `AssertionError` in `_drain_helper` during keepalive pings and kills the websocket connection. Each crash runs the Socket.IO `disconnect` handler and drops the session from `SESSION_POOL`, so every subsequent server-to-browser call fails. The most visible symptom is the Pyodide code execution tool, which reaches the browser through `sio.call('events', ...)` and returns `{"stderr": "Client session disconnected."}` on every run.

Uvicorn 0.50.0 changed `--ws auto` to select the sans-io implementation whenever websockets is installed, and deprecated the legacy one. Bumping the pin therefore fixes this on every launch path at once, without adding a `--ws` flag to the startup scripts. Doing nothing is not stable either: websockets is unpinned apart from uvicorn's own `>=13.0` floor, and `websockets.legacy` is removed outright in websockets 17, which turns the current AssertionError into an ImportError on a fresh install.

Bumping to 0.51.0 rather than the minimum 0.50.0 also picks up the sans-io keepalive pings added in 0.44.0, so raw websocket endpoints keep the idle-timeout behaviour they have today behind a reverse proxy. Uvicorn 0.51.0 drops colorama from its `standard` extra and raises the httptools floor to 0.8.0, which the lockfile already satisfies.

Verified on the bumped pin: the backend boots, `/health` returns 200, `--ws auto` resolves to `WebSocketsSansIOProtocol`, a Socket.IO client completes a websocket handshake against the running app, and a bidirectional `sio.call` round trip succeeds. The unit test suite reports an identical 2273 passed / 7 failed on 0.41.0 and 0.51.0, with the 7 failures unrelated to uvicorn.

Fixes #27550
2026-07-26 17:55:44 -04:00
Classic298
1e0ab84717
fix: unshadow the time module so the web loader rate limiter can sleep (#27528)
`from datetime import datetime, time, timedelta` shadows the `time` module, so `RateLimitMixin._sync_wait_for_rate_limit` calls `datetime.time.sleep` and raises `AttributeError: type object 'datetime.time' has no attribute 'sleep'` whenever it actually has to wait.

Every synchronous loader path that paces requests hits this. `SafeFireCrawlLoader.lazy_load` calls the limiter directly, and Tavily, Microsoft Web IQ and Playwright reach it through `_safe_process_url_sync`. The exception is raised inside their per-URL `try`, so with `continue_on_failure=True` (the default) the URL is logged as a per-URL failure and dropped instead of being scraped. This is live by default: `WEB_LOADER_CONCURRENT_REQUESTS` is passed as `requests_per_second` and defaults to 10, so any URL whose predecessor finished within 100ms takes the sleep branch and is lost. Tavily and Microsoft Web IQ report it as "SSL verification failed", which points at the wrong cause.

`_wait_for_rate_limit` uses `asyncio.sleep` and is unaffected, but `SafeMicrosoftWebIQLoader.alazy_load` runs `lazy_load` in a threadpool, so its async entry point is affected too.

`datetime.time` is not used anywhere in the file, so importing the `time` module instead is enough.

The per-URL `continue` half of #26079 landed in 6f8221df5, which also added the `_sync_wait_for_rate_limit()` call to the Firecrawl loop. This makes that call work rather than throw.

Fixes #26079
2026-07-26 17:55:28 -04:00
Timothy Jaeryang Baek
421834b2de refac 2026-07-26 17:51:49 -04:00
Classic298
c882222f68
fix: verify chat ownership on /api/chat/completed and /api/chat/actions (#27486)
Both routes read `chat_id` from the request body and passed it into `get_event_emitter` without checking the caller owns that chat. The emitter persists through `upsert_message_to_chat_by_id_and_message_id`, which resolves by primary key and takes no owner argument, so an invoked filter or action wrote into whichever chat the caller named. `/api/chat/completions` already performs this check; these two routes did not.

Adds `verify_chat_ownership`, called at the top of both handlers. It runs before the existing try block because the `except Exception` there catches HTTPException and would rewrite the 404 into a 400. Admins are exempt, matching the completions path, so deliberate cross-user operations keep working.

`local:` chat ids are allowed through: they are per-socket, the emitter suppresses database writes for them, and the socket emit targets the caller's own room. `channel:` chat ids are rejected instead. They reach the channel emitter, whose write only checks that the message belongs to the channel and never that the caller may write it, and the membership and write-access gate for channels exists solely on `/api/chat/completions`. No caller sends a `channel:` id to these two routes: the only frontend callers are in the regular chat UI, and the backend channel path dispatches through the completions handler.

Co-authored-by: manus-use <213290975+manus-use@users.noreply.github.com>
2026-07-26 17:39:11 -04:00
G30
878cebac07
fix(ui): persist ollama connection deletions immediately (#27483) 2026-07-26 17:38:49 -04:00
Classic298
a0ee66c145
fix: name the group permission switches (WCAG 4.1.2) (#27513)
`admin/Users/Groups/Permissions.svelte` contains **64** `<Switch>` instances and not one of them passes `ariaLabel`, `ariaLabelledbyId` or `id`. bits-ui renders the switch as a `<button role="switch">` whose subtree is a text free thumb, so all 64 have **no accessible name**. The visible label is a sibling `<div>` with no association to the control.

This is the worst remaining case in the admin area: 64 toggles in one dialog, many with near identical adjacent labels (Import Models / Export Models / Import Prompts / Export Prompts / Import Tools / Export Tools). A screen reader user hears 64 consecutive "switch, on" and "switch, off" with no way to tell which permission is which.

Breaks WCAG 4.1.2 Name, Role, Value (Level A).

Fix: pass the row's own label to each switch. The `ariaLabel` expression is the **same `$i18n.t()` key** as the visible text two lines above it, so the accessible name equals the visible label in every locale, which also satisfies 2.5.3 Label in Name and keeps voice control working.

`ariaLabel` rather than `ariaLabelledbyId`, which is what `chat/Settings/Interface.svelte` uses for the same row shape. The difference is that `Interface.svelte` is a singleton, whereas this component is rendered from `EditGroupModal`, which is instantiated in three places including once per group in `GroupItem.svelte`. Only one can be visible today, but nothing enforces that, and 64 hardcoded ids would fail silently the day two coexist, since `aria-labelledby` resolves to the first matching id. `aria-label` has no such failure mode and needs half the edits.

All 64 mappings were checked individually rather than assumed. The nearest preceding label is the correct one in every case, including the three rows wrapped in a `<Tooltip>` (whose `content` attribute precedes the label in source order) and the ~60 `{#if}` / `{:else if}` explanatory strings (which always follow their switch). All 64 resulting labels are distinct.

The nested sub toggles are unambiguous on their own because upstream already labelled them fully ("Import Models" rather than "Import"), so no extra scoping is needed.

Two known follow ups, deliberately not bundled:

- The warning tooltips on Tools Access, Skills Access and Automations ("Warning: Enabling this will allow users to upload arbitrary code on the server.") are attached to a non focusable wrapper `<div>`, so keyboard and screen reader users never receive them. That needs a change in `common/Tooltip.svelte` or an `ariaDescribedbyId` on `Switch`, not a naming change.
- This file is a ~14 line block repeated 64 times where only the label and permission key vary, and it wants a shared `PermissionRow` component. Extracting it here would bundle a large structural refactor into an accessibility fix and make the diff unreviewable against the claim, so it is left alone.

The diff is +164/−65 rather than 64 changed lines, because 33 of the switches exceed the 100 column print width and Prettier reflows them to the multi line form. The file is Prettier clean and compiles with no new warnings.

Severity: Serious.

### 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.
2026-07-26 17:38:35 -04:00
Classic298
ba4c92c4f0
fix: make sidebar section headers keyboard operable (WCAG 2.1.1, 4.1.2) (#27489)
On latest `dev`, each sidebar section header in `Sidebar/Section.svelte` is a real `<button>` carrying `aria-expanded` and `aria-controls`, but it has **no activation handler**. The toggle comes only from `on:pointerup` on the wrapper inside `common/Collapsible.svelte`.

Keyboard activation dispatches a synthetic `click`, never `pointerup`, and that wrapper's own `on:click` handler calls `stopPropagation()`. So pressing Enter or Space on the header does nothing at all, while `aria-expanded` tells assistive technology this is a working disclosure control.

This affects every section in the sidebar: Models, Notes, Channels, Folders and Chats. Section state is persisted to `localStorage`, so a user whose section was collapsed on a previous visit has no keyboard way to open it again, and the content stays unreachable.

Breaks WCAG 2.1.1 Keyboard (Level A), and 4.1.2 Name, Role, Value (Level A), because the exposed expanded state belongs to a control that cannot be operated.

Fix: handle activation on the header button itself, where focus actually lands, and stop the now duplicate pointer path so a mouse click does not toggle twice. The existing inline `onChange` body is extracted to `setOpen` so the `change` dispatch and the `localStorage` write stay in one place and fire exactly once per toggle in both input modes. The adjacent "+" (`onAdd`) button already stops both `pointerup` and `click`, so it still does not toggle the section.

`Collapsible`'s wrapper cannot simply become a `<button>` instead, because its slot receives buttons from this component and others, so the fix belongs here.

`common/Folder.svelte` and `Sidebar/RecursiveFolder.svelte` have the same latent defect and are not touched by this PR.

Severity: Critical. Sidebar navigation cannot be expanded without a mouse.

### 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.
2026-07-26 17:38:06 -04:00
Classic298
15f724b0f2
fix: give SensitiveInput a unique default id (WCAG 1.3.1, 4.1.2) (#27488)
On latest `dev`, `SensitiveInput` defaults to `export let id = 'password-input'`. The id is used both for the input itself and as the `for` target of the screen reader label rendered just above it.

There are 80 `<SensitiveInput>` usages in `src/` and only 4 pass an explicit id, so the remaining 76 all render `id="password-input"` together with `<label for="password-input">`. These collide on the same page in completely ordinary configurations: `admin/Settings/Audio.svelte` renders 4 at once with `STT_ENGINE === 'openai'` and 4 more with `TTS_ENGINE === 'openai'`, `admin/Settings/Documents.svelte` has 11, and `admin/Settings/WebSearch.svelte` has 33.

`for` resolves to the first matching element, so every label after the first points at the wrong input. In practice a screen reader user tabbing to the OpenAI TTS API key field hears the label belonging to the STT key field from a different section, and every one of those fields announces the same name. Browser password managers and any `getElementById` lookup collapse onto the first element the same way.

Breaks WCAG 1.3.1 Info and Relationships (Level A), because the programmatic label/field relationship is wrong, and 4.1.2 Name, Role, Value (Level A), because the fields do not expose their correct accessible name.

Fix: default the id to a per instance unique value. A Svelte prop default is evaluated per component instance, so each `SensitiveInput` gets its own stable id, and the 4 call sites that pass an explicit id are unaffected. `uuid` is already a direct dependency and `import { v4 as uuidv4 } from 'uuid'` is the existing pattern in the codebase, including `common/Collapsible.svelte`, which already generates a DOM id this way.

Note for self hosted setups: a `#password-input` selector in `static/custom.css` would stop matching. That selector already matched up to 8 elements at once on the Audio settings page, so it was never a reliable hook.

Severity: Serious. Every API key field in Admin Settings is mislabelled for assistive technology.

### 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.
2026-07-26 17:37:45 -04:00
Classic298
d3802f7660
fix: make reasoning and details disclosures keyboard operable (WCAG 2.1.1, 4.1.2) (#27490)
On latest `dev`, the `title !== null` branch of `Collapsible` renders its header as a bare `<div>` whose only handler is `on:pointerup`, with the two Svelte a11y warnings suppressed above it.

`pointerup` is never dispatched by keyboard activation, and the `<div>` has no `role`, no `tabindex` and no `aria-expanded`. The header is therefore not focusable, not activatable and not announced as a control. This is the header for "Thinking..." / "Thought for N seconds", "Analyzing..." / "Analyzed", and every `<details>` block rendered from model output, via `Messages/Markdown/MarkdownTokens.svelte`, `Messages/StructuredOutputRenderer.svelte` and `chat/Controls/Controls.svelte`.

In practice a keyboard or screen reader user cannot expand any model reasoning trace, tool call detail or code interpreter block, and a screen reader reads the header as static text with no hint that anything is collapsed behind it.

Breaks WCAG 2.1.1 Keyboard (Level A), since the disclosure has no keyboard operation at all, and 4.1.2 Name, Role, Value (Level A), since it exposes neither a button role nor its expanded state.

Fix: render that header as a real `<button type="button">` with `aria-expanded` and the native `disabled` attribute, and toggle on `click`, which fires for both pointer and keyboard activation. This branch contains no `<slot />` and no interactive descendants, so a button is valid here. `block text-start` keeps the previous box and alignment behaviour, since a `<button>` otherwise defaults to `inline-block` and centred text. `disabled:cursor-default` replaces the old `{disabled ? '' : 'cursor-pointer'}` ternary, which became a no-op once this was a button, because `src/tailwind.css` applies `cursor-pointer` to every `button`. Verified in a browser that display, text alignment and rendered height match the previous `<div>`, and that a disabled header no longer shows a pointer cursor.

Switching from `pointerup` to `click` also means the header no longer toggles on right click, or when a drag starts outside it and ends inside.

The `{:else}` branch is deliberately left alone. Its `<slot />` receives buttons from `Sidebar/Section.svelte`, `common/Folder.svelte` and `Sidebar/RecursiveFolder.svelte`, so it cannot legally become a `<button>` and needs a different fix.

Severity: Critical. Model reasoning output is entirely unreachable without a mouse.

### 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.
2026-07-26 17:37:34 -04:00
Classic298
65473b6ffa
fix: stop Enter on Cancel from confirming ConfirmDialog (WCAG 3.2.2) (#27491)
On latest `dev`, `ConfirmDialog` registers `handleKeyDown` on `window` and treats **every** Enter keypress as Confirm, calling `event.preventDefault()` first so the focused control never gets its native activation.

The dialog also activates a focus trap with no `initialFocus`, so focus-trap falls back to the first tabbable node, which is the **Cancel** button. So the dialog opens with Cancel focused, and pressing Enter runs Confirm.

This is the confirm surface for Delete chat, Delete folder, Delete model, Delete knowledge base and ~40 other call sites. A keyboard user who tabs to Cancel and presses Enter deletes the thing they were trying to keep. Screen reader users are hit hardest, since they cannot see which button they are on and the control that means "back out safely" performs the irreversible action instead.

Two related paths have the same cause: Enter in the `input=true` textarea submits instead of inserting a newline, and a markdown link inside `message` (reachable via `eventConfirmationMessage` from tool `__event_call__` payloads, and via `web_search_confirmation_content`) becomes the first tabbable node, so Enter on that link confirms instead of following it.

Breaks WCAG 3.2.2 On Input (Level A): changing the focused control changes what the Enter key does, and activating a control performs a different action than the one it is labelled with. Also 2.1.1 Keyboard (Level A), since Cancel has no working keyboard activation.

Fix: let the focused control act on Enter itself, and only fall back to Confirm otherwise. Uses the same `target instanceof Element && target.closest(...)` guard already used in `Functions.svelte`, `Knowledge.svelte`, `Models.svelte`, `Prompts.svelte`, `Skills.svelte` and `Tools.svelte`. `select` is deliberately not in the list, because a native `select` does not act on Enter and excluding it would silently break confirm for the `inputType === 'select'` variant. Two stray `console.log` calls in the same function are removed.

Behaviour after this change: Enter on Cancel cancels, Enter on Confirm confirms, Enter in the textarea inserts a newline, Enter on a link follows it, and Enter anywhere else still confirms as before.

Severity: Critical. Silent, unrecoverable data loss triggered by the most ordinary keyboard interaction there is.

### 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.
2026-07-26 17:37:21 -04:00
Classic298
2725ae6d6c
fix: expose Checkbox as a checkbox with a name and state (WCAG 4.1.2) (#27494)
On latest `dev`, `common/Checkbox.svelte` renders a `<button type="button">` containing only `aria-hidden="true"` SVGs. It has no `role`, no `aria-checked` and no accessible name, and the component has no `$$restProps` spread, so a caller cannot supply a name either.

Assistive technology announces every one of these as an unnamed "button". A screen reader user cannot tell that the control is a checkbox, cannot tell whether it is on or off, and cannot tell what it toggles. The visible label is always an unassociated sibling element, for example `Capabilities.svelte` puts it in a preceding `<div>` with no `id`, and `Groups/Users.svelte` puts it in a different table cell from the checkbox.

Breaks WCAG 4.1.2 Name, Role, Value (Level A) on all three counts at once.

Fix: expose `role="checkbox"` and `aria-checked` on the control, add an `ariaLabel` prop, and pass the label text that is already in scope at each call site. `aria-checked` mirrors the component's existing icon logic exactly, so the indeterminate dash reports `mixed` rather than `false`. The `ariaLabel={ariaLabel || undefined}` shape matches the sibling `common/Switch.svelte`. Every label expression is the same one that renders the visible text next to the checkbox, so the accessible name always matches what is on screen.

Three call sites are deliberately left out of this PR, because they nest `Checkbox` inside another `<button>`, which is invalid HTML and independently broken:

- `workspace/Knowledge/KnowledgeBase.svelte` — the Checkbox's `on:change` sets `includeContent = true` and then the same click bubbles to the outer button, which flips it back with `includeContent = !includeContent`. Clicking the checkbox square is a no-op today, only the text label works. Giving it a confident name would advertise a control that does nothing.
- `workspace/common/MemberSelector.svelte` (two instances) — the inner Checkbox has no `on:change` at all and only works because its click bubbles to the row button. Naming it would create two focusable controls per row with the same name.

Both need the nesting resolved first, so that the row button carries the checkbox semantics. That is a behavioural fix and belongs in its own PR.

Severity: Serious. Affects model capabilities, default features, builtin tools, tool/filter/skill/action selectors and group membership.

### 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.
2026-07-26 17:37:06 -04:00
Classic298
a7a2c7605b
fix: give the rich text editor an accessible name (WCAG 4.1.2, 3.3.2) (#27503)
On latest `dev`, `RichTextInput` passes only `attributes: { id }` to tiptap, so the rendered contenteditable has an implicit `textbox` role and **no accessible name at all**.

The only label is the tiptap placeholder, which renders as CSS generated content in `src/app.css` via `content: attr(data-placeholder)`. Generated content never becomes an element's accessible name, so assistive technology announces the field as "edit text, blank".

This is the chat composer, the channel and thread composers, and the note editor, so it is the most used control in the product.

Breaks WCAG 4.1.2 Name, Role, Value (Level A), and 3.3.2 Labels or Instructions (Level A), since the only instruction is invisible to assistive technology.

Fix: expose the placeholder as `aria-label` on the editor element.

`attributes` is passed as a **function** rather than an object literal. The object form is evaluated once when the `Editor` is constructed and never rebuilt, but `placeholder` is deliberately runtime mutable: `channel/MessageInput.svelte` and `channel/Thread.svelte` swap it between "You do not have permission to send messages in this thread." and "Reply to thread..." once `channel` resolves, and it also changes when the interface language changes. With the object form the field would have been permanently named with whatever string happened to be set at mount, which for a channel the user *can* write to is the no-permission message. That would be worse than no name at all. ProseMirror supports the function form and re-evaluates it on every state update, and the component's existing `setPlaceholder` already dispatches an empty transaction, so the label now tracks the visible placeholder. It binds to `_placeholder`, the same value that feeds the visible text, so the two cannot diverge.

`aria-multiline` is deliberately not set. It is only valid on an explicit `textbox`/`searchbox` role, and adding `role="textbox"` would flatten the editor's inner structure so headings, lists and links inside rich text stop being exposed.

Severity: Critical. The application's primary input announces as an unnamed edit field.

### 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.
2026-07-26 17:36:36 -04:00
Classic298
7effaa05d1
fix: name the switches in Admin Settings rows (WCAG 4.1.2, 1.3.1) (#27510)
`common/Switch.svelte` already accepts `id`, `ariaLabel` and `ariaLabelledbyId`, but **not one of the 148 `<Switch>` instances under `src/lib/components/admin/` passes any of them**.

`admin/Settings/AdminSettingRow.svelte` renders the row label as a plain `<div>` and the control in a **sibling** slot, so there is nothing tying them together. bits-ui renders the switch as a `<button role="switch">` whose subtree is a text free thumb, so it has no accessible name from any source.

A screen reader user working through Admin Settings hears a long run of "switch, on" and "switch, off" with no indication of what any of them controls.

Breaks WCAG 4.1.2 Name, Role, Value (Level A) and 1.3.1 Info and Relationships (Level A).

Fix: `AdminSettingRow` mints a per instance id, puts it on the label element, and hands it to the default slot, so each row's switch can point at the label that is already rendered next to it. This is the pattern `chat/Settings/Interface.svelte` already uses by hand in 45 places, hoisted into the shared row component so call sites stop hand authoring ids.

`aria-labelledby` rather than a wrapping `<label>`: per HTML-AAM a `<button>` takes its name from `aria-labelledby`, then `aria-label`, then its own subtree, never from an associated `<label>`. `chat/Settings/Subagents.svelte` already wraps two switches in a `<label>` and they are still unnamed, which is the same trap. Using the existing label element also guarantees the accessible name is byte identical to the visible text, which keeps voice control working.

The `description` paragraph deliberately sits outside the referenced element, so verbose help text is not pulled into the name.

Scope: this covers the **72** switches that live inside an `AdminSettingRow`, which is every switch that flows through the shared row component. There are no rows containing more than one switch, so nothing is silently skipped.

The remaining 76 admin switches are not in this component and are not touched. 64 of them are in `admin/Users/Groups/Permissions.svelte`, which hand rolls its own row markup, and the other 12 are per entity toggles in lists and dropdowns where the label is a dynamic row name. `Permissions.svelte` is the worst remaining case, 64 toggles with near identical adjacent labels, and it needs either its own labelling pass or a conversion to `AdminSettingRow` that changes its visual styling. Either way that is not an accessibility only diff and belongs in its own PR.

All 12 touched files compile with the Svelte compiler with no new warnings and are Prettier clean.

Severity: Serious. Admin Settings is unusable with a screen reader.

### 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.
2026-07-26 17:35:34 -04:00
Classic298
94b1b7e6b6
fix: close Playwright pages and browser on failure in SafePlaywrightURLLoader (#27526)
`SafePlaywrightURLLoader` opened a new Playwright page for every URL and never closed it, and it only closed the browser after the URL loop finished normally. Pages therefore piled up for the whole batch, and any early exit (a raised error with `continue_on_failure=False`, or the caller abandoning/cancelling the generator mid-search) skipped `browser.close()` entirely.

With `PLAYWRIGHT_WS_URL` pointing at a remote Playwright server this leaks sessions on that server: navigation and route timeouts on slow or bot-protected pages leave pages and browser connections open until the server is restarted, which degrades every later web search.

Both `lazy_load()` and `alazy_load()` now scope the page to the per-URL loop body and the browser to the whole loop using their context managers, so each page is closed as soon as its URL is done and the browser is closed on success, on failure, and on cancellation. Closing a page also disposes the context implicitly created by `new_page()`. Exception handling is unchanged: a close error raised while `continue_on_failure` is set is still caught, logged, and the loop continues.

Fixes #25880
2026-07-26 17:35:13 -04:00
Classic298
225e238856
fix: only route PDFs and images to the PaddleOCR-VL loader (#27529)
When `RAG_DOCUMENT_LOADER_ENGINE` is set to `paddleocr_vl`, the dispatch branch in `Loader._get_loader` checked only the engine name and a non-empty token, so every uploaded file was handed to the PaddleOCR-VL loader regardless of its type. Text based uploads such as `.md`, `.txt` and `.csv` were base64 encoded and posted to the `/layout-parsing` endpoint tagged as PDFs, and the API rejected them with `422 Unprocessable Entity` ("PDFium: Data format error"), so those files never indexed at all.

The loader already knows which extensions it can handle: it tags images with `fileType: 1` and treats everything else as a PDF. That list is now a module level constant, and the dispatch branch gates on `['pdf'] + images`, the same way `mistral_ocr`, `datalab_marker`, `document_intelligence` and `mineru` already limit themselves. Deriving the gate from the loader's own list keeps the two in sync, so a file can never be admitted by the gate and then mislabelled as a PDF on the wire. Everything outside that set falls through to the default loader chain, so `.md` and `.txt` load as text, `.csv` through `CSVLoader`, `.docx` through `Docx2txtLoader`, and so on.

The branch also never checked `PADDLEOCR_VL_BASE_URL`. With the URL cleared, `PaddleOCRVLLoader` raised `ValueError` from its constructor and the upload failed outright instead of falling back. Both settings are now required for the branch to be taken, matching how the other engines guard their own configuration.

Fixes #24988
Fixes #26759
2026-07-26 17:34:59 -04:00
G30
4ac22b89fd
fix: don't wipe sidebar folder chat list when a refresh overlaps an in-flight fetch (#27535) 2026-07-26 17:28:15 -04:00
Classic298
f517cc7172
fix: apply the verified-user role gate to WebSocket authentication (#27537)
The Socket.IO handshake and the terminal WebSocket route each reimplement JWT authentication instead of going through the HTTP dependency chain. Both verified that the token decoded, that it had not been revoked, and that the user row existed, but neither applied the role check that `get_verified_user` enforces on every HTTP route, so any role outside `user` and `admin` was accepted.

That splits authorization across two planes. Deactivating an account by setting its role to `pending` takes effect immediately over HTTP, which returns 401, while the same JWT still opens a WebSocket. Changing a role disconnects the account's live sockets but does not revoke its token, so the client simply reconnects and gets a fresh session. Until the token expires, four weeks by default, a deactivated account keeps its channel rooms and can still read and write any note it holds an access grant on through the collaborative document handlers.

Resolve the user once, in `get_verified_user_by_token`, and route both WebSocket entry points through it. The role set moves into `VERIFIED_USER_ROLES` so the HTTP and WebSocket gates cannot drift apart, which is the underlying cause rather than either call site on its own. This also replaces five copies of the decode, revocation check and user lookup sequence.

`user-join` now resolves the user instead of reusing the identity cached in `SESSION_POOL`, which costs one extra query per handshake. Gating on the cached role would make the authorization decision depend on every future role-mutation path remembering to tear down the session pool, and that is precisely the invariant that failed here.
2026-07-26 17:27:54 -04:00
G30
29499cb4ba
perf: dedupe folder refetches and chat-list sweeps on sidebar folder selection (#27540) 2026-07-26 17:27:33 -04:00
G30
4d576c1aa2
chore: remove no-op stopPropagation statement from folder title click handler (#27542) 2026-07-26 17:27:12 -04:00
G30
65209b0235
fix: enforce a single open chat hover preview across sidebar chat items (#27549) 2026-07-26 17:26:49 -04:00
G30
ef197de0d7
fix: open settings deep links on client-side navigation and open Add Terminal settings directly (#27552) 2026-07-26 17:26:34 -04:00
Classic298
d1aa812d80
i18n: complete de-DE translations (#27448)
Some checks failed
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
* i18n: complete de-DE translations

Fill in all 544 untranslated (empty) strings in the German locale and add
the two keys that were missing entirely ("Response Auto-Scroll" and
"Follow assistant responses as they are generated.").

Wording follows the conventions already used in the file: formal "Sie"
address for user-facing sentences, infinitive phrasing for labels and
buttons, third-person descriptive phrasing for setting descriptions, and
the established terminology (Kontextverdichtung, Erinnerungen,
Wissensspeicher, Werkzeuge, Chunk, Embedding, Skills, Pipelines).
Ambiguous strings were resolved against their usage in the Svelte
components, e.g. "at"/"Through" (schedule and heatmap tooltips),
"Runs"/"runs" (automation runs vs. tool invocations), "Current"
(active chat) and "Selected" (model filter).

* i18n: fix de-DE wording and two pre-existing plural bugs

Review pass over the German locale:

- "Claim" and "DN" are masculine: "Claim, der ..." instead of "Claim,
  das ...", "Passwort für den Bind-DN", "Base DN, der ...".
- "hinzufügen" governs the dative, matching the existing string
  "... fügen Sie sie zuerst dem Arbeitsbereich "Wissen" hinzu."
- "Beschränkt oder schließt Domains ... aus" was a zeugma; the separable
  prefix only belongs to "schließt".
- Sub-agent settings render as label + input + unit suffix on one line,
  so the label and suffix no longer repeat each other.
- The built-in tool descriptions are infinitive, so the notification one
  is too.
- Align wording with terms already used in the file: Assistentennachrichten,
  Benutzernachrichten, Vervollständigungen, Tool-Server, Wissensspeicher,
  lexikalisch. Normalize the few German typographic quotes to the ASCII
  quotes used everywhere else.
- The username setting claimed the chat shows "Sie", but "You" is
  translated as "Du".

Also fixes bugs that predate these translations: "Starting in {{count}}
minutes" had the raw "minutes_one"/"minutes_other" suffix in its value,
and the singular and plural of "Ran {{COUNT}} analysis/analyses" were
swapped.
2026-07-24 17:59:54 -05:00
Timothy Jaeryang Baek
3110050aba refac 2026-07-24 12:49:17 -04:00
joaoback
9d293935a9
i18n: add pt-BR translations for newly added UI items and consistency pass (#27430)
New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.
2026-07-24 12:37:39 -04:00
Classic298
e32c6743ba
docs: align security policy framing with project ownership (#27431)
The security policy described Open WebUI as "a small volunteer team" and "a volunteer- and community-driven project", and explained response times as a shortage of capacity. Read by enterprise evaluators, security researchers and third parties trying to impose disclosure timelines, that wording makes the project look informal, under-resourced and externally steerable, which is the opposite of the position the policy is meant to hold.

Open WebUI is led and maintained by a small core team with clear ownership of the security process. This updates the wording to say that, and reframes response times as prioritisation across the project rather than a capacity shortfall. No rule, scope, commitment or timeline changes: the reporting channel, the disclosure schedule, the credit rules and the expected timeframe all stay exactly as they were.

Also removes the implicit first-come-first-served promise in the follow-up paragraph, which contradicted the severity-based prioritisation stated two paragraphs later, and bumps the last-updated date.
2026-07-24 12:37:23 -04:00
G30
06d2189b26
fix(chat): keep sidebar chat selection in sync with the active chat (#26977)
When navigating from a chat to a non-chat route (e.g. the admin panel),
the previously-viewed chat stayed selected in the sidebar and
deleting/archiving it wrongly redirected back to the new-chat page.
Cloning a chat also left the source chat highlighted alongside the new
clone, so two chats appeared selected at once.

Two independent sources kept the stale selection:

- The chatId store was never cleared when the Chat component unmounted,
  so $chatId still pointed at the last-viewed chat (this drove the
  delete/archive redirect). Clear chatId/chatTitle in Chat's onDestroy.
- The sidebar's optimistic selectedChatId highlight, set on click, was
  only cleared on window blur (hence it appeared to fix itself after a
  tab switch) and never followed programmatic navigation. Bind it to the
  chatId store so it tracks the active chat for leave, delete and clone.
2026-07-24 01:45:37 -05:00
Timothy Jaeryang Baek
300302d432 refac 2026-07-24 02:36:10 -04:00
Classic298
fe4b319428
fix: deny chained access to unregistered base models for non-admins (#26905)
A workspace model shared publicly could be used by any user even when its
base model was private. Unregistered base models (no row in the model
table) are admin-only for direct use — get_filtered_models hides them from
non-admins and check_model_access rejects them — but has_base_model_access
treated a missing row as "no ACL" and allowed the chained request through.

has_base_model_access now takes the caller's role and only allows an
unregistered base model hop for admins, so a shared preset can no longer
reach a base model the caller could not use directly. Registered base
models keep their existing grant-based enforcement.


Claude-Session: https://claude.ai/code/session_018toPfJW1hMXAhokGaL43Ep

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-24 01:32:32 -05:00
Classic298
f7e7f32102
fix: honor Admin UI web loader settings in get_web_loader (#26749)
Since the config refactor, get_web_loader dispatched on the WEB_LOADER_ENGINE module constant, which is read from the environment once at import time. The engine selected in the Admin UI is stored under web.loader.engine in the config table but was never consulted, so UI-configured loader engines (external, playwright, firecrawl, tavily, microsoft_web_iq) were silently ignored and the built-in SafeWebBaseLoader always fetched pages directly. The same applied to the per-engine settings such as the external web loader URL and API key. This breaks egress-restricted deployments that rely on an external web loader: pages are fetched directly from the container and fail with errors like "Network is unreachable" even though an external loader is configured.

Pass the DB-backed loader settings into get_web_loader from both call sites, web search in process_web_search and web fetch via get_loader, and resolve every engine setting from them, keeping the module-level env constants as the fallback for keys that were never saved. Also initialise WebLoaderClass so an unknown engine raises the intended ValueError instead of an UnboundLocalError.

Fixes #26747
2026-07-24 01:30:47 -05:00
Classic298
18719fef9c
fix: malformed WEB_FETCH_FILTER_LIST entry blocking all web fetches (#26910)
Docker compose list-form environment syntax passes quotes through verbatim, so WEB_FETCH_FILTER_LIST="" reaches the backend as two literal quote characters rather than an empty string. Config parsing turned that into the filter entry '""', which has no "!" prefix and therefore landed in the allow list. A non-empty allow list requires every host to match one of its entries, and a quotes-only pattern can never match a hostname, so every fetch_url and web loader request was rejected with "URL blocked by filter list" and surfaced to the user as "The URL you provided is invalid".

get_allow_block_lists now strips surrounding quote characters from each entry and drops entries that are empty after normalisation. Quoted but otherwise valid entries such as "example.com" or !"example.com" now behave as their unquoted forms, and garbage entries no longer convert the default blocklist into a match-nothing allowlist that blocks everything.

Fixes #26908
2026-07-24 01:27:24 -05:00
Timothy Jaeryang Baek
b9d72741bb refac 2026-07-24 02:19:57 -04:00
Classic298
f89b501985
fix: access-check note entries in get_accessible_folder_files (#26739)
get_accessible_folder_files is the server-side filter that reduces a folder's attached-knowledge list (and, once #26723 lands, a direct model's) to the entries the caller may read, before that list is handed to the builtin knowledge tools as `__model_knowledge__`. It validated `file` and `collection` entries but passed `note` entries through unchecked (they fell into the `else` keep-as-is branch), even though notes are a first-class attached-knowledge type that flows through this list.

No current caller is exploitable, because every note consumer (`query_knowledge_files`, `view_note`, and the legacy retrieval path) independently re-checks note access before returning content. But relying on each consumer to remember that check is exactly the fragility this helper exists to remove, and the same `_has_read_access_to_file` membership short-circuit that makes an unvalidated `file` entry dangerous would turn any future note path that trusts list membership into an IDOR. Validate notes here so the filter enforces its own contract instead of leaning on downstream re-checks.

A note entry is now kept only when the caller owns it or holds a read grant. Notes are private by default and carry no self-grant, so ownership is checked explicitly alongside the grant lookup. Admins still bypass all checks and genuinely unknown types are still kept as-is.

Related: #26723
2026-07-24 01:18:43 -05:00
Timothy Jaeryang Baek
b4d13793a3 refac 2026-07-24 02:14:56 -04:00
Timothy Jaeryang Baek
d7513e4ce8 refac 2026-07-24 02:05:31 -04:00
Classic298
585b704597
fix: clear token cookie on 401 auth redirect to stop login flash loop (#26751)
Since v0.10.0 a global fetch interceptor redirects to /auth and clears
localStorage.token whenever an authenticated backend request returns 401.
The OAuth callback cookie ("token", set with httponly=False so the
frontend can read it) is left behind. The auth page's oauthCallbackHandler
then immediately signs the user back in from that cookie and navigates to
"/", where the next 401 triggers the redirect again. The result is an
endless /auth and / ping-pong that renders as uncontrollable screen
flashing, and as a PWA stuck on a flashing splash screen when SvelteKit's
update check turns each navigation into a full page reload. Affected
users could only recover by clearing cookies, which matches the reports.

Clear the token cookie together with localStorage when redirecting, so
/auth stays on the login form and the user can sign in again normally.

Fixes #26731
2026-07-24 00:59:43 -05:00
Classic298
e398ba3506
fix: don't seed non-persistent config keys (oauth.* with flag off) (#26928)
seed_defaults inserted a row for every key in DEFAULT_CONFIG regardless
of whether the DB is authoritative for it. With ENABLE_OAUTH_PERSISTENT_CONFIG
off, the oauth.* keys were seeded from the then-current (often empty) env
values. Enabling the flag later made those stale rows override live env vars
(e.g. ENABLE_OAUTH_SIGNUP=true stopped taking effect) and further env changes
were never picked up.

Skip keys where persistent_enabled_for() is false, matching the masking the
read paths (get/get_many/get_namespace/get_all) already apply.


Claude-Session: https://claude.ai/code/session_01Vr2RCYUTXCtgtV4WMUCK86

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 00:58:30 -05:00
Timothy Jaeryang Baek
28bdcb063b refac 2026-07-24 01:54:36 -04:00
Timothy Jaeryang Baek
793a43d9c4 refac 2026-07-24 01:48:56 -04:00
Timothy Jaeryang Baek
bd5d7b2e87 refac 2026-07-24 01:47:11 -04:00
Timothy Jaeryang Baek
212eec408c refac 2026-07-24 01:44:30 -04:00
Classic298
b6acd3cc45
fix: web speech STT repeating previous transcriptions and inserting text on cancel (#26793)
The VoiceRecording component stays mounted (hidden) between recordings, and the web speech engine accumulated every session's transcript into the never-reset transcription variable. Each new recording therefore confirmed all previous utterances again, so the inserted text repeated once per session and previously deleted text reappeared in the input.

Additionally, cancelling a recording (X button, Escape or a recognition error) called stopRecording(), which stops the SpeechRecognition instance and fires its onend handler, which unconditionally confirms the transcription. Cancelled recordings therefore still inserted the accumulated transcript.

Reset the transcription at the start of each web speech session and detach the onend handler on cancel so cancelled recordings no longer confirm.

Fixes #26784
2026-07-24 00:40:02 -05:00
Timothy Jaeryang Baek
ce831f7b85 refac 2026-07-24 01:25:52 -04:00
Timothy Jaeryang Baek
7b12fd677f refac 2026-07-24 01:23:39 -04:00
Timothy Jaeryang Baek
1f5b0d816f refac 2026-07-24 01:19:28 -04:00
Timothy Jaeryang Baek
33cf3fbb7f refac 2026-07-24 01:13:04 -04:00
Timothy Jaeryang Baek
ff11ff5a3e refac 2026-07-24 01:11:23 -04:00
Timothy Jaeryang Baek
cea991260f refac 2026-07-24 01:09:50 -04:00
G30
e212e3c7f4
fix(ui): resolve workspace model ID to base model on delete from model selector (#26819) 2026-07-24 00:03:31 -05:00
Classic298
a35b37adcd
fix: keep chats shared with an admin readable when ENABLE_ADMIN_CHAT_ACCESS is off (#27127)
get_chat_by_id sent admins down a branch that returned the chat only when
ENABLE_ADMIN_CHAT_ACCESS was on, or the chat was internal, and never fell
through to the access-grant and shared-folder checks. With the setting off,
an admin was therefore denied a chat that had been deliberately shared with
them, either directly or through a shared folder, while any non-admin holding
the same grant could open it. The admin role removed access the user had been
given rather than only closing the admin-only path.

Try the admin path first, then let everyone fall through to the grant and
folder checks. ENABLE_ADMIN_CHAT_ACCESS=false still closes the admin-only
route to other users' chats, and internal chats stay reachable.
2026-07-24 00:02:12 -05:00
Classic298
9b635d8f3d
fix: calendar attendee RSVP correctness — server-derived status and hide declined invites (#27007)
* fix: let only the attendee set their own calendar RSVP status

set_attendees took each attendee's status from the caller-supplied value, so an
event organiser could set another user's RSVP (for example to 'accepted') on
create or update. RSVP is meant to be self-service: the /events/{id}/rsvp
endpoint already scopes status changes to the calling user.

Derive attendee status server-side instead of from the request. An existing
attendee keeps the status they set via RSVP and a newly added attendee starts
'pending'; any caller-supplied status is ignored. Event edits no longer reset
attendees' existing responses.

Co-authored-by: legobattman <302282032+legobattman@users.noreply.github.com>

* fix: hide declined calendar invites from the attendee view

`get_events_by_range` surfaced every event where the user is an attendee regardless of their RSVP status, so declining an invite left it in the calendar with no way to remove it. Exclude `declined` attendee rows from the attendee branch, so a decline now removes the event from the user's own view while pending, accepted and tentative invitations still surface.

Co-Authored-By: legobattman <302282032+legobattman@users.noreply.github.com>

---------

Co-authored-by: legobattman <302282032+legobattman@users.noreply.github.com>
2026-07-24 00:01:16 -05:00
Classic298
48f78ca58d
fix: prevent startup crash when function/tool has null user_id (#26850)
The Function and Tool database columns declare user_id as a nullable
String column, but their Pydantic read-models required a non-null
string. A record with user_id NULL therefore raised a
pydantic ValidationError inside get_functions()/get_tools(), which run
during install_tool_and_function_dependencies() at app startup —
crashing the whole application and blocking all chat completions.

Make user_id Optional in the read/response models so such records
validate gracefully (user is already rendered as None downstream when
the id has no matching user) instead of taking down startup.


Claude-Session: https://claude.ai/code/session_01Y4RRUNq7ZUFkRWbWPkDw3m

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-24 00:00:37 -05:00
Timothy Jaeryang Baek
799748b886 refac 2026-07-24 01:00:00 -04:00
G30
1dc4fd3e9d
fix(ui): hide redundant edit pencil on mobile in admin Models list (#27178)
On narrow viewports the admin Models list rows previously collapsed into
unusable vertical stacks; the Models page redesign on dev has since
absorbed the truncation fixes this branch carried (min-w-0 chain, real
truncate on the name, shrink-0 action group, inline access label).

The one remaining gap: the per-row edit pencil duplicates the row tap
(both open the model editor) while costing scarce horizontal space on
mobile. Hide it below the sm breakpoint; it remains on sm+ screens.
2026-07-23 23:58:07 -05:00
Viktor1
6ace7e5b3a
i18n: add Slovenian language support (#26875)
* i18n: add Slovenian language entry

* i18n: add Slovenian translation
2026-07-23 23:57:22 -05:00
Timothy Jaeryang Baek
5f6a9d16b2 refac 2026-07-24 00:56:43 -04:00
Timothy Jaeryang Baek
d637c2128c refac 2026-07-24 00:56:29 -04:00
Timothy Jaeryang Baek
239cb74007 refac 2026-07-24 00:56:25 -04:00
Timothy Jaeryang Baek
b35e2d265a refac 2026-07-24 00:54:03 -04:00
Timothy Jaeryang Baek
8cbb7f765c refac 2026-07-24 00:47:12 -04:00
Timothy Jaeryang Baek
de939a6562 refac 2026-07-24 00:44:06 -04:00
Timothy Jaeryang Baek
1e88367cc8 refac 2026-07-24 00:43:31 -04:00
Timothy Jaeryang Baek
4e869011cd refac 2026-07-24 00:42:11 -04:00
Timothy Jaeryang Baek
bef8ae4b2f refac 2026-07-24 00:40:42 -04:00
Timothy Jaeryang Baek
381ababeba refac 2026-07-24 00:40:21 -04:00
Classic298
ec18ce2ca0
fix: persist access_grants.allow_groups in default permissions (#27124)
AccessGrantsPermissions only declared allow_users, so allow_groups was
missing from the model backing the default user permissions endpoints.
Pydantic ignores undeclared fields, so POST /users/default/permissions
dropped allow_groups before model_dump(), it never reached the persisted
user.permissions config, and fill_missing_permissions restored it to the
default on the next read. Turning "Allow Sharing With Groups" off in
Admin Settings silently reverted to on, while the same toggle worked when
set per group, since group permissions are stored as a plain dict.

GET /users/default/permissions and /users/default/permissions/defaults
dropped it from their responses for the same reason.

Declare allow_groups on the model so it round-trips, matching the
access_grants block in DEFAULT_USER_PERMISSIONS. It defaults to True, so
existing payloads that omit it are unaffected.
2026-07-23 23:53:59 -04:00
Jan Behet
92b4361e7b
fix: correct typos in German translation (#26778)
This PR fixes two small errors in the German translation file:

- "kaann" -> "kann"
- "AAlle" -> "Alle"

No functional changes, only UI strings.

Co-authored-by: Tim Baek <tim@openwebui.com>
2026-07-23 23:52:35 -04:00
Classic298
c609ec4115
fix: require message authorship for standard-channel message edit and delete (#27197)
The channel message update and delete handlers enforced authorship only on group and dm channels. On standard channels the else branch accepted any caller holding write access on the channel, so a member who could post could also edit or delete messages authored by other members. Because the update form binds content, data and meta, and the model layer never touches message.user_id, an edited message kept the original author's attribution, so another member's message could be rewritten under their name.

Write access on a channel is the capability to post, not a moderation capability, and the frontend gates the edit and delete controls on authorship (message.user_id === user.id, or admin) for every channel type. The group and dm branch already encodes this with an explicit authorship check. Apply the same rule to the standard branch: the caller must hold write access on the channel and be the message author, unless they are an admin. Pinning is unchanged, since it is exposed to every member by design.
2026-07-23 23:40:03 -04:00
Classic298
c895490aa8
fix: blank chat messages on Safari caused by content-visibility virtualization (#26805)
Since v0.10.0 chat messages are virtualized with content-visibility: auto to skip rendering of off-screen messages. Safari's implementation of content-visibility has known paint bugs (WebKit bugs 277573, 281570 and 283846) that can leave the contents of a message unpainted even when it is on screen. On iOS this makes assistant responses render as empty, both in Safari and as a PWA, while the same chats render fine in Chromium and Firefox. This matches the regression window reported in #26712, which appeared when upgrading from 0.9.6 to 0.10.2.

Detect Safari (including all iOS browsers, which use WebKit) with the same user agent check already used in MessageInput and ShareChatModal, and skip the virtualization class there. Safari falls back to rendering all messages like before v0.10.0, while other engines keep the optimization. Verified with a spoofed Safari user agent that messages render without the virtualization class and with content-visibility resolving to visible, while Chromium keeps content-visibility: auto.

Fixes #26712
2026-07-23 23:38:18 -04:00
Classic298
b940cd529b
fix: matplotlib SyntaxError in sandboxed Pyodide code execution (#26800)
The sandboxed Pyodide host (used when ENABLE_PYODIDE_FILE_PERSISTENCE is
disabled, the default) embeds its script in a String.raw template. The
matplotlib show() override was written with '\\t' escapes as if in a normal
string context, but String.raw preserves them verbatim, so the iframe's
script parser turns them into literal backslash-t characters in the
generated Python source. Pyodide then fails to compile any code that
triggers the matplotlib patch with "SyntaxError: unexpected character
after line continuation character", which is why matplotlib only worked
with the file persistence worker path enabled.

Use single '\t' escapes instead: String.raw keeps them as-is in the
script text and the sandbox's JS parser produces real tab indentation,
matching the working implementation in pyodide.worker.ts.

Fixes #26660
2026-07-23 23:34:26 -04:00
Classic298
0f8d12201c
fix: empty assistant message content in action function body (#26798)
Assistant responses are now stored as structured output items on message.output, with message.content left empty. The action payload built in chatActionHandler still sent message.content only, so action functions received assistant messages with an empty content property. Derive the content from the structured output via getOutputText, falling back to message.content, matching how the rest of Chat.svelte resolves assistant text.

Fixes #26672
2026-07-23 23:34:14 -04:00
Classic298
7ef0530b24
fix: handle urllib3-future 4-element socket options in SSRF-safe web loader (#26796)
_ssrf_safe_new_conn unpacks each entry of self.socket_options straight into socket.setsockopt(), which accepts exactly 3 positional arguments. urllib3-future, a drop-in fork that shadows the urllib3 package whenever it is installed (for example as a dependency of niquests pulled in through a tool or function's requirements), declares its default socket options with a per-protocol 4th element: [(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1, "tcp")]. Its own _set_socket_options() strips that element before calling setsockopt(), but our override does not, so with urllib3-future present every synchronous web fetch (fetch_url, web search loading) fails on connect with "TypeError: setsockopt() takes exactly 3 arguments (4 given)" and returns empty content.

Mirror urllib3-future's handling in the override: for 4-element options whose last element is a protocol string, apply "tcp" options truncated to the first 3 elements and skip "udp" options (all sockets created here are SOCK_STREAM). Plain 3-element options, and any other shapes stock urllib3 would accept, are passed through unchanged, so behavior with stock urllib3 (which only ever uses 3-element tuples) is identical.

Verified locally: with urllib3-future installed the loader previously raised the TypeError on every URL and now fetches successfully; with stock urllib3 2.3.0 and 2.7.0 fetches behave the same before and after.

Note: #26015 reported this same crash but attributed it to stock urllib3 2.x, which only uses 3-element tuples; the 4-element form comes from urllib3-future shadowing urllib3.

Fixes #26791
2026-07-23 23:33:55 -04:00
Classic298
504e724fde
fix: detect bare pipe alternation as regex in grep_knowledge_files (#26795)
is_regex_pattern only recognized the BRE-escaped form \| and not a bare |,
so a pattern like "Jornak|Silverlake|Orissa" was treated as one literal
string (including the pipe characters) and silently returned no matches.
This contradicted the tool docstring, which explicitly advertises
"error|warn" as an auto-detected regex example, and misled models into
concluding the searched terms were absent from the file.

Checking for a bare | also covers the escaped form, since \| contains |,
and normalize_regex already converts escaped pipes before compilation.
Literal patterns without regex metacharacters are unaffected.

Fixes #26781
2026-07-23 23:33:35 -04:00
Timothy Jaeryang Baek
315a6b5995 refac 2026-07-23 23:16:28 -04:00
G30
866e8582d1
ci: auto-label bug reports created outside the issue form (#27256)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 23:07:25 -04:00
Timothy Jaeryang Baek
c3c70d4a7c refac 2026-07-23 22:56:02 -04:00
Timothy Jaeryang Baek
2ef6c76f51 refac 2026-07-23 22:52:23 -04:00
Timothy Jaeryang Baek
43e7eefa95 refac 2026-07-23 22:45:54 -04:00
Timothy Jaeryang Baek
40320c1136 refac 2026-07-23 22:44:04 -04:00
Timothy Jaeryang Baek
75a8a0046b refac 2026-07-23 22:36:03 -04:00
Timothy Jaeryang Baek
8d2fee5d45 refac 2026-07-23 22:35:54 -04:00
Jiwon Lee
9d020edf0f
fix: make native date/time picker icons visible in dark mode (#27275) 2026-07-23 22:23:46 -04:00
Timothy Jaeryang Baek
021c4c7a2e refac 2026-07-23 22:20:50 -04:00
G30
3132f11e55
feat: add setting to toggle keyboard shortcuts on/off (#27300) 2026-07-23 22:14:56 -04:00
G30
4f823774ab
fix: migrate deprecated authlib.jose import to joserfc in oauth utils (#27310) 2026-07-23 21:54:59 -04:00
G30
4fca375ad4
chore: remove unused ArchivedChatsModal component (#27308) 2026-07-23 21:54:45 -04:00
Classic298
acf586c006
fix: resolve the web loader parser per URL instead of locking in the first one (#27367)
SafeWebBaseLoader._unpack_fetch_results assigned the resolved parser to the parser parameter itself, so the None check only ran for the first URL. In a mixed batch every later document was parsed with whatever the first URL happened to select: an .xml feed first meant all following HTML pages went through the xml parser (broken text extraction), and an HTML page first meant .xml URLs were parsed as HTML. Web search regularly fetches mixed batches, so this silently degraded extraction quality depending on result order.

The parser is now resolved per URL; an explicitly passed parser still applies to the whole batch as before. Verified with mixed xml/html batches in both orders and with an explicit parser override.
2026-07-23 21:35:27 -04:00
Classic298
656a848043
perf: halve Redis round trips on model resolution and socket pools (#27225)
When WEBSOCKET_MANAGER=redis, app.state.MODELS and the socket session/
usage pools are Redis-backed dicts, so every membership test and
getitem is a network round trip:

- generate_chat_completion checked `model_id not in models` (HEXISTS)
  and then read `models[model_id]` (HGET) on every chat completion.
  A single .get() now serves both, with the same not-found error.
- The direct-connection branch spread the pool with `{**MODELS, ...}`,
  which iterates keys() then fetches each value — HKEYS plus one HGET
  per model. dict(MODELS.items()) issues a single HGETALL instead.
- get_user_ids_from_room called SESSION_POOL.get(sid) twice per
  session (once to filter, once for the value); the usage handler
  checked membership then fetched the same key. Both now do one
  lookup.

In non-Redis mode these are plain dicts and behavior is identical.


Claude-Session: https://claude.ai/code/session_01MHg5zs1VBjvRWQ54qHpfYD

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 21:32:01 -04:00
Timothy Jaeryang Baek
429f2df50c refac 2026-07-23 21:29:33 -04:00
Classic298
484fb61743
perf: make streamed content accumulation genuinely linear (#27359)
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (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_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/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/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
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 / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
The streaming handler's content accumulator is a closure cell (declared nonlocal in stream_body_handler), and CPython's in-place string append optimization only applies to plain local variables (STORE_FAST), never to cell variables (STORE_DEREF). The content += value form introduced in #27231 therefore still allocates and copies the full accumulated string on every delta, exactly like the f-string it replaced; whether that copy is cheap or expensive is up to the allocator, and measurements swing accordingly (6 to 83 ms of pure copying for a 400 KB response on Python 3.12, against 0.3 ms for this patch).

Accumulate the deltas in a list instead and join once at the single read site (publish_chat_finished_event at stream end). List append is amortized O(1) with no dependence on reference counts, bytecode specialization or allocator behaviour, so accumulation is O(n) by construction. The non-str fallback keeps the previous f-string coercion semantics.

Verified end to end against a mock SSE upstream: a streamed chat with a think-tag block plus 40 content deltas produces output items, message text, reasoning text and usage identical to current dev, with no errors in the server log.
2026-07-23 18:17:41 -05:00
Timothy Jaeryang Baek
9a49b271aa refac 2026-07-23 19:17:19 -04:00
Timothy Jaeryang Baek
f2dd88285a refac 2026-07-23 19:15:32 -04:00
Timothy Jaeryang Baek
858e9236df refac 2026-07-23 19:12:47 -04:00
Classic298
d0f759ce40
fix: capture uncompressed response bodies in audit logs (#27369)
CompressMiddleware was registered before AuditLoggingMiddleware. Starlette prepends on add_middleware, so the audit layer ended up outside compression and, at the REQUEST_RESPONSE level, recorded the zstd/brotli/gzip bytes of every response, decoded with errors='replace'. Any client that sent Accept-Encoding (i.e. every browser) therefore produced audit entries whose response_object was unreadable mojibake.

Registering the audit middleware before the compression middleware places it inside compression, so it observes the response body exactly as the route produced it while the client still receives the compressed stream.

Verified with a stacked ASGI harness: in the old order the captured body is not parseable; in the new order the captured body round-trips as the original JSON and the client response stays compressed.
2026-07-23 18:09:40 -05:00
Classic298
3d45947053
fix: report sub-second timings in the X-Process-Time header (#27368)
The header value was truncated with int(), so every request faster than one second reported X-Process-Time: 0 and the header carried no information for exactly the requests it is meant to describe. Emit fractional seconds with microsecond precision instead, matching the pre-ASGI-refactor behavior where the raw float was sent.
2026-07-23 18:09:23 -05:00
Classic298
e0918ddb40
perf: stop the audit middleware from re-running the whole auth pipeline (#27373)
With audit logging enabled, every audited request authenticated twice. The route dependency resolved the user once, and then _log_audit_entry called get_current_user again in the request's finally block: a second JWT decode, two more Redis revocation lookups, a second user row fetch with pydantic validation and, crucially, a second fire-and-forget last-active write transaction per request.

get_current_user now stashes the resolved user on the scope-backed request state (the same mechanism the auth middleware already uses for request.state.token), and the audit middleware reuses it, falling back to the old resolution only when no user was stashed (e.g. routes without an auth dependency). While in the file, the audit path patterns are compiled once in the constructor instead of per request, and the always-log endpoint set is a class attribute instead of a per-call literal; both are fixed for the process lifetime.

Benchmark:

| metric | before | after |
| --- | --- | --- |
| audit auth resolution, CPU floor (JWT decode + user validate only) | 16.7 us | 0.24 us |
| extra work per audited request | 2 Redis GETs + 1 user SELECT + 1 last-active write | none |

The before column understates the saving: it excludes the Redis and DB round trips listed in the second row, which dominate in real deployments.

Functionally verified with a stacked ASGI harness: when the route resolves a user the audit entry carries that user and the auth pipeline is not invoked again; without a stashed user the fallback path still resolves and logs correctly; the skip matrix (exclusions, whitelist mode, always-log auth endpoints, unauthenticated and non-audited methods) is unchanged.
2026-07-23 18:09:04 -05:00
Classic298
699d512e2f
perf: drop redundant session.refresh calls after commit across the model layer (#27381)
Both session factories run with expire_on_commit=False, so ORM objects keep their attribute values after commit. Every session.refresh issued right after a commit therefore re-SELECTed a row whose values the session already held, including full chat JSON blobs and user settings, purely to overwrite identical data. Fifty such calls existed across the model layer, covering nearly every write path in the app (chat inserts, title updates, pin/archive toggles, user role and settings updates, tool, prompt, function, model, file, tag, feedback, memory, automation and grant writes).

All fifty are removed. The only refreshes with an actual job were the two update-then-reload paths in tools and skills, where a Core UPDATE statement bypasses the identity map; those now use session.get(..., populate_existing=True), which guarantees a fresh row in one SELECT whether or not the row was already present in the session (the previous code issued get plus refresh, two SELECTs, on the default configuration).

Benchmark (real SQLite DB, per write):

| write path | before | after |
| --- | --- | --- |
| chat title update, ~600 KB chat blob | 2.08 ms | 1.24 ms |
| user role update, small row | 1.21 ms | 0.68 ms |

On Postgres each removed refresh is additionally a network round trip. The chat-blob case also skips re-parsing the entire JSON document per write.

Functionally verified against a fresh database: user insert, role and settings updates, chat insert (including the server-default meta column, which is always provided client-side), title update and pin toggle, tool insert and the Core-update reload path, tag insert and the prompt insert flow that pins version_id after history creation all return correct values and persist correctly.
2026-07-23 18:08:00 -05:00
Classic298
6b655689cc perf: cut repeated per-model work out of model list assembly
get_all_models runs on every models refresh and, without the base-models cache (off by default), on every /api/models request. Several of its costs multiplied by the model count for no reason:

- The active action and filter id sets were derived from get_functions_by_type, which loads full function rows including plugin source and validates them, only for the ids and is_global flags. A generalized column-only query now returns (id, is_global) tuples; the existing filter-specific helper delegates to it.
- Action priorities were computed inside the per-model sort key, constructing a pydantic Valves object per action per model; with global actions in every model's list that was models x actions constructions per refresh. Priorities are now memoized per action.
- Global action and filter item dicts were rebuilt per model from the same modules. The item lists are now built once per function and shallow-copied per model, keeping per-model dicts independent exactly as before (nested values were already shared).
- Deactivated base-model overrides were dropped with models.remove, a linear scan and shift per removal; removals are now collected and filtered out in one identity-based pass, preserving list.remove's exact object semantics.
- RedisDict.set fingerprinted the payload by serializing the already-serialized mapping a second time plus a sha256; a direct dict comparison against the last written mapping has the same skip semantics without re-serializing anything.
- /api/models did tag normalization and profile-image stripping for every model before access filtering discarded the invisible ones, and always evaluated a json.dumps debug f-string; the work now runs only on visible models and the debug line is gated on the log level. The duplicate-id dedup keeps its position before filtering so the effective-model semantics are unchanged.

Benchmark:

| metric | before | after |
| --- | --- | --- |
| model-cache fingerprint, 200 models | 45 us | 1.4 us |
| action priority Valves builds, 200 models x 4 global actions | 0.37 ms (800 builds) | 0.002 ms (4 builds) |
| function-table payload for id sets | full rows incl. source | (id, is_global) tuples |

Functionally verified: the column-only id query matches the full-row query for actions and filters including inactive exclusion, and the fingerprint skip logic writes on first set, skips identical payloads, updates plus deletes stale keys on change and clears on empty, against a scripted fake Redis.
2026-07-23 19:05:32 -04:00
Classic298
310ae91302
perf: make tools defer_content real and batch tool and knowledge access filters (#27387)
Tools.get_tools(defer_content=True) contained the literal dead statement "stmt = stmt": the deferral was a no-op, so every tools listing loaded the full Python source of every tool (five caller sites pass defer_content=True expecting the optimization: the tools list endpoints and the user and group permission overviews). The listing now selects every column except content, and ToolModel.content becomes optional to represent deferred rows; router projections are content-less response models, so nothing downstream reads the source on these paths.

On top of that, get_tools_by_user_id issued one grant query per non-owned tool and Knowledges.get_knowledge_bases_by_user_id did the same per knowledge base (the latter also sits inside per-file access checks). Both now resolve grants for all non-owned rows in a single get_accessible_resource_ids call, the same batch helper the model listing already uses.

Benchmark (real SQLite DB):

| metric | before | after |
| --- | --- | --- |
| tools listing, 33 tools x ~200 KB source | 5.13 ms | 3.18 ms |
| grant queries per accessible-tools call, N non-owned tools | N | 1 |
| grant queries per accessible-KBs call, N non-owned KBs | N | 1 |

The listing row scales with source size; on Postgres the deferral additionally avoids shipping every tool's source over the wire per listing, and each removed grant query was a real round trip.

Functionally verified: deferred listings match full listings field for field with content None, grants included and router projections working; access filtering returns exactly owned plus granted tools and knowledge bases and nothing for strangers; full (non-deferred) reads still carry the source.
2026-07-23 17:51:07 -05:00
Classic298
5b518cbe43
perf: list knowledge base file metadata without loading extracted file contents (#27386)
Knowledges.get_file_metadatas_by_id fetched full File rows, whose data column carries the entire extracted text of each document, validated each into a FileModel and then threw everything except id, hash, meta and the timestamps away. The function backs every knowledge base detail view and runs again after every file add or remove (eight call sites in the knowledge router), so rendering a filename list for a 50-file knowledge base parsed tens of megabytes of JSON per request.

The listing now selects exactly the five columns the response needs, joined through KnowledgeFile, mirroring the column-only helper that already existed in the files model for id-based lookups.

Benchmark (real SQLite DB, 50 files with ~200 KB extracted text each):

| metric | before | after |
| --- | --- | --- |
| KB file metadata listing | 14.2 ms | 1.20 ms |

The gap widens linearly with file size and count since extracted contents no longer get read, parsed or validated at all.

Functionally verified: output matches the old implementation field for field on all 50 files and an unknown knowledge base still returns an empty list.
2026-07-23 17:50:55 -05:00
Classic298
d67bc4ffcd
perf: batch the file access check queries (#27383)
has_access_to_file runs for every non-owner file GET, per RAG file check and per shared-chat or model-attached file. Its final step called Models.get_models_by_user_id, which issued one grant query per non-owned workspace model, so a single file check on an instance with M workspace models cost M grant queries plus a group query, with the deny path always paying full price. Its collection_name step listed every knowledge base the user can access (itself one grant query per knowledge base) just to scan the list for one id. And get_accessible_folder_files repeated the whole pipeline per folder entry, refetching the caller's group memberships every time.

Three changes, all using parameters and helpers that already exist:
- Models.get_models_by_user_id resolves grants for all non-owned models in one get_accessible_resource_ids call and accepts prefetched user_group_ids.
- The collection_name check fetches the one referenced knowledge base and performs a single owner-or-grant check with the already-resolved group ids, preserving the write-requires-owner guard exactly (including its short-circuit before any grant query).
- get_accessible_folder_files resolves group ids once and threads them through every per-entry check.

Benchmark:

| metric | before | after |
| --- | --- | --- |
| filter loop CPU, 300 workspace models (queries stubbed) | 47 us | 19 us |
| grant queries per file-access check, M workspace models | M | 1 |
| group membership queries per folder listing, F files | F | 1 |

The stubbed CPU row understates the win: each removed query in the other two rows was a real database round trip.

Functionally verified with stubbed accessors: owned plus granted models are returned with owned ids excluded from the batch query; model-attached file access resolves through the batched path; the collection_name path does one KB fetch and one grant check with no full listing; a missing KB falls through; write access via a KB still requires the KB owner to own the file and short-circuits before the grant query; folder listings fetch groups exactly once.
2026-07-23 17:50:08 -05:00
Classic298
d5f099a5d4
perf: cut per-request database session overhead (#27385)
Two independent sources of fixed per-request cost:

The async SQLite engine was created with pool_pre_ping=True. A pre-ping guards against server connections dropped by timeouts or restarts, which cannot happen to a local SQLite file; each ping still costs a hop into the aiosqlite worker thread plus a SELECT 1 on every connection checkout, and with session sharing off a single request checks out a connection for every model-layer call it makes. The Postgres engines keep their pre-ping, where it is actually protective.

CommitSessionMiddleware unconditionally ran ScopedSession.commit() plus remove() after every HTTP request. The scoped registry instantiates a session on first access, so on the vast majority of requests (which never touch the sync session, per the middleware's own docstring) this built a Session, opened and committed an empty transaction and tore everything down for nothing. The middleware now checks ScopedSession.registry.has() first: requests that used the sync session are committed and removed exactly as before, on success and on the rollback path alike, and idle requests skip the machinery entirely.

Benchmark (real SQLite database):

| metric | before | after |
| --- | --- | --- |
| user row fetch incl. session + connection checkout | 681 us | 514 us |
| idle-request sync session work (create + empty commit + teardown) | 12.3 us | 0.26 us |

The first row saves per model-layer call, not per request: a request making five DB calls saves the checkout ping five times.

Functionally verified: normal reads and writes work with pre-ping off; an idle request through the middleware leaves no sync session behind; a request that uses the sync session still gets committed and removed.
2026-07-23 17:49:48 -05:00
Classic298
dd514ee20b
fix: persist upstream streaming error lines by awaiting the message upsert (#27365)
The branch that normalizes plain JSON error lines from streaming upstreams (lines without the SSE data: prefix) called Chats.upsert_message_to_chat_by_id_and_message_id without await. The coroutine was never executed, so the error was never written to the chat and Python emitted a "coroutine was never awaited" RuntimeWarning instead. The frontend still received the error event, but after a reload the message showed no trace of the failure.

The parallel error-persist branch further down the same handler already awaits the call; this aligns the two.
2026-07-23 17:49:18 -05:00
Timothy Jaeryang Baek
e769f9ff4f refac 2026-07-23 17:53:13 -04:00
Timothy Jaeryang Baek
ec56022bc1 refac 2026-07-23 16:35:48 -04:00
Timothy Jaeryang Baek
892dc03151 refac 2026-07-23 16:35:01 -04:00
G30
e62e4eb9fe
fix(ui): contain archived chats and analytics content within the settings modal (#27306) 2026-07-23 13:34:35 -05:00
Timothy Jaeryang Baek
132a29fd1c refac 2026-07-23 14:29:30 -04:00
Timothy Jaeryang Baek
c8f2e09fdc refac 2026-07-23 14:29:27 -04:00
Timothy Jaeryang Baek
25faa19941 refac 2026-07-23 14:27:55 -04:00
Classic298
e64acf1c0a
perf: batch and deduplicate per-request DB reads in the chat middleware (#27223)
Several spots in the chat pipeline issued sequential single-key config
SELECTs, or fetched the same key twice back-to-back, on every request:

- chat_completion_tools_handler: task model default/external and the
  tools prompt template were four sequential Config round trips (the
  template was fetched twice). One batched Config.get_many now serves
  all of them.
- chat_completion_files_handler: the six RAG settings (top_k,
  top_k_reranker, relevance_threshold, hybrid_bm25_weight,
  enable_hybrid_search, full_context) were six sequential round trips
  inside the retrieval call. Batched into one get_many.
- Voice and code-interpreter prompt templates were each fetched twice
  within one conditional; fetch once and reuse. The code-interpreter
  engine was likewise fetched twice per execution.
- Skill resolution fetched the accessible-skills list, kept only the
  ids, then re-fetched each mentioned skill by id (N+1). Reuse the
  rows from the access query.

Value semantics are identical: get_many applies the same defaults as
the individual gets, and the pre-existing truthiness/empty-string
checks on templates are preserved exactly.


Claude-Session: https://claude.ai/code/session_01MHg5zs1VBjvRWQ54qHpfYD

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 13:21:29 -05:00
Juan Calderon-Perez
ca2d7c9deb
feat: add LDAP group synchronization support (#27263)
* feat: expose LDAP group sync settings in admin config

LDAP group synchronization was already wired into the login flow but its
settings (group management, auto-creation, and the group attribute) could
only be set via environment variables. OAuth, by contrast, exposes its
group-mapping settings through the admin config API and UI.

Bring LDAP to parity:
- Add enable_group_management, enable_group_creation and
  attribute_for_groups to LdapServerConfig and LDAP_SERVER_CONFIG_KEYS so
  the /admin/config/ldap/server endpoint reads and persists them.
- Add a "Group Mapping / Auto-Create Groups / Group Attribute" section to
  the LDAP admin settings UI, mirroring the OAuth group-mapping controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

* fix: harden LDAP group sync config and login flow

Address review findings on the LDAP group-sync settings:

- ldap_auth: move the auto-create-groups call inside the try/except that
  wraps group sync, so a group-creation error is logged instead of
  bubbling to the broad handler and failing the whole login.
- update_ldap_server: reject saving with group management enabled but an
  empty group attribute, which would otherwise make sync silently no-op
  (mirrors the existing required-field validation).
- Authentication.svelte: merge the LDAP server config response into the
  client defaults instead of replacing the object, so any key an older
  backend omits keeps its default value.

Note: the empty-directory-groups behavior was reviewed and already
matches OAuth (both skip removal when no groups are returned), so it was
left unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

* fix: default blank LDAP group attribute to memberOf before save

The Group Attribute field advertises "Default to memberOf", but the
backend now rejects an empty group attribute when group management is
enabled. Fall back to the memberOf default client-side when the field is
left blank, so the advertised default holds and the save isn't rejected.
The backend validation remains as defense-in-depth for direct API calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

* fix: initialize LDAP port default as null instead of empty string

The backend LdapServerConfig types port as `int | None`, but the frontend
initialized it to an empty string. If a save carried that default (e.g.
when the backend response omits port under version skew), Pydantic would
reject the empty string. `null` matches the model and is also what the
type="number" input yields when the field is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

* fix: parse LDAP group DNs correctly instead of splitting on commas

Group CN extraction split the DN on raw commas and sliced off "CN=",
which mangles any group whose name contains an escaped separator (e.g.
"CN=Sales\, EMEA,OU=...") into a truncated, wrong name that then fails to
match the intended Open WebUI group. Use ldap3's parse_dn to split the DN
respecting RFC 4514 escaping, and unescape the resulting value so the CN
matches what an administrator sees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

* chore: address review feedback on _unescape_ldap_dn_value

Trim the docstring and rename the loop index to a more descriptive name
(i -> pos) per review feedback on the group DN unescaping helper. No
behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 13:20:55 -05:00
Timothy Jaeryang Baek
0f82f40b70 refac 2026-07-23 13:49:49 -04:00
Timothy Jaeryang Baek
ca11bd90a7 chore: format 2026-07-23 13:41:16 -04:00
Timothy Jaeryang Baek
93bd05271c refac 2026-07-23 13:40:30 -04:00
Timothy Jaeryang Baek
3026ac64a2 refac 2026-07-23 13:36:35 -04:00
Classic298
dc4b828852
fix: correct async import hook that disables the code-interpreter module blocklist (#27245)
The middleware code-interpreter path defines `restricted_import` as `async def` and assigns it to `builtins.__import__`, which Python's import machinery calls synchronously. Calling an async function returns a coroutine without running its body, so the blocklist check never executes and `_real_import` is never called. When `CODE_INTERPRETER_BLOCKED_MODULES` is set, blocked modules are therefore not blocked, and every subsequent import inside the interpreter binds a dangling coroutine instead of the module, breaking legitimate imports as well.

Define the hook as a regular `def`, matching the working implementation in `tools/builtin.py`. A blocked top-level import now raises `ImportError`, and all other imports pass through to the real importer.
2026-07-23 12:33:44 -05:00
andrep2222
66bf96c62d
Forward user info headers to Mistral OCR API (#27253)
Mirrors the ENABLE_FORWARD_USER_INFO_HEADERS pattern already used by
the audio/TTS and external document loader integrations, so the
Mistral OCR backend can identify the requesting user the same way.

Co-authored-by: andrep <vpham@aut.ac.nz>
2026-07-23 12:33:19 -05:00
Timothy Jaeryang Baek
49abfbdd15 refac 2026-07-23 13:18:04 -04:00
Classic298
da7097565c
perf: deduplicate repeated config fetches in Ollama request handlers (#27226)
The per-request Ollama handlers (chat, generate, embed, embeddings,
and the OpenAI-compat completions/chat-completions/messages/responses
endpoints) fetched 'ollama.api_configs' up to three times and
'ollama.base_urls' separately within a single request — the .get()
default-argument pattern made the second api_configs fetch
unconditional, and get_api_key() triggered a third. Up to four
sequential SELECTs per request collapse to one.

A new get_ollama_connection_config() helper fetches base_urls and
api_configs together in one batched Config.get_many where both are
needed; handlers that only need api_configs fetch it once into a
local. Admin operations (pull/push/copy/delete) and the TTL-cached
model-list path are deliberately left untouched.

Resolution semantics (str(idx) key first, url-key legacy fallback,
same defaults) are unchanged.


Claude-Session: https://claude.ai/code/session_01MHg5zs1VBjvRWQ54qHpfYD

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 12:48:55 -04:00
Timothy Jaeryang Baek
85664f650c refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-07-23 12:48:23 -04:00
Timothy Jaeryang Baek
f9107edeeb refac 2026-07-23 12:48:14 -04:00
Classic298
8ace4f0a8a
perf: stop recomputing static work in per-response ASGI middlewares (#27229)
SecurityHeadersMiddleware called set_security_headers() on every
response — 14 os.environ.get lookups plus a regex validation per
configured header, for values that are static for the process
lifetime. Compute the header list once at construction; when no
security env vars are set, skip wrapping send entirely.

RedirectMiddleware decoded and parse_qs'd the query string of every
GET, though it only acts on /watch?v= and ?shared= URLs. Add a cheap
path/substring precheck first; a false positive just falls through to
the previous full parse, so no redirect behavior changes.

Verified byte-identical responses (status, Location, header values)
against the previous implementations across redirect, passthrough,
and no-env cases.


Claude-Session: https://claude.ai/code/session_01MHg5zs1VBjvRWQ54qHpfYD

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 12:29:01 -04:00
Timothy Jaeryang Baek
1513ddaf58 refac 2026-07-23 12:25:17 -04:00
Classic298
62491debfa
perf: linear content accumulation in the streaming response handler (#27231)
The streaming handler rebuilt the full accumulated response with
`content = f'{content}{value}'` on every content delta — a complete
string copy per chunk, making accumulation O(n^2) over the response
length. Use in-place `content += value` for the (universal) str case,
which CPython extends in place, keeping accumulation O(n); the
f-string fallback is preserved for non-str values so coercion
behavior is unchanged.

In the ENABLE_REALTIME_CHAT_SAVE branch, full_output() — which
concatenates the entire accumulated output — was called twice per
chunk (once for the DB upsert, once for the emitted delta). Compute
it once and reuse.


Claude-Session: https://claude.ai/code/session_01MHg5zs1VBjvRWQ54qHpfYD

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 12:24:29 -04:00
Classic298
8becf9443e
fix: only admins may switch the global Automatic1111 checkpoint from image generation (#27244)
The Automatic1111 branch of the image generation route called set_image_model whenever the request carried a model field. On this backend set_image_model is not request-scoped: it persists image_generation.model to the global configuration and posts the new sd_model_checkpoint to the shared server, because Automatic1111 holds a single checkpoint instance-wide. A non-admin holding features.image_generation could therefore change the instance-wide image model and the shared backend checkpoint for every user by sending a model on an ordinary generation request, even though the setting is otherwise managed only through the admin-only image configuration route and the frontend never sends this field.

Gate the switch on an admin caller. A non-admin now generates on the currently configured checkpoint and the model field no longer mutates global state; admins retain per-request model switching here and through the image configuration route. Image editing is unaffected, as it selects its model per request without writing global configuration.
2026-07-23 12:23:24 -04:00
Classic298
9a6d168499
fix: enforce action availability and model access on the chat action route (#27243)
The chat action route loaded a Function by its raw action_id and executed its action callable after only checking that the id and the requested model existed. The model list that the client renders actions from resolves each model's actions to the active action-type Functions that are global or assigned to that model, and the action route did not mirror that resolution, so a disabled, unassigned, or wrong-type Function, or an action on a model the caller cannot access, could be reached by calling the route directly.

Gate the route on the same rules the model resolution applies: the Function must be an active action, and for server-resolved models the caller must have model access and the action must be one the model actually surfaces (matched by function id, the prefix of each model actions entry, so single and sub-actions both resolve). Direct connections carry a client-supplied model the caller already owns, so the model-bound checks are scoped to non-direct calls; the active-action check always applies. Executing admin-authored Function code remains intended behaviour — this only keeps the route consistent with which actions each model exposes.

Co-authored-by: komyunghan <komyunghan@users.noreply.github.com>
2026-07-23 12:23:05 -04:00
Classic298
9a54bc4bbb
perf: halve function-table queries in get_all_models (#27230)
get_all_models ran four function-table queries: global actions, active
actions, global filters, active filters. Global functions are by
definition (type, is_active=True, is_global=True) — a subset of the
active set — so the global id sets can be derived from the active-rows
queries' is_global flag. Four queries become two, and each dropped
query returned full rows including every plugin's source code.

Also folds the mid-function 'models.default_metadata' read into the
Config.get_many already issued at the top of the function (one fewer
round trip; the existing `or {}` default handling is preserved).


Claude-Session: https://claude.ai/code/session_01MHg5zs1VBjvRWQ54qHpfYD

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 12:11:51 -04:00
Classic298
32242a6788
perf: 40% LESS CPU usage: cut per-instance CPU cost of shared socket.io Redis pub/sub channel (#27282)
* perf: cut per-instance CPU cost of shared socket.io Redis pub/sub channel

Profiling a multi-instance deployment (py-spy --gil) showed ~44% of worker
CPU in the socket.io pub/sub listener. Two causes, two fixes:

- Add hiredis so redis-py parses the RESP protocol in C instead of pure
  Python (redis/_parsers/resp3.py alone accounted for ~28% of GIL samples;
  redis-py auto-selects the hiredis parser when importable).

- Subclass AsyncRedisManager to drop emits whose target room has no local
  participants before upstream _handle_emit re-encodes the full packet.
  Every instance receives every emit published on the shared channel, so
  with N instances all but the hosting one were paying full packet
  re-serialization per message just to deliver it to nobody. Broadcasts
  (room=None) are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9CQ9qnp3sZGYQQwztsCJT

* fix: restrict pub/sub emit early-out to string rooms

Adversarial review against python-socketio 5.16.2 found one divergence
from upstream: for a degenerate empty-sequence room (emit to room=[]) on
an instance whose namespace has no local clients, the filter's
get_participants probe raises IndexError from room[0] where upstream
returns silently at the namespace guard and still publishes to Redis.
Open WebUI only ever emits to scalar string rooms or room=None, so the
case is unreachable today; guard on isinstance(room, str) anyway so any
non-string room shape passes through to upstream behavior unchanged.

Every open-webui emit uses a string room, so the fast path still covers
all real traffic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9CQ9qnp3sZGYQQwztsCJT

* Update requirements.txt

* Update pyproject.toml

* Update requirements-min.txt

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 12:11:38 -04:00
Timothy Jaeryang Baek
aaf2834db7 refac 2026-07-23 12:03:26 -04:00
Timothy Jaeryang Baek
d0f7da4f45 refac 2026-07-23 04:45:27 -04:00
Timothy Jaeryang Baek
bb12b1a18b refac 2026-07-23 04:16:14 -04:00
Timothy Jaeryang Baek
cc9a44569e refac 2026-07-23 04:01:30 -04:00
Timothy Jaeryang Baek
48625e657f refac 2026-07-23 03:54:19 -04:00
Classic298
073cd65afe
Log upstream provider rejections at warn/error level (#27238)
When an upstream provider rejects a request (e.g. a 400 for a
max_tokens value above the model's ceiling), the actionable error
message was only published to event sinks, which are invisible unless
an event function or webhook is configured. Admins had to query the
provider's API directly to diagnose failures (open-webui#27237).

Add a single log line in publish_model_provider_request_failed — the
chokepoint every upstream failure path (OpenAI-compatible chat,
embeddings, responses, token counting, and Ollama) already routes
through — recording status, provider, url, model, error code, and the
upstream message truncated to 1000 chars. 4xx logs at WARNING, 5xx at
ERROR. Client-facing responses are unchanged, so no additional error
detail is exposed in the chat.


Claude-Session: https://claude.ai/code/session_018VecyiPejru1EVF5yfe2sU

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 03:42:48 -04:00
Timothy Jaeryang Baek
48cc9d388e refac 2026-07-23 03:39:56 -04:00
Timothy Jaeryang Baek
e18e249d5d refac 2026-07-23 03:39:30 -04:00
Timothy Jaeryang Baek
af629177f4 refac 2026-07-23 03:31:32 -04:00
Classic298
3cf3f8e189
fix: add missing folders field to SharingPermissions so the permission persists (#27296)
`sharing.folders` is present in `DEFAULT_USER_PERMISSIONS` but absent from the `SharingPermissions` response/update schema, so the admin default and group permission API silently drops it on every round-trip and the setting is never saved. Add `folders: bool = False`, matching the config default (`USER_PERMISSIONS_FOLDERS_ALLOW_SHARING`), restoring parity with `DEFAULT_USER_PERMISSIONS`.

Fixes #27120.
2026-07-23 03:24:51 -04:00
Andi Chandler
adf07e8df0
**i18n**: Internationalization or localization changes (#27304)
* Change spelling to British English in translation.json

Updated translations to use British English spelling for various terms.

* Update translation.json
2026-07-23 03:24:21 -04:00
joaoback
1b28b8a144
i18n: add pt-BR translations for newly added UI items and consistency pass (#27324)
New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.
2026-07-23 03:23:47 -04:00
Timothy Jaeryang Baek
9f00b62b3a refac 2026-07-23 03:22:51 -04:00
Timothy Jaeryang Baek
30415c925a refac 2026-07-23 03:11:38 -04:00
Timothy Jaeryang Baek
6ff1df326c refac 2026-07-23 03:05:29 -04:00
Timothy Jaeryang Baek
060d5da473 refac 2026-07-23 03:01:01 -04:00
Timothy Jaeryang Baek
73421c5b42 refac 2026-07-23 02:55:06 -04:00
Timothy Jaeryang Baek
cf887b68ea refac 2026-07-23 02:54:56 -04:00
Timothy Jaeryang Baek
5418ac921b refac 2026-07-23 02:33:09 -04:00
Timothy Jaeryang Baek
c4efa81d08 refac
Some checks failed
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
2026-07-21 14:18:41 -04:00
Timothy Jaeryang Baek
9ca8cf528a refac 2026-07-21 14:16:51 -04:00
Timothy Jaeryang Baek
409fb39717 refac 2026-07-21 13:53:30 -04:00
Timothy Jaeryang Baek
86efecd9ad refac 2026-07-20 22:45:50 -04:00
Timothy Jaeryang Baek
8631dc83dc refac 2026-07-20 22:27:13 -04:00
Timothy Jaeryang Baek
6940297486 refac 2026-07-20 22:27:09 -04:00
Timothy Jaeryang Baek
49e57f4e7e chore: format 2026-07-20 22:11:42 -04:00
Timothy Jaeryang Baek
4a42543fc3 refac 2026-07-20 22:11:16 -04:00
Timothy Jaeryang Baek
e88d2e053c refac 2026-07-20 22:10:11 -04:00
Timothy Jaeryang Baek
704d07e9a2 refac 2026-07-20 22:04:39 -04:00
Timothy Jaeryang Baek
bc8d24c951 refac 2026-07-20 22:03:31 -04:00
Timothy Jaeryang Baek
1428a4ddce refac 2026-07-20 21:44:12 -04:00
Timothy Jaeryang Baek
0c7ddbdb4f refac 2026-07-20 19:49:34 -04:00
Timothy Jaeryang Baek
2fcb36267f refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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 / notify-helm-charts (push) Blocked by required conditions
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 / copy-to-dockerhub (, main) (push) Blocked by required conditions
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 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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Unit Tests (push) Waiting to run
Frontend Build / Format & Build (push) Waiting to run
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
2026-07-20 01:33:53 -04:00
Timothy Jaeryang Baek
af9a315ac3 refac 2026-07-20 01:33:47 -04:00
Timothy Jaeryang Baek
6e5efc1f75 refac 2026-07-20 01:21:15 -04:00
Timothy Jaeryang Baek
4da2ff2655 refac 2026-07-20 00:25:37 -04:00
Timothy Jaeryang Baek
d3ea51fd46 refac 2026-07-20 00:23:01 -04:00
Timothy Jaeryang Baek
2dbdba1f91 refac
Some checks failed
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
2026-07-17 23:39:49 -04:00
Timothy Jaeryang Baek
1a32d92d08 refac
Some checks failed
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
2026-07-17 06:50:16 -04:00
Timothy Jaeryang Baek
b2f7ecd83a refac 2026-07-17 06:45:00 -04:00
Timothy Jaeryang Baek
59c75f569b refac 2026-07-17 06:38:10 -04:00
Timothy Jaeryang Baek
f192657dc9 refac 2026-07-17 06:35:24 -04:00
Timothy Jaeryang Baek
9281adc564 refac 2026-07-17 06:34:17 -04:00
Timothy Jaeryang Baek
fd07e3a8e3 refac 2026-07-17 06:30:04 -04:00
Timothy Jaeryang Baek
f3a3550784 refac 2026-07-17 06:29:19 -04:00
Timothy Jaeryang Baek
890bfd0d97 refac 2026-07-17 06:28:07 -04:00
Timothy Jaeryang Baek
ff49217206 refac 2026-07-17 06:22:15 -04:00
Timothy Jaeryang Baek
1bf05ebc7d refac 2026-07-17 06:06:23 -04:00
Timothy Jaeryang Baek
cda5bdb9d4 refac 2026-07-17 05:51:39 -04:00
Timothy Jaeryang Baek
ea2e3d0afc refac 2026-07-17 05:48:41 -04:00
Timothy Jaeryang Baek
437c06c479 refac 2026-07-17 05:46:09 -04:00
Timothy Jaeryang Baek
d027a32ed1 refac 2026-07-17 05:40:40 -04:00
Timothy Jaeryang Baek
21e180182a refac 2026-07-17 05:34:00 -04:00
Timothy Jaeryang Baek
2d83c7438c refac 2026-07-17 05:32:50 -04:00
Timothy Jaeryang Baek
d0bea60581 refac 2026-07-17 05:31:32 -04:00
Timothy Jaeryang Baek
421da67446 refac 2026-07-17 05:15:09 -04:00
Timothy Jaeryang Baek
6fcb38fe2e refac 2026-07-17 05:12:16 -04:00
Timothy Jaeryang Baek
5424ac5891 refac 2026-07-17 05:09:29 -04:00
Timothy Jaeryang Baek
3316ba76aa refac 2026-07-17 04:54:44 -04:00
Timothy Jaeryang Baek
e5e2cd7876 refac 2026-07-17 04:48:21 -04:00
Timothy Jaeryang Baek
56f2cb5302 refac 2026-07-17 04:11:11 -04:00
Timothy Jaeryang Baek
a213355785 refac 2026-07-17 04:09:33 -04:00
Timothy Jaeryang Baek
ab519e40d9 refac 2026-07-17 03:41:51 -04:00
Timothy Jaeryang Baek
af8cc6c91a refac 2026-07-17 03:40:28 -04:00
Timothy Jaeryang Baek
88c6b8bc5c refac 2026-07-17 03:30:44 -04:00
Timothy Jaeryang Baek
203953faa2 refac 2026-07-17 03:28:45 -04:00
Timothy Jaeryang Baek
741ce0c239 refac 2026-07-17 03:24:55 -04:00
Timothy Jaeryang Baek
a6fcc61a16 refac 2026-07-17 00:55:24 -04:00
Timothy Jaeryang Baek
ce44b90eae refac 2026-07-17 00:54:13 -04:00
Timothy Jaeryang Baek
27d7eafcd9 refac 2026-07-17 00:16:01 -04:00
Timothy Jaeryang Baek
d0ba3ada2c refac 2026-07-17 00:03:13 -04:00
Timothy Jaeryang Baek
4d10bfb72a refac 2026-07-17 00:01:34 -04:00
Timothy Jaeryang Baek
30c91e46e5 refac 2026-07-17 00:00:24 -04:00
Timothy Jaeryang Baek
346b99c383 refac 2026-07-16 23:39:16 -04:00
Timothy Jaeryang Baek
b08f1e8847 refac 2026-07-16 23:38:27 -04:00
Timothy Jaeryang Baek
53b8e6560b refac 2026-07-16 23:35:13 -04:00
Timothy Jaeryang Baek
001775d8e8 refac 2026-07-16 22:53:31 -04:00
Timothy Jaeryang Baek
743b9fd3ce refac 2026-07-16 21:57:43 -04:00
Timothy Jaeryang Baek
a2000df253 refac 2026-07-16 21:57:15 -04:00
Timothy Jaeryang Baek
668f9fe390 refac 2026-07-16 21:25:35 -04:00
Timothy Jaeryang Baek
975c9f9b50 refac 2026-07-16 21:08:23 -04:00
Timothy Jaeryang Baek
d7f33996ee refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-07-16 02:49:21 -04:00
Timothy Jaeryang Baek
2e857a82d7 refac 2026-07-16 02:47:51 -04:00
Timothy Jaeryang Baek
6167dc3564 refac 2026-07-16 02:43:50 -04:00
Timothy Jaeryang Baek
c3ce0c5080 refac 2026-07-16 02:42:47 -04:00
Timothy Jaeryang Baek
75894161e4 refac 2026-07-16 02:35:44 -04:00
Timothy Jaeryang Baek
8270aa59ab refac 2026-07-16 02:27:59 -04:00
Timothy Jaeryang Baek
f8d78289a6 refac 2026-07-16 02:25:20 -04:00
Timothy Jaeryang Baek
58960028a4 refac 2026-07-16 02:23:46 -04:00
Timothy Jaeryang Baek
f0eea61155 refac 2026-07-16 02:21:46 -04:00
Timothy Jaeryang Baek
3c45f9f511 refac 2026-07-16 02:18:54 -04:00
Timothy Jaeryang Baek
dc5a250068 refac 2026-07-16 02:07:47 -04:00
Timothy Jaeryang Baek
9be11883b8 refac 2026-07-16 02:05:08 -04:00
Timothy Jaeryang Baek
a410ca36af refac 2026-07-16 01:46:02 -04:00
Timothy Jaeryang Baek
4d27bfff92 refac 2026-07-16 01:42:24 -04:00
Timothy Jaeryang Baek
b80d204b81 refac 2026-07-16 01:37:21 -04:00
Timothy Jaeryang Baek
e6c2b8ad59 refac 2026-07-16 01:34:50 -04:00
Timothy Jaeryang Baek
cf235738f5 refac 2026-07-16 01:27:52 -04:00
Timothy Jaeryang Baek
200d447f62 refac 2026-07-16 01:10:13 -04:00
Timothy Jaeryang Baek
c55e373b99 refac 2026-07-16 00:58:34 -04:00
Timothy Jaeryang Baek
771a024b40 refac 2026-07-16 00:39:31 -04:00
Timothy Jaeryang Baek
92fbc77877 refac 2026-07-16 00:32:27 -04:00
Timothy Jaeryang Baek
b23ddeb280 refac 2026-07-16 00:30:44 -04:00
Timothy Jaeryang Baek
c1e228d6ad refac 2026-07-16 00:10:28 -04:00
Timothy Jaeryang Baek
1fe862b965 refac 2026-07-16 00:07:41 -04:00
Timothy Jaeryang Baek
f7716fcbaf refac 2026-07-16 00:00:38 -04:00
Timothy Jaeryang Baek
e389874fe2 refac 2026-07-15 23:55:31 -04:00
Timothy Jaeryang Baek
2b8ef9340e refac 2026-07-15 23:51:37 -04:00
Timothy Jaeryang Baek
0dc93b8ae7 refac 2026-07-15 23:48:32 -04:00
Timothy Jaeryang Baek
c4c4ab57e3 refac 2026-07-15 23:44:05 -04:00
Timothy Jaeryang Baek
d864669b59 refac 2026-07-15 23:38:48 -04:00
Timothy Jaeryang Baek
07fafe081b refac 2026-07-15 23:34:45 -04:00
Timothy Jaeryang Baek
2712103c59 refac 2026-07-15 23:32:17 -04:00
Timothy Jaeryang Baek
f67252b5e8 refac 2026-07-15 23:27:58 -04:00
Timothy Jaeryang Baek
ee000c503c refac 2026-07-15 23:21:06 -04:00
Timothy Jaeryang Baek
f7af03ff26 refac 2026-07-15 22:48:41 -04:00
Timothy Jaeryang Baek
588f129695 refac 2026-07-15 22:45:00 -04:00
Timothy Jaeryang Baek
9e2536aa57 refac 2026-07-15 22:40:30 -04:00
Timothy Jaeryang Baek
e65fdf1ca5 refac 2026-07-15 22:36:47 -04:00
Timothy Jaeryang Baek
185bca8552 refac 2026-07-15 22:34:52 -04:00
Timothy Jaeryang Baek
b16a4c4e9a refac 2026-07-15 21:45:17 -04:00
Timothy Jaeryang Baek
423cafd4e7 refac 2026-07-15 21:43:47 -04:00
Timothy Jaeryang Baek
4d11553a6b refac 2026-07-15 18:54:47 -04:00
Timothy Jaeryang Baek
b3255a3656 refac 2026-07-15 18:52:12 -04:00
Timothy Jaeryang Baek
6abee5cc3c refac 2026-07-15 18:29:48 -04:00
Timothy Jaeryang Baek
c5f546d3fb refac 2026-07-15 18:29:01 -04:00
Timothy Jaeryang Baek
72b14195b6 refac 2026-07-15 18:28:53 -04:00
Timothy Jaeryang Baek
d608c1298c refac 2026-07-15 18:26:29 -04:00
Timothy Jaeryang Baek
9e2eac05b0 refac 2026-07-15 18:24:15 -04:00
Timothy Jaeryang Baek
c3878b418a refac 2026-07-15 18:21:24 -04:00
Timothy Jaeryang Baek
4b0122a120 refac
Some checks are pending
Python CI / Ruff Format (3.12) (push) Waiting to run
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/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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Python CI / Ruff Format (3.11) (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 / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
2026-07-15 18:09:34 -04:00
Timothy Jaeryang Baek
933ab1e1cd refac 2026-07-15 18:03:36 -04:00
Timothy Jaeryang Baek
ba067258de refac 2026-07-15 17:50:26 -04:00
Timothy Jaeryang Baek
db934a3b4f refac 2026-07-15 16:51:08 -04:00
Timothy Jaeryang Baek
5b934b1f90 refac 2026-07-15 16:33:25 -04:00
Timothy Jaeryang Baek
667cba1a95 refac 2026-07-15 16:31:27 -04:00
Timothy Jaeryang Baek
9cfafc0608 refac 2026-07-15 16:27:13 -04:00
Timothy Jaeryang Baek
29782aba01 refac 2026-07-15 16:26:40 -04:00
Timothy Jaeryang Baek
609cc6ad9b refac 2026-07-15 16:26:31 -04:00
Timothy Jaeryang Baek
86f55d04ec refac 2026-07-15 16:21:47 -04:00
Timothy Jaeryang Baek
946487a3ba refac 2026-07-15 16:04:08 -04:00
Timothy Jaeryang Baek
74d976c2f7 refac 2026-07-15 15:13:51 -04:00
Timothy Jaeryang Baek
285a65ef39 refac 2026-07-15 15:08:53 -04:00
Timothy Jaeryang Baek
3fcb7b2d64 refac 2026-07-15 15:06:08 -04:00
Timothy Jaeryang Baek
396ee62226 refac 2026-07-15 05:52:21 -04:00
Timothy Jaeryang Baek
f8fbd50af3 refac 2026-07-15 05:51:06 -04:00
Timothy Jaeryang Baek
727041da78 refac 2026-07-15 05:49:48 -04:00
Timothy Jaeryang Baek
f8ea15b84a refac 2026-07-15 05:38:44 -04:00
Timothy Jaeryang Baek
3ce3c52936 refac 2026-07-15 05:17:41 -04:00
Timothy Jaeryang Baek
a3908f1281 refac 2026-07-15 04:55:38 -04:00
Timothy Jaeryang Baek
bd406851ea refac 2026-07-15 04:52:08 -04:00
Timothy Jaeryang Baek
343eb1d659 refac 2026-07-15 04:52:06 -04:00
Timothy Jaeryang Baek
1760b073c7 refac 2026-07-15 04:39:37 -04:00
Timothy Jaeryang Baek
91277726cd refac 2026-07-15 04:35:52 -04:00
Timothy Jaeryang Baek
59fc600b52 refac 2026-07-15 04:33:06 -04:00
Timothy Jaeryang Baek
d859110311 refac 2026-07-15 04:29:16 -04:00
Timothy Jaeryang Baek
9499587c33 refac 2026-07-15 04:25:51 -04:00
Timothy Jaeryang Baek
2018546a7b refac 2026-07-15 04:18:56 -04:00
Timothy Jaeryang Baek
f8350360df refac 2026-07-15 04:12:11 -04:00
Timothy Jaeryang Baek
05e3f71317 refac 2026-07-15 04:01:23 -04:00
Timothy Jaeryang Baek
9a706329c5 refac 2026-07-15 03:41:49 -04:00
Timothy Jaeryang Baek
fa889837e9 refac 2026-07-15 03:39:45 -04:00
Timothy Jaeryang Baek
fee4c280f8 refac 2026-07-15 03:22:29 -04:00
Timothy Jaeryang Baek
36cff229a9 refac 2026-07-15 03:17:32 -04:00
Timothy Jaeryang Baek
309313db68 refac 2026-07-15 03:16:06 -04:00
Timothy Jaeryang Baek
3ff20b210e refac 2026-07-15 03:13:37 -04:00
Timothy Jaeryang Baek
d300522e96 refac 2026-07-15 03:11:15 -04:00
Timothy Jaeryang Baek
3fa600123a refac 2026-07-15 03:01:50 -04:00
Timothy Jaeryang Baek
04311d559d refac 2026-07-15 02:53:39 -04:00
Timothy Jaeryang Baek
622767d724 refac 2026-07-15 02:49:46 -04:00
Timothy Jaeryang Baek
84b1ab0ed3 refac 2026-07-15 02:16:55 -04:00
Timothy Jaeryang Baek
280179828a refac 2026-07-15 02:11:10 -04:00
Timothy Jaeryang Baek
b2769f831e refac 2026-07-15 02:07:39 -04:00
Timothy Jaeryang Baek
804ec68a6b refac 2026-07-15 02:01:45 -04:00
Timothy Jaeryang Baek
fb2ea27295 refac 2026-07-15 01:59:56 -04:00
Timothy Jaeryang Baek
581f2f36f4 refac 2026-07-15 01:54:51 -04:00
Timothy Jaeryang Baek
b92f592300 refac 2026-07-15 01:51:40 -04:00
Timothy Jaeryang Baek
de0e90551f refac 2026-07-15 01:38:17 -04:00
Timothy Jaeryang Baek
1d1f60ab44 refac 2026-07-15 01:35:59 -04:00
Timothy Jaeryang Baek
ccb1ab7739 refac 2026-07-15 01:31:21 -04:00
Timothy Jaeryang Baek
c64fe45376 refac 2026-07-15 01:22:51 -04:00
Timothy Jaeryang Baek
43e792a8f4 refac 2026-07-15 01:16:06 -04:00
Timothy Jaeryang Baek
c1460570b7 refac 2026-07-15 00:40:20 -04:00
Timothy Jaeryang Baek
e05f9fa17e refac 2026-07-15 00:30:28 -04:00
Timothy Jaeryang Baek
7ebf15040b refac 2026-07-15 00:15:55 -04:00
Timothy Jaeryang Baek
63ada24706 refac 2026-07-15 00:14:01 -04:00
Timothy Jaeryang Baek
254888cf15 refac 2026-07-14 23:20:15 -04:00
Timothy Jaeryang Baek
263bbc77d8 refac 2026-07-14 23:13:50 -04:00
Timothy Jaeryang Baek
7a9928ef17 refac 2026-07-14 23:08:41 -04:00
Timothy Jaeryang Baek
ea31a3bd61 refac 2026-07-14 18:05:36 -04:00
Timothy Jaeryang Baek
50d3c927bf refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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
Frontend Build / Unit Tests (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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
2026-07-14 17:34:00 -04:00
Timothy Jaeryang Baek
e8b4c7f9e2 refac 2026-07-14 17:16:20 -04:00
Timothy Jaeryang Baek
9588c97e64 refac 2026-07-14 17:10:11 -04:00
Timothy Jaeryang Baek
eb11029eac refac 2026-07-14 17:07:39 -04:00
Timothy Jaeryang Baek
e39ff71532 refac 2026-07-14 17:05:07 -04:00
Timothy Jaeryang Baek
d42da41090 refac 2026-07-14 17:01:10 -04:00
Timothy Jaeryang Baek
54cef1df48 refac 2026-07-14 17:00:27 -04:00
Timothy Jaeryang Baek
79d3e34eea refac 2026-07-14 16:57:35 -04:00
Timothy Jaeryang Baek
6772b1cb4f refac 2026-07-14 14:41:12 -04:00
Timothy Jaeryang Baek
9f17c5960a refac 2026-07-14 14:33:10 -04:00
Timothy Jaeryang Baek
8dd862d338 refac 2026-07-14 14:17:59 -04:00
Timothy Jaeryang Baek
3be493f5b0 refac 2026-07-14 04:53:37 -04:00
Timothy Jaeryang Baek
c7f1ff9323 refac 2026-07-14 04:30:47 -04:00
Timothy Jaeryang Baek
c9103a29ff refac 2026-07-14 04:26:18 -04:00
Timothy Jaeryang Baek
7b2efdff08 refac 2026-07-14 04:20:28 -04:00
Timothy Jaeryang Baek
aedb6bef4e refac 2026-07-14 04:15:20 -04:00
Timothy Jaeryang Baek
a7fbf66269 refac 2026-07-14 04:06:41 -04:00
Timothy Jaeryang Baek
5929bf2061 refac 2026-07-14 04:02:42 -04:00
Timothy Jaeryang Baek
1b64ccbaa0 refac 2026-07-14 04:02:28 -04:00
Timothy Jaeryang Baek
b2e4bda927 refac 2026-07-14 03:53:37 -04:00
Timothy Jaeryang Baek
04b146f2ce refac 2026-07-14 03:52:59 -04:00
Timothy Jaeryang Baek
e58a4633b1 refac 2026-07-14 03:51:09 -04:00
Timothy Jaeryang Baek
92842ecf23 refac 2026-07-14 03:49:21 -04:00
Timothy Jaeryang Baek
dbdacf2678 refac 2026-07-14 03:48:34 -04:00
Timothy Jaeryang Baek
b3aead23da refac 2026-07-14 03:46:37 -04:00
Timothy Jaeryang Baek
2e8d92c7b1 refac 2026-07-14 03:43:34 -04:00
Timothy Jaeryang Baek
b2fd6ccfd5 refac 2026-07-14 03:36:44 -04:00
Timothy Jaeryang Baek
fcedbebcf4 refac 2026-07-14 03:18:46 -04:00
Timothy Jaeryang Baek
7407eeede8 refac 2026-07-14 03:16:20 -04:00
Timothy Jaeryang Baek
d475cb9174 refac 2026-07-14 03:10:12 -04:00
Timothy Jaeryang Baek
d364101761 refac 2026-07-14 02:55:01 -04:00
Timothy Jaeryang Baek
d8131d1091 refac 2026-07-14 02:37:21 -04:00
Timothy Jaeryang Baek
b9d5462ed0 refac 2026-07-14 02:11:38 -04:00
Timothy Jaeryang Baek
251ef952bf refac 2026-07-14 02:10:02 -04:00
Timothy Jaeryang Baek
809d9f29f3 refac 2026-07-14 01:47:50 -04:00
Timothy Jaeryang Baek
bcf449d5ad refac 2026-07-14 01:44:00 -04:00
Timothy Jaeryang Baek
a62ba97467 refac 2026-07-14 01:41:40 -04:00
Timothy Jaeryang Baek
b012d683d8 refac 2026-07-14 01:40:58 -04:00
Timothy Jaeryang Baek
6e14d0d627 refac 2026-07-14 01:39:40 -04:00
Timothy Jaeryang Baek
34813573fa refac 2026-07-14 01:34:41 -04:00
Timothy Jaeryang Baek
a9617ca218 refac 2026-07-14 01:15:29 -04:00
Timothy Jaeryang Baek
f1ded9409a refac 2026-07-14 01:13:40 -04:00
Timothy Jaeryang Baek
8f77533317 refac 2026-07-14 01:00:24 -04:00
Timothy Jaeryang Baek
410ebb05d4 refac 2026-07-14 00:54:01 -04:00
Timothy Jaeryang Baek
5ab0ea8b8c refac 2026-07-14 00:50:44 -04:00
Timothy Jaeryang Baek
d38c953608 refac 2026-07-14 00:49:30 -04:00
Timothy Jaeryang Baek
f1584b5a37 refac 2026-07-14 00:48:40 -04:00
Timothy Jaeryang Baek
84e4d6ef82 refac 2026-07-14 00:44:08 -04:00
Timothy Jaeryang Baek
77da3d8c81 refac 2026-07-14 00:43:40 -04:00
Timothy Jaeryang Baek
f84dabe3d9 refac 2026-07-14 00:43:06 -04:00
Timothy Jaeryang Baek
4ed19d504b refac 2026-07-14 00:42:57 -04:00
Timothy Jaeryang Baek
caa2457c17 refac 2026-07-14 00:42:47 -04:00
Timothy Jaeryang Baek
f730733bc4 refac 2026-07-14 00:30:47 -04:00
Timothy Jaeryang Baek
53ccd718a5 refac 2026-07-14 00:24:20 -04:00
Timothy Jaeryang Baek
009715cd63 refac 2026-07-14 00:22:00 -04:00
Timothy Jaeryang Baek
6a7068c3a4 refac 2026-07-14 00:21:47 -04:00
Timothy Jaeryang Baek
797293c749 refac 2026-07-14 00:19:41 -04:00
Timothy Jaeryang Baek
7088d245bb refac 2026-07-14 00:10:28 -04:00
Timothy Jaeryang Baek
0c23466a3e refac 2026-07-13 23:46:28 -04:00
Timothy Jaeryang Baek
8f07c0c8ee refac 2026-07-13 23:34:54 -04:00
Timothy Jaeryang Baek
d3fd860c13 refac 2026-07-13 23:32:07 -04:00
Timothy Jaeryang Baek
3005b7bc71 refac 2026-07-13 23:31:14 -04:00
Timothy Jaeryang Baek
23062e9fca refac 2026-07-13 23:31:01 -04:00
Timothy Jaeryang Baek
17e6496538 refac 2026-07-13 23:28:43 -04:00
Timothy Jaeryang Baek
959558fd82 refac 2026-07-13 23:27:54 -04:00
Timothy Jaeryang Baek
e1f96aa20e refac 2026-07-13 23:21:45 -04:00
Timothy Jaeryang Baek
2f37e853d1 refac 2026-07-13 23:19:58 -04:00
Timothy Jaeryang Baek
e355959e91 refac 2026-07-13 23:16:22 -04:00
Timothy Jaeryang Baek
08dacd19da refac 2026-07-13 23:01:10 -04:00
Timothy Jaeryang Baek
51ff386fd6 refac 2026-07-13 23:00:54 -04:00
Timothy Jaeryang Baek
e8b59b2ef3 refac 2026-07-13 22:50:24 -04:00
Timothy Jaeryang Baek
49d202a18e refac
Some checks are pending
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-07-13 12:06:17 -04:00
Timothy Jaeryang Baek
09d4cccb79 refac
Some checks failed
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
2026-07-10 18:32:21 -05:00
Timothy Jaeryang Baek
9a772f42c8 refac 2026-07-10 16:41:35 -05:00
A-PS1999
8c71897bc0
Corrected a few Japanese translations, added several more which were previously blank (#26881)
Some checks failed
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (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/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 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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
2026-07-10 13:33:46 -05:00
Classic298
f8c0d2fdd6
fix: don't mutate caller's payload when resolving a pipe base model (#26906)
generate_function_chat_completion rewrote form_data['model'] to the base
model id in place. Because that dict is the same object process_chat_response
later re-submits for the post-tool-call continuation, the continuation was
access-checked against the base model instead of the user-facing preset.
For a public preset over a private/unregistered pipe base, a non-admin's
first message succeeded but the continuation after a native tool call was
denied and silently swallowed, aborting the response (admins skip the
user-only check, so they were unaffected).

Operate on a shallow copy of form_data, mirroring the openai/ollama routers
which already substitute the base model on a copy. The continuation now
re-checks the preset the user actually has access to.


Claude-Session: https://claude.ai/code/session_018toPfJW1hMXAhokGaL43Ep

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-10 13:33:24 -05:00
Classic298
274729aa47
fix: socket reconnect recovery never runs for chats started from the home page (#26913)
When a chat is started from the home page, the URL is switched to /c/{id} with
history.replaceState, so the Chat component is never remounted and chatIdProp
stays empty for the lifetime of that view. Both websocket recovery paths,
handleSocketConnect and the chat:active fallback, are gated on chatIdProp and
therefore never run for these chats. After any websocket drop during a response
(mobile backgrounding, VPN or IP change, wake from sleep) the completed response
is never fetched and the chat stays stuck in a loading state until a manual page
refresh. Chats opened directly via /c/{id} recover fine, which is why the bug
only reproduces reliably on freshly started chats.

Gate both recovery paths on the chatId store instead, which is set for every
persisted chat, and let loadChat fall back to it so the recovery reload also
works when chatIdProp is empty. Chats opened via /c/{id} behave exactly as
before and temporary chats stay excluded.

The same gate likely explains the remaining reports in #26315.

Fixes #26844
2026-07-10 13:30:55 -05:00
Classic298
65a5fad7b9
fix: gate allow-same-origin on the terminal file-preview iframe to prevent same-origin XSS (#26907)
The system-terminal HTML file preview (FilePreview.svelte, serveUrl branch) rendered served HTML in an iframe that hardcoded allow-same-origin. The terminal proxy serves the file root-relative (same origin as the app) and injects no CSP, and there is no default global CSP, so script in a previewed HTML file executed in the application's own origin and could read localStorage (the session token), enabling account takeover and, for admin or workspace.functions victims, server-side RCE via Functions. The sibling srcdoc branch already gates allow-same-origin behind the iframeSandboxAllowSameOrigin setting (off by default) and injects a CSP; the serveUrl branch never received that defense. Gate allow-same-origin on the serveUrl branch identically, so by default the preview runs at an opaque origin and its scripts cannot reach the parent context. Legitimate HTML preview rendering is unaffected.

Co-authored-by: manus-use <manus-use@users.noreply.github.com>
2026-07-10 13:30:18 -05:00
Classic298
f4a6ea9300
fix: Milvus multitenancy scalar index creation on Milvus Lite (#26911)
Enabling ENABLE_MILVUS_MULTITENANCY_MODE with the default MILVUS_URI (embedded Milvus Lite at DATA_DIR/vector_db/milvus.db) fails on the first embedding write: _create_shared_collection calls collection.create_index(RESOURCE_ID_FIELD) with no index params. A Milvus server auto-selects a scalar index type in that case, but Milvus Lite rejects the call with "create_index missing required 'index_type' parameter", so shared collection creation raises and every embedding write 500s (memory add, file upload, knowledge writes).

Keep the parameterless call as the first attempt so behavior on Milvus servers is unchanged, fall back to an explicit INVERTED scalar index, and if that also fails log a warning and continue. The scalar index only accelerates resource_id filters; inserts and filtered queries work without it, so a missing index must not break collection creation.

Verified against embedded Milvus Lite: shared collections now create (with the warning), and memory add, file upload and memory query succeed end to end. Against a Milvus server the first attempt is identical to the current code, so nothing changes where it works today.
2026-07-10 13:29:29 -05:00
Classic298
0f8846b7fc
fix: convert SecurityHeadersMiddleware to pure ASGI (#26924)
SecurityHeadersMiddleware was the last middleware in the stack still
subclassing BaseHTTPMiddleware, after CommitSession, AuthToken,
WebsocketUpgradeGuard and Redirect were all moved to pure ASGI in
utils/asgi_middleware.py. BaseHTTPMiddleware re-buffers the response
body through an anyio task group, which has known issues with
streaming and Content-Length-bearing responses (e.g. the FileResponse
returned by /api/v1/audio/speech).

Reimplement it as a pure-ASGI middleware that stamps the configured
security headers onto the http.response.start message via
MutableHeaders and forwards all body chunks untouched, matching the
pattern already used by its four siblings. set_security_headers() and
all its helpers are unchanged.

Co-authored-by: classic298 <classic298@users.noreply.github.com>
2026-07-10 13:29:14 -05:00
Classic298
42f5c3d6f7
Merge pull request #26914 from Classic298/srcdoc-embed-prompt-confirmation
fix: restore prompt confirmation for sandboxed tool result embeds
2026-07-10 13:28:39 -05:00
Timothy Jaeryang Baek
b854389951 refac 2026-07-09 18:46:40 -05:00
Timothy Jaeryang Baek
6e030e892b refac 2026-07-09 18:28:38 -05:00
Timothy Jaeryang Baek
5fe525b8e0 refac 2026-07-09 18:02:37 -05:00
Timothy Jaeryang Baek
f5b196c060 refac 2026-07-09 17:59:17 -05:00
Timothy Jaeryang Baek
247b866330 refac 2026-07-09 17:44:40 -05:00
Timothy Jaeryang Baek
285379d489 refac 2026-07-09 17:43:13 -05:00
Timothy Jaeryang Baek
5ab012e7ae refac 2026-07-09 17:38:52 -05:00
Timothy Jaeryang Baek
d3ea8eb7e7 refac 2026-07-09 17:38:19 -05:00
Timothy Jaeryang Baek
6be9d1e760 refac 2026-07-09 17:37:47 -05:00
Timothy Jaeryang Baek
5e1a337d6e refac 2026-07-09 17:37:43 -05:00
Timothy Jaeryang Baek
31996a5acf refac 2026-07-09 17:37:31 -05:00
Timothy Jaeryang Baek
c89b6c50bc refac 2026-07-09 17:37:17 -05:00
Timothy Jaeryang Baek
0a8492b15d refac 2026-07-09 17:37:12 -05:00
Timothy Jaeryang Baek
9951fbe549 refac 2026-07-09 17:37:04 -05:00
Timothy Jaeryang Baek
5c389ad93f refac 2026-07-09 17:33:30 -05:00
Timothy Jaeryang Baek
975f7b868a refac 2026-07-09 17:32:45 -05:00
Timothy Jaeryang Baek
ef8630d556 refac 2026-07-09 17:31:43 -05:00
Timothy Jaeryang Baek
252e6fd855 refac 2026-07-09 17:30:02 -05:00
Timothy Jaeryang Baek
951f96021a refac 2026-07-09 17:29:51 -05:00
Timothy Jaeryang Baek
db802e28d3 refac 2026-07-09 17:29:07 -05:00
Timothy Jaeryang Baek
a489e4f219 refac 2026-07-09 17:28:45 -05:00
Timothy Jaeryang Baek
8e46450acd refac 2026-07-09 17:28:34 -05:00
Timothy Jaeryang Baek
bd6e0b61c2 refac 2026-07-09 17:28:05 -05:00
Timothy Jaeryang Baek
44c2a27ce0 refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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 / notify-helm-charts (push) Blocked by required conditions
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
2026-07-09 16:20:19 -05:00
Timothy Jaeryang Baek
10724d057a refac
Some checks failed
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
2026-07-02 12:38:22 -05:00
Tim Baek
ecd48e2f71
0.10.2 (#26642)
Some checks failed
Release to PyPI / release (push) Has been cancelled
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) Has been cancelled
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Release / publish (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
* i18n: add pt-BR translations for newly added UI items and consistency pass (#26391)

New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.

* refac

* i18n(th-TH): translate missing Thai keys/fix typo (#26406)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: use updated_at for sidebar chat timestamp (#26454)

The sidebar time-ago indicator rendered `created_at`, so the relative time stayed pinned to the chat's creation age and never reflected new activity. After sending a message the chat would jump to the top of the list (which sorts by `updated_at`) while still showing a stale label such as "3w", which is confusing.

The indicator was originally added using `updated_at` and was inadvertently switched to `created_at` during a later refactor. Restore `updated_at` (falling back to `created_at` when absent) so the timestamp matches the list ordering and updates whenever a chat is modified.

Fixes #26451

* fix: use absolute indexURL for pyodide sandbox (#26625)

* i18n: fix Spanish relative time labels (#26463)

* Update and fix Catalan translation.json (#26409)

* refac

* refac

Co-Authored-By: Syed Osama Ali Shah <86572800+osamaali313@users.noreply.github.com>

* refac

Co-Authored-By: Syed Osama Ali Shah <86572800+osamaali313@users.noreply.github.com>

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: derive content from output for search (#26405)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* Update CHANGELOG.md (#26641)

* Update CHANGELOG.md

* Update CHANGELOG.md

---------

Co-authored-by: Tim Baek <tim@openwebui.com>

---------

Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Sicknine <156204309+SNaytiP@users.noreply.github.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: JuanMa Diaz <torgus@gmail.com>
Co-authored-by: Aleix Dorca <aleixdorca@mac.com>
Co-authored-by: Syed Osama Ali Shah <86572800+osamaali313@users.noreply.github.com>
2026-07-01 03:40:54 -05:00
Classic298
4b55e69640
Update CHANGELOG.md (#26641)
Some checks failed
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 / notify-helm-charts (push) Blocked by required conditions
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 / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Frontend Build / Format & Build (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/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 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/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 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
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Unit Tests (push) Waiting to run
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
* Update CHANGELOG.md

* Update CHANGELOG.md

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
2026-07-01 03:38:33 -05:00
Timothy Jaeryang Baek
90eca2ac25 refac 2026-07-01 03:37:35 -05:00
Timothy Jaeryang Baek
2e5b094cea refac 2026-07-01 03:36:30 -05:00
Timothy Jaeryang Baek
80af65c24a refac 2026-07-01 03:35:46 -05:00
Timothy Jaeryang Baek
7f182ea063 refac 2026-07-01 03:35:27 -05:00
Timothy Jaeryang Baek
54f31c630a refac 2026-07-01 03:32:12 -05:00
Timothy Jaeryang Baek
52ee5cb1b3 refac 2026-07-01 03:29:44 -05:00
Timothy Jaeryang Baek
4351c78b1e refac 2026-07-01 03:22:13 -05:00
Timothy Jaeryang Baek
fa2abe4cb6 refac 2026-07-01 03:17:06 -05:00
Timothy Jaeryang Baek
c98d8ecacc refac 2026-07-01 03:16:54 -05:00
Timothy Jaeryang Baek
092b5857bb refac 2026-07-01 03:09:26 -05:00
Algorithm5838
0e5540c2b2
fix: derive content from output for search (#26405) 2026-07-01 03:05:47 -05:00
Timothy Jaeryang Baek
73c0be8389 refac 2026-07-01 03:01:46 -05:00
Timothy Jaeryang Baek
645cd9327c refac 2026-07-01 02:59:14 -05:00
Timothy Jaeryang Baek
9562f036f8 refac 2026-07-01 02:57:29 -05:00
Timothy Jaeryang Baek
c416c6cad6 refac 2026-07-01 02:53:42 -05:00
Timothy Jaeryang Baek
4b08d65597 refac 2026-07-01 02:53:22 -05:00
Timothy Jaeryang Baek
0016266c06 refac 2026-07-01 02:49:33 -05:00
Timothy Jaeryang Baek
650b817925 refac 2026-07-01 02:49:28 -05:00
Timothy Jaeryang Baek
64b92ff08a refac 2026-07-01 02:48:29 -05:00
Timothy Jaeryang Baek
b6d4baeb7e refac 2026-07-01 02:28:22 -05:00
Timothy Jaeryang Baek
989c6c13f5 refac 2026-07-01 02:26:47 -05:00
Timothy Jaeryang Baek
8fe480250f refac 2026-07-01 02:25:20 -05:00
Timothy Jaeryang Baek
ab22fe64bd refac
Co-Authored-By: Syed Osama Ali Shah <86572800+osamaali313@users.noreply.github.com>
2026-07-01 02:20:50 -05:00
Timothy Jaeryang Baek
688bda09fb refac
Co-Authored-By: Syed Osama Ali Shah <86572800+osamaali313@users.noreply.github.com>
2026-07-01 02:20:16 -05:00
Timothy Jaeryang Baek
19d8f03bd2 refac 2026-07-01 02:15:16 -05:00
Aleix Dorca
9ed7af823c
Update and fix Catalan translation.json (#26409) 2026-07-01 02:11:24 -05:00
JuanMa Diaz
980786f574
i18n: fix Spanish relative time labels (#26463) 2026-07-01 02:10:37 -05:00
Algorithm5838
a78f0c0302
fix: use absolute indexURL for pyodide sandbox (#26625) 2026-07-01 02:09:19 -05:00
Classic298
2d7fc04bfb
fix: use updated_at for sidebar chat timestamp (#26454)
The sidebar time-ago indicator rendered `created_at`, so the relative time stayed pinned to the chat's creation age and never reflected new activity. After sending a message the chat would jump to the top of the list (which sorts by `updated_at`) while still showing a stale label such as "3w", which is confusing.

The indicator was originally added using `updated_at` and was inadvertently switched to `created_at` during a later refactor. Restore `updated_at` (falling back to `created_at` when absent) so the timestamp matches the list ordering and updates whenever a chat is modified.

Fixes #26451
2026-07-01 02:08:56 -05:00
Timothy Jaeryang Baek
9866a02863 refac 2026-07-01 02:08:34 -05:00
Timothy Jaeryang Baek
2ed8934f5b refac 2026-07-01 02:05:14 -05:00
Timothy Jaeryang Baek
56ee875e21 refac 2026-07-01 01:46:36 -05:00
Timothy Jaeryang Baek
0b0eec05f8 refac 2026-07-01 00:43:00 -05:00
Timothy Jaeryang Baek
58ef80d8f7 refac 2026-07-01 00:10:23 -05:00
Timothy Jaeryang Baek
af1c0eee89 refac 2026-06-30 20:20:15 -05:00
Timothy Jaeryang Baek
0b75445ff9 refac 2026-06-30 20:20:10 -05:00
Timothy Jaeryang Baek
139206f0fe refac 2026-06-30 16:59:02 -05:00
Timothy Jaeryang Baek
6ed56b07c4 refac 2026-06-30 16:55:25 -05:00
Sicknine
1014c212a4
i18n(th-TH): translate missing Thai keys/fix typo (#26406) 2026-06-30 15:18:18 -05:00
Timothy Jaeryang Baek
4067e357b2 refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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 / notify-helm-charts (push) Blocked by required conditions
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-06-29 21:31:49 -05:00
joaoback
651a02ed2f
i18n: add pt-BR translations for newly added UI items and consistency pass (#26391)
New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.
2026-06-29 21:14:01 -05:00
Tim Baek
b711935dd5
Merge pull request #26388 from open-webui/dev
Some checks failed
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Release to PyPI / release (push) Has been cancelled
Release / publish (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
0.10.1
2026-06-29 14:38:32 -05:00
Timothy Jaeryang Baek
86231e4438 refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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 / notify-helm-charts (push) Blocked by required conditions
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
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-06-29 14:35:39 -05:00
Timothy Jaeryang Baek
f6baa1bb77 refac 2026-06-29 14:34:22 -05:00
771 changed files with 121052 additions and 50076 deletions

View file

@ -13,6 +13,18 @@ OPENAI_API_KEY=''
# CORS_ALLOW_ORIGIN='http://localhost:5173;http://localhost:8080'
CORS_ALLOW_ORIGIN='*'
# Set to false to keep memory tools enabled without adding memory context to the system context.
ENABLE_MEMORY_SYSTEM_CONTEXT=true
# Set to true to add compact row/column stats to parsed CSV retrieval context.
ENABLE_RAG_CSV_SUMMARY=false
# Set to true to preserve backing file records, storage blobs, and per-file vectors when files are removed from knowledge bases.
ENABLE_KNOWLEDGE_FILE_RETENTION=false
# Set to false to disable workspace Tools and Functions.
ENABLE_PLUGINS=true
# For production you should set this to match the proxy configuration (127.0.0.1)
FORWARDED_ALLOW_IPS='*'

2
.github/FUNDING.yml vendored
View file

@ -1 +1 @@
github: tjbck
github: open-webui

View file

@ -2,7 +2,6 @@ name: Bug Report
description: Create a detailed bug report to help us improve Open WebUI.
title: 'issue: '
labels: ['bug', 'triage']
assignees: []
body:
- type: markdown
attributes:
@ -35,6 +34,8 @@ body:
required: true
- label: I have also searched in the CLOSED issues AND CLOSED discussions and found no related items (your issue might already be addressed on the development branch!).
required: true
- label: I have checked whether this issue is already fixed on the `dev` branch or in the latest source.
required: true
- label: I am using the latest version of Open WebUI.
required: true
@ -55,7 +56,7 @@ body:
id: open-webui-version
attributes:
label: Open WebUI Version
description: Specify the version (e.g., v0.6.26)
description: Specify the version (e.g., v0.11.0)
validations:
required: true
@ -63,7 +64,7 @@ body:
id: ollama-version
attributes:
label: Ollama Version (if applicable)
description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1)
description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0)
validations:
required: false
@ -71,7 +72,7 @@ body:
id: operating-system
attributes:
label: Operating System
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13)
validations:
required: true
@ -79,7 +80,7 @@ body:
id: browser
attributes:
label: Browser (if applicable)
description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0)
description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3)
validations:
required: false
@ -138,11 +139,11 @@ body:
placeholder: |
Example (include every detail):
1. Start with a clean Ubuntu 22.04 install.
2. Install Docker v24.0.5 and start the service.
1. Start with a clean Ubuntu 26.04 install.
2. Install Docker v29.7.1 and start the service.
3. Clone the Open WebUI repo (git clone ...).
4. Use the Docker Compose file without modifications.
5. Open browser Chrome 115.0 in incognito mode.
5. Open browser Chrome 151.0 in incognito mode.
6. Go to http://localhost:8080 and log in with user "test@example.com".
7. Set the language to "English" and theme to "Dark".
8. Attempt to connect to Ollama at "http://localhost:11434".

View file

@ -36,6 +36,8 @@ body:
options:
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
required: true
- label: I have checked whether this request is already implemented on the `dev` branch or in the latest source.
required: true
- type: checkboxes
id: feature-scope

View file

@ -1,30 +1,55 @@
<!--
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
2. First-time contributors should not open pull requests directly unless the pull request contains only i18n/localization updates.
Do not open a PR as the first step.
For real, reproducible bugs, start with a well-described Issue that explains the problem, why it matters, and what outcome you are looking for.
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active Discussion.
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
We may close unsolicited PRs without review.
Contributors with a history of successful merged PRs may be given more latitude.
3. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
-->
# Pull Request Checklist
### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) to discuss your idea/fix with the community before creating a pull request, and describe your changes before submitting a pull request.
### Do not open a pull request as the first step.
This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR.
For real, reproducible bugs, start with a well-described [Issue](https://github.com/open-webui/open-webui/issues) that explains the problem, why it matters, and what outcome you are looking for.
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions). Merely opening a discussion is not enough; it needs to be actively discussed.
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
Unsolicited PRs may be closed without review. Contributors with a history of successful merged PRs may be given more latitude.
<!--
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
We appreciate thoughtful contributions. Pull requests are for implementation-ready changes that have already been requested, confirmed, or actively discussed in a linked Issue or Discussion. Feature ideas, behavior changes, UI/UX changes, architecture changes, suspected fixes, and unconfirmed approaches should start as an Issue or Discussion instead.
Before opening a PR, make sure the change has a clear linked problem, follows nearby patterns, has been manually tested, and accounts for related or downstream behavior. PRs that are ideas, prototypes, unresolved design questions, unchecked AI-generated code, symptom-only patches, one-off patches, or changes where affected paths have not been checked will usually be closed.
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
-->
**Before submitting, make sure you've checked the following:**
**Before submitting, make sure you've checked and filled out the following:**
- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. If one does not exist, create one first. PRs without a linked issue or discussion may be closed without review.
- [ ] **Linked Issue/Discussion:** This PR references an existing, well-described [Issue](https://github.com/open-webui/open-webui/issues) for a real bug or an active, substantive [Discussion](https://github.com/open-webui/open-webui/discussions) for a feature request or enhancement — `Closes #___` / `Relates to #___`.
- [ ] **First-time contributor policy:** This is not my first contribution to Open WebUI, this PR contains only i18n/localization updates, or a maintainer explicitly asked me to open this PR after reviewing the linked Issue or Discussion.
- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
- [ ] **Description:** A concise description of the changes is provided below.
- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom.
- [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs).
- [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented.
- [ ] **Testing:** Manual tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
- [ ] **Testing:** **Manual** end-to-end tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
- [ ] **User-facing changes:** I have confirmed whether this PR changes the UI. If it does, screenshots are required, and a video recording is recommended.
- [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately.
- [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards.
- [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first.
@ -40,9 +65,6 @@ The most impactful way to contribute to Open WebUI is through well-written bug r
- **i18n**: Internationalization or localization changes
- **perf**: Performance improvements
- **refactor**: Code restructuring
- **style**: Formatting changes (whitespace, semicolons, etc.)
- **test**: Test additions or corrections
- **WIP**: Work in progress
# Changelog Entry
@ -86,7 +108,7 @@ The most impactful way to contribute to Open WebUI is through well-written bug r
### Screenshots or Videos
- [Attach relevant screenshots or videos demonstrating the changes]
- [Attach screenshots or videos for user-facing changes. For UI changes, screenshots are required, and a video recording is recommended.]
### Contributor License Agreement

View file

@ -75,6 +75,17 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Prepare CI Dockerfile
run: |
awk '
/^FROM --platform=\$BUILDPLATFORM node:/ {
print
print "ENV NODE_OPTIONS=\"--max-old-space-size=12288\""
next
}
{ print }
' Dockerfile > "${RUNNER_TEMP}/Dockerfile"
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
@ -115,6 +126,7 @@ jobs:
id: build
with:
context: .
file: ${{ runner.temp }}/Dockerfile
push: true
platforms: ${{ matrix.platform.arch }}
labels: ${{ steps.meta.outputs.labels }}

View file

@ -43,6 +43,8 @@ jobs:
- name: Production build
run: npm run build
env:
NODE_OPTIONS: --max-old-space-size=8192
# ── Vitest unit tests ────────────────────────────────────────────────────
unit-tests:

139
.github/workflows/issue-label.yaml vendored Normal file
View file

@ -0,0 +1,139 @@
name: Issue Labeler
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
label-bug-reports:
runs-on: ubuntu-latest
steps:
- name: Add "bug" label to unlabeled bug reports
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
// Web-form submissions already carry the label from the issue template
if (issue.labels.some((label) => label.name === 'bug')) {
return;
}
const isEdit = context.payload.action === 'edited';
const titleWasEdited = Boolean(context.payload.changes?.title);
if (isEdit && !titleWasEdited) {
return;
}
const title = issue.title ?? '';
const body = issue.body ?? '';
// Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..."
const bugLikeTitle = /^\s*(\[\s*(bug|issue|fix)\b[^\]]*\]|(bug|issue|fix)\s*[:/\-])/i.test(title);
// API/CLI-created issues that reproduce the bug report form structure.
// Only headings distinctive to the bug form (both are required fields there) —
// generic headings like "Expected Behavior" also appear in freeform feature requests.
const bugFormBody = /###\s*(Installation Method|Open WebUI Version)/i.test(body);
if (!bugLikeTitle && !bugFormBody) {
return;
}
if (isEdit) {
const events = await github.paginate(github.rest.issues.listEvents, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 100
});
const bugLabelWasRemoved = events.some(
(event) => event.event === 'unlabeled' && event.label?.name === 'bug'
);
if (bugLabelWasRemoved) {
return;
}
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['bug']
});
label-feature-requests:
runs-on: ubuntu-latest
steps:
- name: Add "enhancement" label to unlabeled feature requests
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
if (issue.labels.some((label) => label.name === 'enhancement')) {
return;
}
// A human (or the bug form) already classified this as a bug;
// do not stack a second, contradictory classification on it.
if (issue.labels.some((label) => label.name === 'bug')) {
return;
}
const isEdit = context.payload.action === 'edited';
const titleWasEdited = Boolean(context.payload.changes?.title);
if (isEdit && !titleWasEdited) {
return;
}
const title = issue.title ?? '';
const body = issue.body ?? '';
// Feature requests: "feat: ...", "feature: ...", "feature request: ...",
// "enhancement: ...", "enh: ...", "[Feature Request] ..." — the feature
// request form titles every submission "feat: ", so form submissions are
// covered by the same pattern.
const featureLikeTitle =
/^\s*(\[\s*(feat|feature|enhancement|enh)\b[^\]]*\]|(feat|feature( request)?|enhancement|enh)\s*[:/\-])/i.test(
title
);
// API/CLI-created issues that reproduce the feature request form structure.
// Only headings distinctive to that form.
const featureFormBody = /###\s*(Proposed Solution|Alternatives Considered)/i.test(body);
if (!featureLikeTitle && !featureFormBody) {
return;
}
if (isEdit) {
const events = await github.paginate(github.rest.issues.listEvents, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 100
});
const enhancementLabelWasRemoved = events.some(
(event) => event.event === 'unlabeled' && event.label?.name === 'enhancement'
);
if (enhancementLabelWasRemoved) {
return;
}
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['enhancement']
});

View file

@ -5,6 +5,657 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.11.1] - 2026-08-25
### Added
- 🚦 **Human in the loop tool approval.** Where an administrator has turned it on, you can switch a conversation from letting tools run freely to being asked first, so a model that wants to use a tool stops and waits for you to allow or deny it, one call at a time in a saved conversation, by button or by keyboard shortcut, with your choice remembered for this conversation and for future ones, switching back to running freely releasing anything already waiting, and automations, channel replies, and temporary chats unaffected. [Commit](https://github.com/open-webui/open-webui/commit/7d99b2716a0472b2100b3a71825d8eb3fcbbe877), [Commit](https://github.com/open-webui/open-webui/commit/ec36972c2b5a8d48713f1a240b0ed305e535b4cc), [Commit](https://github.com/open-webui/open-webui/commit/653562d660398c32a9a193450bbbee300d7195c3), [Commit](https://github.com/open-webui/open-webui/commit/fa94a5ab2431edba064150651a14dd4992ef29e8), [Commit](https://github.com/open-webui/open-webui/commit/55c202e841e76cd69679206cd0ecb4a65b039ea4), [Commit](https://github.com/open-webui/open-webui/commit/30f82788bc75e6965da3766e3bf43840dd971eed), [Commit](https://github.com/open-webui/open-webui/commit/bbfdbd59f29e246db1d8b5c2401bc7b11aa32c99), [Commit](https://github.com/open-webui/open-webui/commit/7fc5fa1ff3f6cd6f0efcbf847064ca8d21620ba6), [Commit](https://github.com/open-webui/open-webui/commit/3eb65f47151af4fb4ccfaf33d6e77154546e7259), [Commit](https://github.com/open-webui/open-webui/commit/62fc436999ad32e82d1405ac14d1f03e0f0341ec)
- 🙋‍♂️ **Models that can ask you a question.** A new built-in tool lets a model pause and put up to three multiple-choice questions to you before continuing, with room to type your own answer instead, and the question survives a reload in a saved conversation, so you can come back and answer it later rather than losing the conversation. [Commit](https://github.com/open-webui/open-webui/commit/4465f52a3eb521854cf190f91b0ea7cf3fa21830), [Commit](https://github.com/open-webui/open-webui/commit/133549a87ee371577453d897c2db2d8071223f27), [Commit](https://github.com/open-webui/open-webui/commit/b018feb7419e68314378b3cdd8b7b1b389400a98), [Commit](https://github.com/open-webui/open-webui/commit/083e35144152d6a301bed01aa6d898db3871ddcf), [Commit](https://github.com/open-webui/open-webui/commit/256cce505be8ddd4930e8cb2536faea718d3f789), [Commit](https://github.com/open-webui/open-webui/commit/14e4d72d9a21a10196e8e6efb04180cd9104b8e6), [Commit](https://github.com/open-webui/open-webui/commit/57bd08304e45f707768a898de9f50894929008dd), [Commit](https://github.com/open-webui/open-webui/commit/d9014b3483d4c6e8d99e50395afcdc538bcf05cd)
- 🖇 **Agents can now display terminal files directly.** A model can now show a file it made in a terminal directly in its reply, with a preview and a download button, instead of describing a path that led nowhere when clicked, and a new interface setting chooses whether these open in the reply or in the files pane. [Commit](https://github.com/open-webui/open-webui/commit/78f48a21eef330c0b78c33f2b5fc2169b084997c), [Commit](https://github.com/open-webui/open-webui/commit/e623c02acc70c4ee5d7f2eb2f32d9b7f39287663), [Commit](https://github.com/open-webui/open-webui/commit/f64c0c87e8d1bfdbe060ea5e5a3dee24c0323657), [#27650](https://github.com/open-webui/open-webui/issues/27650)
- 📶 **Streaming rebuilt from the ground up.** A reply now streams as small pieces of new text instead of resending the whole message so far with every update, so the data sent over a reply grows with its length rather than with its length squared, which on a server with many people chatting at once means far less processor time spent encoding, passing, and decoding those updates, far less load and memory on the shared cache that carries them between instances, and far less work in your browser, which no longer takes in the whole reply again and redraws the parts of it that have not changed on every update, cutting the data sent and the server work spent appending to a message by up to 1000x on a very long reply, and a reply still in progress is now kept aside on the server, so reopening the conversation after a refresh picks it up where it is rather than showing a blank message, on deployments backed by Redis. [Commit](https://github.com/open-webui/open-webui/commit/a1579a01ff43cacb357269707d36267ad35e01d6), [Commit](https://github.com/open-webui/open-webui/commit/c755ef60c6bd47ea25306bd898d9a6d1bd8e871d), [Commit](https://github.com/open-webui/open-webui/commit/d02b6a21fc02fb073782e25356968cfee3c45c36), [Commit](https://github.com/open-webui/open-webui/commit/3e186abdd91edee9e97e43c9b345714643a84cf5)
- 🪵 **Much faster throughout.** Hundreds of places across the application no longer assemble detailed log text that is switched off and thrown away unread, so sending messages, uploading and indexing files, running searches, signing in, and loading admin pages all get that time back, with the largest gains on busy servers, in long conversations, and on chats that draw from a large knowledge base. [#27834](https://github.com/open-webui/open-webui/pull/27834), [#27837](https://github.com/open-webui/open-webui/pull/27837)
- 🚀 **Faster model list lookups.** Title generation, tag suggestions, autocomplete, and other background steps of a chat turn now fetch the model list in one go, which keeps other people's responses flowing on busy Redis-backed instances with many models. [#27821](https://github.com/open-webui/open-webui/pull/27821)
- 🛰️ **Cheaper log export.** Deployments that export their logs to a telemetry collector, with "ENABLE_OTEL" and "ENABLE_OTEL_LOGS" both set, now prepare each exported line once instead of twice, which matters more than it used to now that log text is only assembled when something will actually read it. [#27840](https://github.com/open-webui/open-webui/pull/27840)
- 📇 **Faster permission checks on large instances.** Working out which groups you belong to is now a direct lookup rather than a scan of every membership on the server, so chats and the admin user list stay quick as an organization grows. [#27822](https://github.com/open-webui/open-webui/pull/27822)
- ⚙️ **Much faster JSON handling.** Saving and opening chats, reading settings, returning results from built-in tools, streaming replies, signing in and signing up, working out your permissions, and reading stored chunk details during knowledge base searches on Valkey and Oracle vector storage are all handled much faster across the application when the "ENABLE_ORJSON" option is turned on. [Commit](https://github.com/open-webui/open-webui/commit/bb0f898b431d5aa45efa7805956657ed9c3dd78d), [#28396](https://github.com/open-webui/open-webui/pull/28396), [#27841](https://github.com/open-webui/open-webui/pull/27841), [#27807](https://github.com/open-webui/open-webui/pull/27807), [#27805](https://github.com/open-webui/open-webui/pull/27805), [#27813](https://github.com/open-webui/open-webui/pull/27813)
- 📤 **Much faster outbound requests.** Conversations and embedding batches sent to Ollama and Anthropic models are packaged for delivery much faster, which is most noticeable in long chats when the "ENABLE_ORJSON" option is turned on. [#27811](https://github.com/open-webui/open-webui/pull/27811), [#27810](https://github.com/open-webui/open-webui/pull/27810)
- 🐍 **Much faster code interpreter output.** Printed output and generated images from code run in chat appear much faster when the "ENABLE_ORJSON" option is turned on. [#27812](https://github.com/open-webui/open-webui/pull/27812)
- 🪶 **Lighter page loads.** Several small requests the interface makes on every page load, along with a few administrative ones, no longer set up database access they never used, which took several times longer than the rest of the request put together. [#28178](https://github.com/open-webui/open-webui/pull/28178)
- ♻️ **One less read per message.** Sending a message no longer loads the whole conversation from the database twice over, which mattered most in long chats where that record is largest. [#28809](https://github.com/open-webui/open-webui/pull/28809)
- 🏁 **Faster skills on large instances.** Opening the skills list, or sending a message that uses one, no longer checks every skill on the instance one at a time, so both are far quicker where many skills exist and most of them are not yours. [#28798](https://github.com/open-webui/open-webui/pull/28798)
- 🩻 **Faster tools on large instances.** Listing or exporting tools no longer checks every tool on the instance one at a time, so the integrations menu and the tools workspace open faster where many exist. [Commit](https://github.com/open-webui/open-webui/commit/4807866a1cf47340f1b5ea76fded95f8114305f9)
- 🧊 **Faster file access checks.** Checking whether you may reach a file no longer walks every workspace model you can see looking for it, so opening a folder of files, downloading one, or retrieving from one is much quicker on instances with many models. [#28802](https://github.com/open-webui/open-webui/pull/28802)
- 🧱 **Faster folder listings.** Listing your folders now works out your group memberships once for the whole listing rather than again for every item in every folder. [#28810](https://github.com/open-webui/open-webui/pull/28810)
- 🧼 **Less work per update in a long chat.** Each update saved while a reply streams no longer re-examines the entire conversation, only the part being added, so the cost of an update stops growing with the length of the chat. [#28820](https://github.com/open-webui/open-webui/pull/28820)
- 🗃 **Cheaper attaching of sources and files to a reply.** Adding a source, file, or embedded item to a reply now reads just that one field rather than rebuilding the whole conversation to find it, which on a two hundred message chat is around 3.1 ms per item down to 0.65 ms, and no longer grows with the length of the conversation. [Commit](https://github.com/open-webui/open-webui/commit/536b9edec00547d5b84ef2e6ea0f929c054c1333), [Commit](https://github.com/open-webui/open-webui/commit/9dff5e93277aa1a2236d702e7e2f55dbb35ea7fa)
- 🚏 **Faster workspace model lookups.** Working out which workspace models you may edit no longer loads every model on the instance and discards most of them, which also speeds up exporting models and the file access checks that relied on it. [#28795](https://github.com/open-webui/open-webui/pull/28795)
- 📮 **Faster handing off a streaming reply.** Passing a reply in progress between instances now writes it once rather than converting it back and forth and scanning it for characters that only matter elsewhere, which on a large non-English conversation took most of the time spent on each write. [#28833](https://github.com/open-webui/open-webui/pull/28833)
- 🥵 **Constant load on an idle instance.** An instance sitting idle no longer works through every chat you have once a second looking for timers that are due, which on a large history kept about a quarter of a processor core busy doing nothing and could exhaust memory until the application was killed. [#27663](https://github.com/open-webui/open-webui/pull/27663), [#27622](https://github.com/open-webui/open-webui/issues/27622), [#27745](https://github.com/open-webui/open-webui/issues/27745)
- 📍 **Sidebar folders fetched once.** Refreshing the sidebar now asks for your folders once rather than three times, on page load and on every action that refreshes it. [#28662](https://github.com/open-webui/open-webui/pull/28662), [#28661](https://github.com/open-webui/open-webui/issues/28661)
- 💤 **Far fewer writes just from being signed in.** Recording that someone is online now writes at most once a minute for each person rather than on every single request, where an open tab alone caused two write transactions a minute before anyone touched anything. [#28177](https://github.com/open-webui/open-webui/pull/28177), [#28165](https://github.com/open-webui/open-webui/issues/28165)
- 🛰 **Less overhead on every request.** The layers each request passes through before it is handled are now one instead of five, which also removes a quarter of that cost from every piece of a streamed reply on instances that set security headers. [Commit](https://github.com/open-webui/open-webui/commit/b96d2b12dae5e953e520bec03f74e9b85b955dc7), [#28171](https://github.com/open-webui/open-webui/issues/28171)
- 🎏 **Turning off compression of live updates.** A new "UVICORN_WS_PER_MESSAGE_DEFLATE" setting stops the server compressing every live update it sends, which costs processor time on each one for almost no saving now that a reply streams as small pieces; compression stays on unless it is turned off. [#28613](https://github.com/open-webui/open-webui/pull/28613)
- 🌡 **Faster chat list and unread counts.** Opening the sidebar, and the unread markers on folders, no longer read through your whole chat history to produce a short list, which on an instance with 15000 chats took 2 to 4 seconds. [#27663](https://github.com/open-webui/open-webui/pull/27663), [#27622](https://github.com/open-webui/open-webui/issues/27622), [#27745](https://github.com/open-webui/open-webui/issues/27745)
- 🥁 **Long replies no longer slow as they grow.** A long reply is no longer re-examined from the beginning for reasoning and code blocks on every piece that arrives, so the work stops growing with the length of the reply, which on a long reply is around 190x less time spent on it. [#28861](https://github.com/open-webui/open-webui/pull/28861)
- 💽 **Faster saving of long chats.** A chat is now written to the database in one go rather than one message at a time, so saving a long conversation is much quicker and puts far less strain on the database, and saving one where nothing has changed writes nothing at all. [#28806](https://github.com/open-webui/open-webui/pull/28806)
- 📦 **Faster loading of shared folders.** Folders shared with you now load in a couple of queries rather than one for each folder and each owner, so the list appears sooner for anyone with many of them. [#28804](https://github.com/open-webui/open-webui/pull/28804)
- ⚡ **Uninterrupted chat during knowledge search.** Responses now keep streaming for everyone on the server while knowledge base searches run, instead of pausing until each search finishes. [#27824](https://github.com/open-webui/open-webui/pull/27824)
- 🔍 **Smarter chat search.** Searching your chats now finds conversations containing all of your words in any order rather than only the exact phrase you typed, with exact matches still listed first, and the preview snippet points at whichever word it found. [Commit](https://github.com/open-webui/open-webui/commit/0800c21c64c64810f24c2ec88cca5e36daebb10e)
- ⌨️ **Model switching from the message box.** Typing "/model" now tells you which model you are on, switching to another by name with "/model" followed by its id, or opening the model picker straight from the slash menu without reaching for the mouse. [Commit](https://github.com/open-webui/open-webui/commit/29eeda9f9abeaedf77176908cb036ace7f175e76), [Commit](https://github.com/open-webui/open-webui/commit/9c7ce154e79a3baea3a8223ab1e5b1371ce80dbb)
- 📎 **Sending while attachments upload.** Sending a message before its files have finished uploading now queues it and sends it automatically once they are ready, instead of refusing with an error, and each queued message shows the progress of its attachments. [Commit](https://github.com/open-webui/open-webui/commit/6c4d0ace163a89aba6e8cd2aa9fef185167ebe0f), [Commit](https://github.com/open-webui/open-webui/commit/c1c07cbe0f847e4dd04e21f2a6c5f12cdc0fc7ee), [#28381](https://github.com/open-webui/open-webui/pull/28381), [#28380](https://github.com/open-webui/open-webui/issues/28380)
- 📖 **Opening a document at the right page.** A model showing you a PDF, Word document, or slide deck from a terminal can now open it at a particular page or slide, so a reply that cites something on page 76 can put that page in front of you. [Commit](https://github.com/open-webui/open-webui/commit/fd8cc2ba4a226ccbf51e114c71f156676eabb2d9), [Commit](https://github.com/open-webui/open-webui/commit/cf4ac9c8db67031627f196fae894764367fba5b4), [Commit](https://github.com/open-webui/open-webui/commit/6cb2449ab70ed6aa5124fcd2b1291b48527af4cd)
- 💼 **Attachments that go straight to a terminal.** A terminal connection can now be set to receive files attached in chat into its own working directory rather than into the conversation, which also means files can be attached while using a model that cannot read them itself. [Commit](https://github.com/open-webui/open-webui/commit/8a42aa53e826d5f25d6443ca95e0173e3cf034cf), [Commit](https://github.com/open-webui/open-webui/commit/d7d935275a77fb185586a5130cd37acf1175ca0a), [Commit](https://github.com/open-webui/open-webui/commit/1b3b9375bb4de0f360782dd2f9d8c7c2903b8baa), [Commit](https://github.com/open-webui/open-webui/commit/d17f06a23501e5c39cf98d9c9642178da25343fb)
- 🔦 **Searching files in the terminal browser.** The file browser now has a search box that finds files by name and by what is inside them, and opening a result takes you to the matching line. [Commit](https://github.com/open-webui/open-webui/commit/7abe11346a1cc309da2c3fcec2f61faaeb47b96a)
- 🌲 **Browsing files as a tree.** The terminal file browser now expands folders in place rather than only navigating into them, remembers what you had open, offers a right-click menu, can show hidden files, sorts by size, expands a folder you hover over while dragging something onto it, and moves a whole selection in one go when you drop it. [Commit](https://github.com/open-webui/open-webui/commit/7dfbdd221ac0f7a839dd049e3e82b6a84c316860), [Commit](https://github.com/open-webui/open-webui/commit/516cf1a9a6154336182475b9df2d333b1579516d)
- 🧰 **Managing models on more servers.** Administrators can now download, load, and unload models on llama.cpp and LM Studio connections from the manage models dialog, remove them on llama.cpp, and start a download straight from the model picker's search box, alongside the Ollama support that was already there. [Commit](https://github.com/open-webui/open-webui/commit/85c3d0ae2fad58ffc2b92b1733a6c8bc7ab471db), [Commit](https://github.com/open-webui/open-webui/commit/8260d527ee97372a207ce9bd9c6dab4909061281), [Commit](https://github.com/open-webui/open-webui/commit/25802c048e6123fa602182949ad2a9f349e1e863), [Commit](https://github.com/open-webui/open-webui/commit/31c1ffd55a018a74c7e53a35fb9f9bbe33774b6f), [#28766](https://github.com/open-webui/open-webui/pull/28766)
- 📢 **Automations that post to a channel.** An automation can now be pointed at a channel instead of a chat, so its scheduled run appears as a message there for everyone to see, chosen from a new destination picker that also covers folders. [Commit](https://github.com/open-webui/open-webui/commit/2649e3305c49cb37101c112ae10ffd8beacb5885)
- 🙋 **Mentioning people in a channel.** Typing an at sign in a channel now lists that channel's own members first, before everyone else on the server, so the people you are likely to mean are at the top. [Commit](https://github.com/open-webui/open-webui/commit/ba885d0026ad3cce70c7cc36d1d6f59775264a12), [Commit](https://github.com/open-webui/open-webui/commit/dcff244f9e87e615ee0a3f345f7f801c5ebe370d), [#28883](https://github.com/open-webui/open-webui/issues/28883)
- 🔗 **Attaching any link.** Pasting a link into a chat or a knowledge base now works out what is behind it, downloading a document or image as a real attachment rather than treating everything as a web page to be read as text. [Commit](https://github.com/open-webui/open-webui/commit/8fbfd14a8b3fe7236883594fd18760076e4de0d5)
- 🔎 **Searching tools and skills in chat.** The integrations menu now has a search box for tools and for skills, so a long list can be narrowed by name instead of scrolled through. [#26709](https://github.com/open-webui/open-webui/issues/26709), [Commit](https://github.com/open-webui/open-webui/commit/954613944b317a78d5929456ae1388543aa052c8), [#28807](https://github.com/open-webui/open-webui/pull/28807), [#28812](https://github.com/open-webui/open-webui/pull/28812)
- 🧭 **More from the message box.** The slash menu now offers settings and, in a new chat, a toggle for temporary chat, alongside the commands that were already there. [Commit](https://github.com/open-webui/open-webui/commit/a5ea732c1e3d0d1f70a7e4b6a20c30c336114784)
- 🚨 **Being told when a file fails to process.** A file that cannot be processed for a knowledge base now raises a notification naming the file and what went wrong, and keeps that reason on the file, instead of quietly being marked as failed. [#27666](https://github.com/open-webui/open-webui/pull/27666), [#6311](https://github.com/open-webui/open-webui/issues/6311), [Commit](https://github.com/open-webui/open-webui/commit/1a376ac17fa0c3f957656a997b6f8ffacb1f6f30)
- 🔬 **Zoom controls on previewed images.** An image opened in the file browser now has zoom in, zoom out, and a reset button showing the current zoom, and pinching, scrolling, and holding a modifier key while scrolling now zoom and pan as they do elsewhere. [Commit](https://github.com/open-webui/open-webui/commit/2befa8f796266e92fa55861bb8eaa81639ff4053), [Commit](https://github.com/open-webui/open-webui/commit/bfb68feea766e8d5408fb6e278be56cbca0c4afe), [Commit](https://github.com/open-webui/open-webui/commit/ec9bf5a64f9f718e472123350ec83bce1b064884), [Commit](https://github.com/open-webui/open-webui/commit/467be93e6d7e4a31358f9c75ee67bfac1200c0a1)
- 🗂️ **Recognisable file icons.** The terminal file browser now marks each file with an icon for its type, so code, images, archives, documents, and configuration files can be told apart at a glance instead of sharing one generic page icon. [Commit](https://github.com/open-webui/open-webui/commit/60feca71a6a77b7d1eef1c172a61624d5fe48150), [Commit](https://github.com/open-webui/open-webui/commit/8d25ad00e2fc5f0a328a2098c2e73fb00f2ca934), [Commit](https://github.com/open-webui/open-webui/commit/c1f914a6268580f7722825390ac8a204743a4520)
- 📽️ **Truer PowerPoint previews.** Slide previews now render tables, charts, connectors, gradients, theme colours, bullets, fonts, and text alignment far closer to the original, and the viewer lets you move between slides with the arrow keys or the scroll wheel while the thumbnail strip follows along. [Commit](https://github.com/open-webui/open-webui/commit/8dd23f74c9a59fdfbdaefb4a403ef79bdd3dfed9), [Commit](https://github.com/open-webui/open-webui/commit/048c06399363baac4046717129f072172ee90805), [Commit](https://github.com/open-webui/open-webui/commit/c93c6d6fc48ee8870eec349cb4c5af88c9435cfb), [Commit](https://github.com/open-webui/open-webui/commit/76583749edb966c0239ce203a84c1ee8d8c7faee), [Commit](https://github.com/open-webui/open-webui/commit/b3a5fd3875dc3c29a11eb5435438e55685cad639), [Commit](https://github.com/open-webui/open-webui/commit/794671a9883d5e067c407067f324ffa25ed38e3f), [Commit](https://github.com/open-webui/open-webui/commit/f96b717566b2084bd5fe70fb622ab66b5a643c0d), [Commit](https://github.com/open-webui/open-webui/commit/b7292890ccea794750651abbcde01cda62551e1b), [Commit](https://github.com/open-webui/open-webui/commit/31d08d592c4ee46a7ce827d6ffdf8e3cf16bf2ac)
- 📄 **Faithful Word document previews.** Word documents now open as proper pages with headers, footers, footnotes, and embedded images intact, and can be zoomed, rather than being flattened into plain formatted text. [Commit](https://github.com/open-webui/open-webui/commit/ff7467b4c593f1e775311c088c63340d0cc1e4a2), [Commit](https://github.com/open-webui/open-webui/commit/060648f939447e2a9de390d05221ca896c50eeed)
- 🗝 **Deleting your API key.** An API key can now be revoked outright from your account settings, where the only way to retire one was to replace it with a new one. [#28874](https://github.com/open-webui/open-webui/issues/28874), [Commit](https://github.com/open-webui/open-webui/commit/b30b11d4c975b8ff9a6c6eb9e39fbb310aed1072)
- 🎛️ **Settings for the task model.** Administrators can now set the generation parameters used for background work such as titles, tags, follow-ups, search queries, and conversation summaries, either from the admin panel or through "TASK_MODEL_PARAMS", instead of those requests always using a fixed token limit that could cut a summary short. [#27604](https://github.com/open-webui/open-webui/issues/27604), [Commit](https://github.com/open-webui/open-webui/commit/f0bfcd40976dfb1e1876f86b39d6a659d894d15e), [Commit](https://github.com/open-webui/open-webui/commit/865c80c1600ecc2e606bef4e90540ce305b4b9de)
- 🎚️ **Default interface settings for everyone.** Administrators can now set system-wide defaults for the interface options in Settings, either from the admin panel or through "DEFAULT_INTERFACE_SETTINGS", with each person's own choices still taking precedence and anything left untouched shown as inherited and kept in step with later changes to the defaults. [Commit](https://github.com/open-webui/open-webui/commit/37f2548155efdd5cd8114ceb9b57c7864de4bef2), [Commit](https://github.com/open-webui/open-webui/commit/13346c5f1621b014e2fcaabd41375d6f7035dd0a), [Commit](https://github.com/open-webui/open-webui/commit/90a0e61cef119154e89d900d6921123b599a0389), [Commit](https://github.com/open-webui/open-webui/commit/eeaf1a1df01525e4b6d4eb73060b32a9a75ebfc6), [Commit](https://github.com/open-webui/open-webui/commit/d4461bd6f39936460a989e8bd50097de604b4b00), [Commit](https://github.com/open-webui/open-webui/commit/b4d3b27caf1587b783a46268c769132eb9211c80), [Commit](https://github.com/open-webui/open-webui/commit/407c40f72cdadcd9f57538d9ce38070662ce42c7), [Commit](https://github.com/open-webui/open-webui/commit/724d2ebbf1bd2fc90f27a2e5bc571d40afc3db4c), [Commit](https://github.com/open-webui/open-webui/commit/1674e5a9ef835696f0963cdbcf477c8b92ce0b8a)
- 🔠 **Interface scaling throughout.** The UI Scale setting and your browser's own text size now resize the whole interface consistently, including the sidebar, menus, dialogs, and file browser, rather than leaving parts of it fixed. [Commit](https://github.com/open-webui/open-webui/commit/30d08a42f8b0c32cc64dccd81203cc760651584a), [Commit](https://github.com/open-webui/open-webui/commit/b5f86e6a433b519e30a5a6f1b0a5bf985939c9a8), [Commit](https://github.com/open-webui/open-webui/commit/ec03e8814403422a6ab3dc62f4239fd7af439361), [Commit](https://github.com/open-webui/open-webui/commit/72a909fd2f3ac8d67554f6c85bc1723934454e2d)
- 🏷️ **Named writing blocks.** When a model wraps a draft such as an email in a writing block, the block is now titled with its subject and shows the recipient beside it, rather than every block reading simply as Writing. [#28280](https://github.com/open-webui/open-webui/pull/28280), [#28198](https://github.com/open-webui/open-webui/issues/28198)
- 🤝 **Files for delegated tasks.** A task handed to a sub-agent can now carry the attachments it needs, so an image or document from your conversation reaches the sub-agent instead of arriving as a file reference it cannot open and may answer about anyway. [Commit](https://github.com/open-webui/open-webui/commit/5ec16e76e6402980b39c26923b8ee26278f0b243), [#28213](https://github.com/open-webui/open-webui/issues/28213)
- 📟 **Terminal availability and scope.** Administrators can now decide for each managed terminal whether it appears in chats and in automations at all, and whether everyone shares a single workspace or each chat or automation gets its own, with per chat terminals waiting until the conversation has been saved. [Commit](https://github.com/open-webui/open-webui/commit/009999f3636b1a3451f8fe55232e6ab132a64e66)
- 🔒 **Read-only files in the terminal browser.** Files and folders you are not allowed to change are now labelled read-only, with uploading, editing, renaming, moving, and deleting turned off for them rather than failing at the moment you try. [Commit](https://github.com/open-webui/open-webui/commit/2dadc5435af77b0638af69200e3af1b9654417b6)
- 🔐 **Terminals that use your own login.** Managed terminals configured for session authentication now authenticate the terminal connection with your own token, where it previously sent no credentials at all. [Commit](https://github.com/open-webui/open-webui/commit/2dadc5435af77b0638af69200e3af1b9654417b6)
- 🎟 **Setting up a tool server that uses OAuth.** Adding one is now easier to get right: the connection dialog can authorize the account from the dialog itself, the check button tests the sign-in details rather than reporting a connection failure that was never going to succeed without them, and it is now labelled for what it does rather than suggesting it verifies the whole connection. [Commit](https://github.com/open-webui/open-webui/commit/f822605b3563c57030aa200492f68572cadcc4da), [#28552](https://github.com/open-webui/open-webui/issues/28552)
- 🪤 **Control over what embedded pages may do.** Two new interface settings decide whether pages shown inside a chat, such as an artifact or an HTML preview, may run scripts and start downloads. [Commit](https://github.com/open-webui/open-webui/commit/3c66d639e31ba8a7477d337b37d8d671ea2430cf), [#28924](https://github.com/open-webui/open-webui/issues/28924), [Commit](https://github.com/open-webui/open-webui/commit/842c1f9d677c0d9940cccdd18e34add13f746a68)
- 🗄 **Keeping files removed from a knowledge base.** A new "ENABLE_KNOWLEDGE_FILE_RETENTION" setting keeps the stored file and its search data when a file is taken out of a knowledge base, rather than deleting them. [Commit](https://github.com/open-webui/open-webui/commit/363ad352fec9553469852d111bc0506b896504a6)
- 🧾 **CSV shape in retrieval.** Turning on "ENABLE_RAG_CSV_SUMMARY" adds a short line naming the row count, data row count, column count, and column names of a CSV file to what the model sees, giving it the shape of the table alongside its contents. [Commit](https://github.com/open-webui/open-webui/commit/1b72899f246ba46ab8f7cd6aee2d56ef69217d82)
- 🔭 **OpenSERP in the search settings.** OpenSERP can now be picked as the web search engine in the admin panel, with a field for its address, rather than only being configurable through the environment. [#27594](https://github.com/open-webui/open-webui/pull/27594), [#27592](https://github.com/open-webui/open-webui/issues/27592)
- 🪧 **Profile changes from single sign-on.** A name, email address, or picture updated from an identity provider at sign-in now raises an event naming what changed, and the rest of the session uses the updated record rather than a stale copy. [Commit](https://github.com/open-webui/open-webui/commit/927ce0eae67af1b4a856d3e82fba0601c3b40d02)
- 📯 **Group changes from single sign-on.** Group memberships added or removed when someone signs in through an identity provider, and groups created automatically along the way, now raise the same events as the equivalent change made by an administrator or over directory sync. [#27657](https://github.com/open-webui/open-webui/pull/27657)
- 🔔 **Sign-in and sign-out events for single sign-on.** Signing in through an identity provider now raises the same login event that signing in with a password does, and signing out says which provider the session came from, so a function can set up or tidy up an account in another system when someone arrives or leaves. [#27619](https://github.com/open-webui/open-webui/pull/27619), [#27613](https://github.com/open-webui/open-webui/issues/27613)
- 🪛 **Naming background worker threads.** A new "THREAD_POOL_THREAD_NAME_PREFIX" setting labels the threads that background work runs in, so they can be told apart when reading a profile or a thread dump. [Commit](https://github.com/open-webui/open-webui/commit/4ec6ee14418edd04eaba9e34bd5868453f61df40)
- 📙 **OpenDocument files in a temporary chat.** A text document, spreadsheet, or presentation from an office suite that uses the OpenDocument format now has its text read out in the browser when attached to a temporary chat, where the model was handed the raw archive and answered that it could not read the file. [Commit](https://github.com/open-webui/open-webui/commit/9e7c9360b744c878ae0c38aa1caf3886f27b07ab), [#28906](https://github.com/open-webui/open-webui/discussions/28906)
- 🌍 **Pointing Tavily somewhere else.** A new "TAVILY_API_BASE_URL" setting sends Tavily searches and page fetches to a different address, for instances that reach the internet only through a gateway of their own or that use a compatible service. [Commit](https://github.com/open-webui/open-webui/commit/98ee2bdfd3e90faec6bfe8e7ebf5803159f379e3), [#28701](https://github.com/open-webui/open-webui/issues/28701)
- 🪟 **Honest OAuth settings.** When single sign-on settings come from the environment rather than being saved in the application, the admin panel now shows them as read-only with a note naming the setting that controls this, instead of accepting edits that were silently discarded on the next restart. [#28276](https://github.com/open-webui/open-webui/pull/28276)
- 📏 **Widening the chat controls pane.** The controls pane can now be dragged as wide as you like, where it stopped at a fixed limit regardless of screen size. [Commit](https://github.com/open-webui/open-webui/commit/0fb542b3764cefecf2366607a4685288051d1b46)
- 📱 **Smoother sidebar on mobile.** The sidebar now follows your finger as you swipe it open or closed, responds to a quick flick, dims the page behind it as it moves, and gives every chat row a menu button you can reach without a hover you cannot perform on a touchscreen. [Commit](https://github.com/open-webui/open-webui/commit/b20bcdbba72707e3b0cf2b9a6a5f3168b464ba3c), [Commit](https://github.com/open-webui/open-webui/commit/178ccb30e1253ea727fe5ccbb5cc3ca3832dc70b), [Commit](https://github.com/open-webui/open-webui/commit/943294df9a03d45e2708b330e876967af3463282), [Commit](https://github.com/open-webui/open-webui/commit/d6679082e5c6b0b54ca00a99a88e66a53a30c7d9), [Commit](https://github.com/open-webui/open-webui/commit/d8ae7ed40551362922925d9d6e47ba65d3658cfd), [Commit](https://github.com/open-webui/open-webui/commit/be4afd75452361ead376d9977cf2ca8cb93e55eb), [Commit](https://github.com/open-webui/open-webui/commit/ab41dcc487d1517f7c8c5d0b02a02cdaadedb03d)
- 🚪 **Sidebar that stays put.** Opening and closing the sidebar is now a smooth transition that keeps your chat list loaded, instead of rebuilding the list each time. [Commit](https://github.com/open-webui/open-webui/commit/3c010951db2cc349466658d3b52234cc22dad327), [Commit](https://github.com/open-webui/open-webui/commit/8edab5020eaa5d47b12a572eb632a891aa29440a), [Commit](https://github.com/open-webui/open-webui/commit/3e9b075954b7fc9d492a7ec832550b10b0bf80ed), [Commit](https://github.com/open-webui/open-webui/commit/3793b0c886f57630dc31320d3c0257c933c6eca1), [Commit](https://github.com/open-webui/open-webui/commit/0b4b7ae5ff3c0a17b58e8e85a5fddf190e3bda14)
- 👁️ **Turning off chat previews.** A new setting under Settings and Interface lets you switch off the preview card that appears when you hover a chat in the sidebar, useful for a quieter sidebar, for sharing your screen, or on a slow connection. [#27632](https://github.com/open-webui/open-webui/pull/27632), [#27639](https://github.com/open-webui/open-webui/issues/27639)
- ☑️ **Checkboxes beside their labels.** In the model editor and the admin model defaults, each capability, feature, and tool checkbox now sits directly in front of its own label instead of at the far edge of its column, where it could look like it belonged to the next one, and the label itself can be clicked to toggle it. [#27788](https://github.com/open-webui/open-webui/pull/27788), [#27771](https://github.com/open-webui/open-webui/issues/27771), [Commit](https://github.com/open-webui/open-webui/commit/1b39ff352a2fa3b57bd7815f44c50daf96937a14), [Commit](https://github.com/open-webui/open-webui/commit/0f821398ca9c9ddd8da521b5f9dcf103030202e8)
- ✍️ **Typing cursor while responding.** A blinking cursor now marks where the reply is being written, from the moment you send your message until generation finishes, in place of the previous loading placeholder. [Commit](https://github.com/open-webui/open-webui/commit/cbb3aade2b4e901c22aa9a30530634721c80b078)
- ✒️ **Underlined text.** Underlined text now appears underlined in a reply instead of showing the markup around it, and underlining is kept when you edit in a rich text box rather than being dropped. [Commit](https://github.com/open-webui/open-webui/commit/11db926a7b471e9477595786ef451a916174cc18), [#26904](https://github.com/open-webui/open-webui/issues/26904)
- 📥 **Adding group members from a file.** Administrators can now add many people to a group at once by uploading a CSV of names and email addresses, with a template to download and a message naming any row whose address does not match an account. [Commit](https://github.com/open-webui/open-webui/commit/f3f7659da754b51c216b17b2d6def4d59071d78f)
- 📑 **Apache Tika 4 support.** Administrators extracting document text with Tika can now choose which server version they run, from Admin Settings under Documents or through "TIKA_SERVER_VERSION", where only Tika 3 was understood before. [Commit](https://github.com/open-webui/open-webui/commit/170ad0595d9440113721eb06375a2fc99aefaad1), [#28939](https://github.com/open-webui/open-webui/issues/28939)
- 💓 **Tunable heartbeat for live updates.** A new "WEBSOCKET_HEARTBEAT_INTERVAL" setting controls how often each open tab checks in with the server, where it was fixed at 30 seconds, so a large deployment can cut background traffic that no one asked for. [Commit](https://github.com/open-webui/open-webui/commit/3c1017f6c3ffc7194f074f2cfd2ea5f49943c575), [#28166](https://github.com/open-webui/open-webui/issues/28166)
- ⌛ **Expiring abandoned reply state.** A new "REDIS_RESPONSE_STREAM_TTL" setting expires the saved state of a reply that never finished, so a server killed mid-answer no longer leaves that data behind for good. [Commit](https://github.com/open-webui/open-webui/commit/176fa462128d5298492db29c67080a4e1afc2642)
- 🖨️ **File and image detail parts on API requests.** A request sent to the OpenAI-compatible endpoint carrying an image detail level or a file part in its message content now forwards both to providers that use the Responses API, where they were dropped, while documents attached inside Open WebUI are unaffected because those still go through knowledge retrieval. [Commit](https://github.com/open-webui/open-webui/commit/ca4e07a40b4ff11989a25d84b517d3d0049f93ae)
- 🧺 **Leaner stored document metadata.** Bulky extraction details such as page layouts, tables and detected languages are no longer kept alongside a document in the vector store, and a new "RAG_METADATA_MAX_VALUE_CHARS" setting drops any remaining oversized value, falling back to the configured upload size limit so a document that expands enormously while being read cannot exhaust a server's memory. [Commit](https://github.com/open-webui/open-webui/commit/278e97589e71d119b887d5bca9d6ae32912d1dff), [Commit](https://github.com/open-webui/open-webui/commit/e3a7a64d82ab2dd06c681ce85027367ccc8234d4), [#29025](https://github.com/open-webui/open-webui/pull/29025)
- 💨 **No filter work on installs without filters.** A completed message on an install with no filter functions and no pipeline filters, which is the default, no longer rebuilds the whole conversation and ships it to the browser as an event nothing acts on. [Commit](https://github.com/open-webui/open-webui/commit/28f2965934f5b6fba0e0c38a15b2af6ee790819f)
- 📂 **Opening a file in a knowledge base.** A file listed in a knowledge base can now be opened and read straight from that list, where the name was shown but nothing happened when it was clicked. [Commit](https://github.com/open-webui/open-webui/commit/20f35d157bc7d535091ce6a90b517c23d26486c0), [#28086](https://github.com/open-webui/open-webui/issues/28086)
- 🧵 **Cheaper saving of a reply as it streams.** The resume snapshot taken on every piece of a streamed reply no longer rebuilds the whole answer each time, so the cost of a save stops growing with the length of the reply. [#28821](https://github.com/open-webui/open-webui/pull/28821)
- 📀 **Less repeated work setting up built-in tools.** Every chat request no longer rebuilds a fresh copy of each built-in tool's definition from scratch, which was paid once per tool on every message. [#28860](https://github.com/open-webui/open-webui/pull/28860)
- 🕹️ **Control over what a terminal port preview may reach.** A new interface setting decides whether a previewed port runs with access to same-origin browser APIs, so you can lock a preview out of them on installs where previews serve content you do not fully trust. [Commit](https://github.com/open-webui/open-webui/commit/54d7a223707f03172efbb9e754db6e69709956d0)
- ♿ **Improved UI accessibility.** A closed sidebar is no longer reachable by keyboard or announced by screen readers, the tool call blocks in a response can now be expanded with the keyboard, the buttons that normally appear on hover, such as message actions, file removal, and chat menus, now appear when you reach them with the keyboard as well, whatever you have tabbed to is marked with a clear outline throughout the application, and the rows in the integrations menu now tell a screen reader whether each tool or feature is switched on. [Commit](https://github.com/open-webui/open-webui/commit/48a5696042b414f6511911afa72b3289ad2797b9), [Commit](https://github.com/open-webui/open-webui/commit/bd250a0e2431f8c2c7e2f4a34c5327f21fc77d4b), [Commit](https://github.com/open-webui/open-webui/commit/c8f8fa451a60974dfa4ebaf7cd6163ef30c45fd6), [Commit](https://github.com/open-webui/open-webui/commit/29541cbb52659a8a6ee22d255f14e6d2f168a09b), [Commit](https://github.com/open-webui/open-webui/commit/ac0368b4abe4c880f715b236ee0024826b2e3e7a), [Commit](https://github.com/open-webui/open-webui/commit/c086b80313fe0f5ce60831a0d150f695646c66e4), [#27667](https://github.com/open-webui/open-webui/pull/27667), [#17150](https://github.com/open-webui/open-webui/issues/17150)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Faroese was added, and translations for Slovenian, Hungarian, Finnish, Korean, Portuguese (Brazil), Catalan, and French were enhanced and expanded.
### Fixed
- 🛡️ **Security Advisory**: This release includes security and access-control fixes. We recommend updating production deployments at your earliest convenience. Not all security fixes in this version may be enumerated in the fixed section. Some may be withheld for a short time to give administrators time to upgrade. [Advisories](https://github.com/open-webui/open-webui/security)
- 🛂 **Knowledge search reaching past what you may read.** Searching knowledge bases now applies the list of collections you are allowed to open, where that restriction was handed to the vector store and silently discarded, so results could include material from knowledge bases you have no access to. [Commit](https://github.com/open-webui/open-webui/commit/1d6d4e6e6647e1d403438ede7bd9ba20bc4cc8f6)
- 💣 **Documents that unpack far beyond their size.** A Word, Excel, PowerPoint, OpenDocument or EPUB file that expands to far more than it stores is now rejected before it is read, where one could previously be used to exhaust a server's memory. [Commit](https://github.com/open-webui/open-webui/commit/2a0274a0a039dbe0a1ad4d24003b085aae7b896b)
- ✂️ **Long replies cut off partway.** A single oversized piece of a streamed reply, such as a long reasoning trace or a turn carrying many tool calls, no longer ends the answer early with a misleading error about byte counts, which affected every default installation. [#28114](https://github.com/open-webui/open-webui/pull/28114), [#25664](https://github.com/open-webui/open-webui/issues/25664)
- 🚧 **Web address checks that could be skipped.** The fetchable-address test and the operator's web fetch filter list now run on every outgoing request, where a proxied or already-open connection could bypass them and a filter entry written as an address range silently matched nothing at all. [#27823](https://github.com/open-webui/open-webui/pull/27823)
- 🧑‍💻 **Code execution reachable through a tag in a reply.** On installs using native function calling, the older path that runs code found inside a tag in the model's reply is no longer active alongside the built-in tool, so code execution happens only through an explicit tool call. [#29024](https://github.com/open-webui/open-webui/pull/29024)
- ⌚ **Recurrence rules that could tie up the server.** How often an automation repeats is now taken from the rule the scheduler actually parsed rather than from the text of the rule, so a crafted rule can no longer disagree with what gets scheduled and walk the server through an unbounded run of occurrences, and a rule carrying a time zone on its start date now schedules instead of erroring. [Commit](https://github.com/open-webui/open-webui/commit/067114c28038b77045a3a1983e2e2719b1a32ad8)
- 🗝️ **Changing a password now ends other sessions.** Changing your password, or an administrator resetting it for you, now stops every device that was already signed in, where they had stayed signed in on the old password until their session expired on its own, up to four weeks by default; the device making the change is signed out too and asked to sign in again, and this requires Redis, without which nothing can be revoked and a warning is now logged saying so. [#28725](https://github.com/open-webui/open-webui/pull/28725), [#28647](https://github.com/open-webui/open-webui/discussions/28647)
- 🧬 **Workspace models shadowing a real one.** Someone without administrator rights can no longer create, import, or edit a workspace model so that it takes over the identity of a model served by a connected provider, where doing so would have changed what everyone else got when they picked that model. [Commit](https://github.com/open-webui/open-webui/commit/ea55d38793014a4e3cd5a4046816fe22e69e9739)
- 🌳 **Folders disappearing when moved into themselves.** Moving a folder inside one of its own subfolders is now refused, where it was accepted and made that folder and everything in it vanish from the sidebar with no way to bring it back, while leaving the server walking the loop endlessly and querying the database as it went, which could exhaust a worker and its memory; any folder already in that state is returned to the top level. [#28748](https://github.com/open-webui/open-webui/pull/28748)
- 🧨 **Searching a knowledge base with a costly pattern.** A search pattern written so that it expands enormously before it even runs is now refused, where it could tie up the server; ordinary patterns are unaffected. [Commit](https://github.com/open-webui/open-webui/commit/d5b66533e7829654f6fb343abbaaeab988dbfde8), [#28284](https://github.com/open-webui/open-webui/pull/28284)
- 💧 **Attaching a very large file from a link.** A file fetched from a link is now written to disk as it arrives and stops at the configured size limit, where the whole thing was held in memory first with no limit applied, so a large enough file could exhaust the server; a download that fails partway no longer leaves the partial file behind. [#28945](https://github.com/open-webui/open-webui/pull/28945)
- ⛓ **Deleting one knowledge base removing a shared connection.** Deleting an external knowledge base now leaves its connection in place while other knowledge bases still use it, and only an administrator removing the last one clears it, where any user deleting theirs took the connection away from everyone. [#28113](https://github.com/open-webui/open-webui/pull/28113)
- 📡 **Intermittent connection failures.** Requests to model providers and to services on the same network no longer fail intermittently with name lookup errors, often surfacing as a misleading model not found message, because addresses are resolved through the system again by default, with the faster resolver still available through "AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER". [#28242](https://github.com/open-webui/open-webui/pull/28242), [#28013](https://github.com/open-webui/open-webui/issues/28013), [#28215](https://github.com/open-webui/open-webui/issues/28215)
- 🗯️ **Losing the conversation with memory on.** With the memory tool enabled, the model can see the earlier messages in your conversation again, instead of answering the second message as though the first had never been sent. [#28400](https://github.com/open-webui/open-webui/issues/28400)
- 👻 **Vanishing responses.** Replies from Responses-API providers that report an empty output at the end of a stream no longer disappear the moment generation finishes, leaving an empty message in their place. [#27800](https://github.com/open-webui/open-webui/pull/27800), [#27789](https://github.com/open-webui/open-webui/discussions/27789)
- 📥 **Queued messages disappearing.** Messages waiting to be sent are put back in the queue if sending them fails, rather than vanishing without being sent. [Commit](https://github.com/open-webui/open-webui/commit/f79b443c226098564386d0a31708e71ec0146158)
- 🧵 **Replies cut short mid-stream.** A reply no longer breaks off part way through when a provider sends the pieces of its response in an unexpected order, which had left the answer truncated and skipped the filters that run once a message finishes. [#28312](https://github.com/open-webui/open-webui/pull/28312)
- 🧷 **Replies not carried into the next turn.** With providers that skip parts of the streaming sequence, the finished reply is now taken from the completed message, so it stays available as context for your next question and the citations that arrived with it are no longer dropped. [#28310](https://github.com/open-webui/open-webui/pull/28310)
- 🌊 **Replies arriving in oversized pieces.** Very large streamed pieces no longer break the response on default settings, where the reader that splits them safely only ran when a chunk size limit was configured. [Commit](https://github.com/open-webui/open-webui/commit/a33fa05adc6def8f3d098539a4786dc9c7bf61d2)
- 🩹 **Signing in after a long-delayed upgrade.** Accounts on instances that were upgraded from a version older than 0.6.41 to 0.9.6 or newer can sign in again, where an upgrade step had written their single sign-on identity in a form the application could not read afterwards, and a repair step corrects the affected accounts on startup. [#28107](https://github.com/open-webui/open-webui/pull/28107), [#28101](https://github.com/open-webui/open-webui/issues/28101), [Commit](https://github.com/open-webui/open-webui/commit/bd8378f643dd8a56366ad9d1843b7d40f67cf1f9)
- 🔑 **Signing in with some identity providers.** Logging in through a provider that adds its own vendor-specific information to the header of the sign-in token now completes, rather than failing at the final step with a message claiming the email or password was wrong. [#28065](https://github.com/open-webui/open-webui/pull/28065), [#28062](https://github.com/open-webui/open-webui/issues/28062)
- 🔌 **Role changes taking effect at once.** Changing someone's role now ends their live sessions no matter how the change was made, whether by a directory sync, an identity provider, a trusted header, or deleting the account, so permissions from their old role cannot linger, and their browser reconnects on its own. [Commit](https://github.com/open-webui/open-webui/commit/ce3c175e260709f359d7e6cbb3132f0572098b95)
- 🛑 **Memory permission being respected.** Taking away someone's memory permission now also stops their stored memories being added to the context of their conversations, which one path had continued doing regardless. [#27668](https://github.com/open-webui/open-webui/pull/27668)
- 🔍 **Listing a single connection's models.** Asking for the models or version of one particular connection is now restricted to administrators, and a request naming a specific backend is checked against the models that backend actually serves even where the access control bypass is turned on. [Commit](https://github.com/open-webui/open-webui/commit/16f118d77ad9d68c64116f94551ffb14bf8b2abd)
- ⚖️ **Sharing defaults matching what was configured.** On instances upgraded from older versions, public sharing of tools and notes no longer shows as switched on in the admin panel, and saving any unrelated permission no longer grants everyone a capability that was never enabled. [#27716](https://github.com/open-webui/open-webui/pull/27716), [#27715](https://github.com/open-webui/open-webui/issues/27715)
- 🗂️ **Folder permissions when starting a chat.** Starting a conversation filed into a folder now checks that you are allowed to write to that folder, a check the message sending path had been skipping, and every place a chat can be filed now treats ownership, shared access, and unknown folders the same way. [#28366](https://github.com/open-webui/open-webui/pull/28366)
- 🪧 **Clearer attachment failures.** A link that cannot be read now says so and names the link, and a YouTube video whose transcript is refused explains why and points at the proxy setting that exists for it, instead of both being reported as a knowledge base error. [#28362](https://github.com/open-webui/open-webui/pull/28362), [#28361](https://github.com/open-webui/open-webui/issues/28361)
- 🔎 **Chat search finding recent messages.** Searching your chats now looks inside the messages of current conversations on default installations, where it had only been reading an older storage format and missing their content entirely. [Commit](https://github.com/open-webui/open-webui/commit/0800c21c64c64810f24c2ec88cca5e36daebb10e)
- 🧭 **Your place in a compacted chat.** Opening a conversation whose history has been compacted now takes you to its most recent message instead of leaving you parked on the summary, and updates to an existing message no longer move your place in the conversation. [Commit](https://github.com/open-webui/open-webui/commit/5caa91a49304148696641d1cd39e43184ba8d748)
- 🖥️ **Chats with a personal terminal.** Sending a message with a terminal you added yourself under Settings selected no longer fails with a terminal unavailable error, which had blocked those chats since 0.11.0. [#27621](https://github.com/open-webui/open-webui/issues/27621), [Commit](https://github.com/open-webui/open-webui/commit/5b333d75c6adea4d8bde96439974d6e9c83d8198), [Commit](https://github.com/open-webui/open-webui/commit/3becec6ccfc7dc457270d4e9f0d8c957e8160ddc)
- 📆 **Default date for new events.** Creating a calendar event now starts on today's date rather than tomorrow's when you open the form in the evening, or yesterday's when you open it early in the morning. [#27779](https://github.com/open-webui/open-webui/pull/27779), [#27778](https://github.com/open-webui/open-webui/issues/27778)
- 🗓️ **Recurring event times.** Repeating calendar events now show at the time you set them for instead of being worked out in the server's time zone and shifted by the gap between the two. [#27774](https://github.com/open-webui/open-webui/issues/27774), [Commit](https://github.com/open-webui/open-webui/commit/d721b0d19621d01e35505105a778ce5681b2ffba)
- 🧩 **Chats during model list refreshes.** On direct connections, background work such as title and tag generation no longer fails or runs against a mix of old and new model entries while the model list is being refreshed. [#27821](https://github.com/open-webui/open-webui/pull/27821)
- 🎛️ **Chat Controls staying put.** Hovering a chat in the sidebar whose preview contains an artifact no longer forces the Chat Controls pane open and fills it with that artifact, over the chat you currently have open. [#27773](https://github.com/open-webui/open-webui/pull/27773), [#27772](https://github.com/open-webui/open-webui/issues/27772)
- 📨 **Reliable streaming with unusual characters.** Responses containing any of three rare invisible line break characters no longer arrive split or broken when the "ENABLE_ORJSON" option is turned on. [#27819](https://github.com/open-webui/open-webui/pull/27819)
- 🧮 **JSON options honoured again.** Options passed to the shared JSON helper are no longer silently ignored when the "ENABLE_ORJSON" option is turned on, falling back to the standard encoder that supports them. [Commit](https://github.com/open-webui/open-webui/commit/78ed5a0235c4de67828ba4b0d6147035067cec2c)
- 🚫 **Duplicate models in lists.** Adding a model that is already on a connection's allowed list is now rejected instead of quietly adding it a second time, the arena picker no longer offers models you have already chosen, and existing duplicates are cleaned up the next time the list is saved. [#28251](https://github.com/open-webui/open-webui/pull/28251), [#28249](https://github.com/open-webui/open-webui/issues/28249)
- 📁 **Dragging chats into shared folders.** A shared folder you can write to now highlights and accepts a dropped chat, while one you only have read access to no longer offers itself as a drop target for an action that could only fail. [Commit](https://github.com/open-webui/open-webui/commit/e4dd6c4bf14c7ea54c1effa6c40ad9e95185c413), [#28261](https://github.com/open-webui/open-webui/issues/28261)
- ⏰ **Listing automations through chat.** Asking a model to list your automations without naming a folder now returns every automation you have, instead of only the ones that sit outside a folder. [Commit](https://github.com/open-webui/open-webui/commit/f8ac75d188b7e07906aa1d5e648e3fc25a78ef2b)
- 🧠 **Faster follow-ups with memory enabled.** The memories handed to the model now appear in a stable order from one message to the next, so servers that reuse their work between turns no longer reprocess the whole conversation each time you reply. [#28292](https://github.com/open-webui/open-webui/issues/28292), [Commit](https://github.com/open-webui/open-webui/commit/ff74bfa6a117c6f03097034c74c2f9bdfb824203), [Commit](https://github.com/open-webui/open-webui/commit/d22bb6703f244e6769ac9b0f2e41d784ad6cf535)
- 🎯 **Custom model parameters combining.** Setting a custom parameter on a model no longer silently discards every custom parameter defined in the global defaults, and a value sent directly in an API request is no longer overwritten by the model's saved settings. [Commit](https://github.com/open-webui/open-webui/commit/11739a2de8eddf7ef26378356aff8a1bcab4a349), [#28241](https://github.com/open-webui/open-webui/issues/28241)
- 👥 **Sharing with people already added.** The access picker no longer offers people and groups that already have access, and the Users heading no longer appears above an empty list. [Commit](https://github.com/open-webui/open-webui/commit/385d08bea5899bcb160391db466662b0a0a6f801), [#28253](https://github.com/open-webui/open-webui/issues/28253)
- 🔧 **Full tool parameter descriptions.** A tool whose parameter description runs over several lines now passes the whole description to the model instead of only its first line. [Commit](https://github.com/open-webui/open-webui/commit/b606e13da3753027ead0e92e35e1399b60c96c8e)
- 📝 **Notes saved in an unexpected shape.** A note whose content was stored as structured data rather than text no longer breaks the notes page for everything else, and opens with that content shown as a formatted code block. [Commit](https://github.com/open-webui/open-webui/commit/8d1c205d8e7335ef7c292afb125a7270976f327b), [#28222](https://github.com/open-webui/open-webui/issues/28222)
- 💬 **Direct messages after an account is deleted.** A direct message conversation no longer counts a deleted account among its members, and opening a direct message with someone finds the existing conversation instead of starting a second one alongside it. [Commit](https://github.com/open-webui/open-webui/commit/a41faa3c226b20e7db7508c49c7d1d015ac9e973), [#28257](https://github.com/open-webui/open-webui/issues/28257)
- 🫥 **Deactivating a model.** Turning a model off no longer removes the wrong entry from the model list, or fails the list outright and leaves the model picker empty for everyone until the model is turned back on. [Commit](https://github.com/open-webui/open-webui/commit/5cecb7dbfad3994228ee53d19232650400462332), [#28202](https://github.com/open-webui/open-webui/issues/28202)
- 💭 **Readable errors on chat actions.** When moving, renaming, or otherwise changing a chat fails, the message explaining why now appears in place of an unhelpful object placeholder. [#28260](https://github.com/open-webui/open-webui/pull/28260), [#28259](https://github.com/open-webui/open-webui/issues/28259)
- 🪪 **Authorship in shared chats.** A chat shared with you now shows the name and picture of whoever wrote it, in the message list and in the overview panel, rather than crediting the messages to you. [#28274](https://github.com/open-webui/open-webui/pull/28274), [#28273](https://github.com/open-webui/open-webui/issues/28273)
- 🖇️ **Adding terminals over plain connections.** Saving a terminal connection now works when the interface is served without HTTPS, where the dialog would sit there doing nothing because the browser withholds the tool used to generate its identifier. [Commit](https://github.com/open-webui/open-webui/commit/2a45fa04cb1d07b258e0b880ab0c02577f0dc035), [#28148](https://github.com/open-webui/open-webui/issues/28148)
- 🗃️ **openGauss vector storage.** Deployments using openGauss for vector storage no longer fail the moment they touch it. [#27838](https://github.com/open-webui/open-webui/pull/27838)
- 🎚️ **ColBERT reranker startup.** Loading a ColBERT reranker now names the model in the log rather than printing a logging error and a traceback in its place. [#27838](https://github.com/open-webui/open-webui/pull/27838)
- 🖼️ **Images that no longer exist.** A message whose image file has been deleted now shows a small unavailable placeholder that cannot be opened, rather than a broken image that spilled the whole reply text into the picture frame and still opened full screen. [#27730](https://github.com/open-webui/open-webui/pull/27730), [#27728](https://github.com/open-webui/open-webui/issues/27728), [Commit](https://github.com/open-webui/open-webui/commit/f8c5fda283e6206d92e81ae3b83ffe5d7a144b03)
- 🛂 **Connecting external accounts.** Authorizing a tool's external account now completes only for the person who started it, rather than for whoever happens to return with the authorization, and signing out clears the session it relies on. [Commit](https://github.com/open-webui/open-webui/commit/c2107e5bb3689a69c170ca526925f4ed84bd00f5)
- 🪟 **Starting up on Windows.** The Windows start script now creates the secret key it needs on a fresh installation, instead of printing a run of file not found messages and then refusing to start, and it copes with an installation path that contains spaces. [#28061](https://github.com/open-webui/open-webui/pull/28061), [#28060](https://github.com/open-webui/open-webui/issues/28060)
- 🕸️ **Overlapping branches in the overview.** Branch nodes in a chat's overview keep a clear gap between them when the interface is scaled up, rather than sitting on top of one another. [#27995](https://github.com/open-webui/open-webui/pull/27995), [#27994](https://github.com/open-webui/open-webui/issues/27994)
- 🗑️ **Delete offered only when allowed.** The chat deletion controls in the sidebar, the chat menu, search, archived chats, and data controls no longer appear for people whose permissions do not allow deleting, where using them produced an access denied error. [#27714](https://github.com/open-webui/open-webui/pull/27714), [#27713](https://github.com/open-webui/open-webui/issues/27713)
- 🍴 **Fork offered only when allowed.** The fork action and the fork command no longer appear for people whose chat import permission is turned off, where using them produced an access denied error. [#27711](https://github.com/open-webui/open-webui/pull/27711), [#27692](https://github.com/open-webui/open-webui/issues/27692)
- ↕️ **Expand button in the message box.** The button that enlarges the message box no longer sits on top of a tagged model's dismiss button or the first attached file, and stays reachable in long prompts. [#27676](https://github.com/open-webui/open-webui/pull/27676), [#26736](https://github.com/open-webui/open-webui/issues/26736)
- 🔆 **Regenerate in high contrast mode.** With high contrast mode on, the regenerate button now stays visible on earlier replies instead of appearing only when you hover over them. [#27644](https://github.com/open-webui/open-webui/pull/27644), [#27638](https://github.com/open-webui/open-webui/issues/27638)
- ✂️ **Clipped icons and avatars.** The terminal icon beside the message box and the profile picture in account settings are no longer shaved flat along their left edge. [#27691](https://github.com/open-webui/open-webui/pull/27691), [#27690](https://github.com/open-webui/open-webui/issues/27690)
- 🪄 **Merged responses after a reload.** Merging the answers from several models now works on a conversation you have reopened, instead of the merging model reporting that the other responses were empty. [#27673](https://github.com/open-webui/open-webui/pull/27673), [#26962](https://github.com/open-webui/open-webui/issues/26962)
- 🔢 **Token counts for background chats.** Conversations started by automations, timers, sub-agents, and channels now report their token usage like any other chat, rather than arriving without it even when the model is set up to provide it. [#27661](https://github.com/open-webui/open-webui/pull/27661), [#27653](https://github.com/open-webui/open-webui/issues/27653)
- 📐 **Settings on tall screens.** The settings window now grows with the height of your display instead of stopping short and making you scroll inside it while space sits unused above and below. [#27615](https://github.com/open-webui/open-webui/pull/27615), [#27614](https://github.com/open-webui/open-webui/issues/27614)
- 🖱️ **Sections opening by accident.** Folders, collapsible sections, and tool call blocks now open and close only when you click them, rather than also reacting when you release the mouse over them after dragging or selecting text. [Commit](https://github.com/open-webui/open-webui/commit/bd250a0e2431f8c2c7e2f4a34c5327f21fc77d4b)
- 🔁 **Rebuilding knowledge base vectors.** Rebuilding the vectors for a knowledge base now also rebuilds them for each file it contains, so attaching a single file afterwards finds its content instead of quietly returning nothing and letting the model answer from thin air. [#28106](https://github.com/open-webui/open-webui/issues/28106), [Commit](https://github.com/open-webui/open-webui/commit/2a6e671f548970c8223692024e630b9936e9fa7c), [Commit](https://github.com/open-webui/open-webui/commit/89922cc9d585e10b026693681b44afdc4b874588)
- 📌 **Attaching a chat shared with you.** Attaching a conversation that was shared with you, directly or through a shared folder, now brings its content along instead of quietly attaching nothing. [Commit](https://github.com/open-webui/open-webui/commit/5cd9a395344e882b4384bec0899f503ad540aecc)
- 🧲 **The page staying still when typing.** Returning focus to the message box no longer scrolls the conversation, so switching chats, running a command, or picking something from a menu leaves your place on screen alone. [Commit](https://github.com/open-webui/open-webui/commit/9122c24ea2d506aad50384ca8aba1616d1bed626)
- 📷 **Round profile pictures on narrow screens.** Profile pictures in the admin user list and other lists no longer squash into ovals of differing widths when the window is narrow. [#28000](https://github.com/open-webui/open-webui/pull/28000), [#27999](https://github.com/open-webui/open-webui/issues/27999)
- 🎙️ **Voice mode in the notes editor.** The voice mode button is no longer offered in the chat embedded in a note, where it does not apply. [Commit](https://github.com/open-webui/open-webui/commit/9122c24ea2d506aad50384ca8aba1616d1bed626), [Commit](https://github.com/open-webui/open-webui/commit/e963d36e393aead56b941e99d4167fb0dc9d2d7a)
- ⏳ **Analytics stuck loading.** Choosing a custom date range in analytics without picking dates yet no longer leaves the tab spinning forever, including after leaving and coming back to it. [#28125](https://github.com/open-webui/open-webui/issues/28125), [Commit](https://github.com/open-webui/open-webui/commit/629cdcb5303958c5b99ee437030ab80cc418bc2e)
- 🧑‍🤝‍🧑 **Owner avatars on shared chats.** The picture beside a chat someone shared with you now loads, and falls back to the default image if it cannot, rather than leaving a blank gap when the interface and the server are on different addresses. [#28272](https://github.com/open-webui/open-webui/pull/28272), [#28271](https://github.com/open-webui/open-webui/issues/28271)
- 🎨 **Image generation and web search staying switched off.** Turning either off now takes effect at once on every path: sessions opened beforehand can no longer produce images or run searches, an image request no longer reaches the provider on a model using the older tool-calling method, the entry disappears from the integrations menu right away, and an active marker beside the message box no longer lingers after its feature is withdrawn. [#27759](https://github.com/open-webui/open-webui/pull/27759), [#27758](https://github.com/open-webui/open-webui/issues/27758), [#26842](https://github.com/open-webui/open-webui/issues/26842), [#27669](https://github.com/open-webui/open-webui/pull/27669)
- 📰 **Attached pages reaching the model.** The text pulled from an attached web page or YouTube video now actually reaches the model, rather than arriving empty so the reply had nothing to work from, and opening the source to check no longer fails. [#28378](https://github.com/open-webui/open-webui/issues/28378), [Commit](https://github.com/open-webui/open-webui/commit/9c21d4ed3ba9ba8e53def7e1a1366b74834cb42d)
- 🌐 **Tavily page fetching.** Reading a web page with Tavily selected as the loader works again, having failed on every attempt since 0.10.0. [#27636](https://github.com/open-webui/open-webui/pull/27636), [#27602](https://github.com/open-webui/open-webui/issues/27602)
- 🗒️ **Reply box in threads.** The reply box in a channel thread now stays at the bottom of the panel while you scroll back through the replies, instead of scrolling out of sight with them. [#27768](https://github.com/open-webui/open-webui/pull/27768), [#27767](https://github.com/open-webui/open-webui/issues/27767)
- 🎹 **Model picker shortcut.** The keyboard shortcut for opening the model picker works again, and a link to a chat naming a model you do not have still opens the picker with that name filled in. [Commit](https://github.com/open-webui/open-webui/commit/e1acd7e7ca5085e03babbea16b4770e51c38886e)
- ⌨️ **Reaching the download options by keyboard.** In the model picker, arrowing past the last result now moves through the options to fetch that model from each server that can supply it, so they can be chosen with the keyboard instead of only by clicking. [Commit](https://github.com/open-webui/open-webui/commit/4e03d89414a7be1203579f45225ca64f1221205f), [Commit](https://github.com/open-webui/open-webui/commit/25802c048e6123fa602182949ad2a9f349e1e863)
- 📂 **Opening a folder in the sidebar.** Selecting a folder now refreshes just that folder's chats rather than rebuilding the whole folder tree, and a folder that is empty or still loading says so instead of showing nothing. [Commit](https://github.com/open-webui/open-webui/commit/2e939874904e1ae54d02b8bc6d745931a8e1f673), [Commit](https://github.com/open-webui/open-webui/commit/86b7bf1f7e11109816b0d16ada708c637b50c520)
- ⏲️ **Changing an automation through chat.** Asking a model to change one thing about an automation no longer moves it out of its folder or drops its model when the model fills those fields in blank instead of omitting them. [Commit](https://github.com/open-webui/open-webui/commit/90bb94abf9a8c6fbb87c72952295ae39d2ad98e5)
- 🔂 **Automations that run a set number of times.** An automation asked to run a limited number of times is now rejected unless it says when to start counting from, rather than being accepted and then running indefinitely. [#27781](https://github.com/open-webui/open-webui/pull/27781), [#27780](https://github.com/open-webui/open-webui/issues/27780)
- 📅 **Editing a calendar event through chat.** Asking a model to change one thing about an event, such as its title, no longer fails or wipes the details you did not mention. [#27777](https://github.com/open-webui/open-webui/pull/27777), [#27776](https://github.com/open-webui/open-webui/issues/27776)
- 🧹 **Session cleanup on multi-instance setups.** The instance doing the periodic session cleanup now keeps its claim on that job alive between passes, so an idle deployment stops logging a renewal warning every two minutes and the claim no longer lapses for half of every cycle. [Commit](https://github.com/open-webui/open-webui/commit/939bcdb79e3dcad2278f1e7f4f517a3f2f36f3ec), [#27762](https://github.com/open-webui/open-webui/issues/27762)
- 🏠 **Starting folder in the file browser.** Reopening the file browser now keeps the folder you were in, instead of the breadcrumb losing its starting point and jumping you elsewhere. [Commit](https://github.com/open-webui/open-webui/commit/52c5e3b20d3cd8c3a15db50e2cc75bbd6e82068e)
- 🩺 **Repairing default model settings.** Instances whose stored default and pinned model settings had been written in the wrong shape are corrected on startup, so those defaults take effect again. [Commit](https://github.com/open-webui/open-webui/commit/5c05608e3ac0aefce8c1eed18c3e205c61f6b6fe)
- 🧽 **Cleaner conversation history for the model.** Internal bookkeeping attached to your messages, such as attachment records and token counts, is no longer sent to the model along with the conversation. [Commit](https://github.com/open-webui/open-webui/commit/a32a17965ca36f730ebf5ff53a2a8e17082b61a6)
- 🙈 **Needless request from the model picker.** Opening the model picker as a non-administrator no longer fires a request to an administrator-only settings endpoint that was always refused. [Commit](https://github.com/open-webui/open-webui/commit/b4738d1a2e6af1ce20adb322fc6d2aa2c9407901)
- ⏹️ **Stopping a reply that is waiting.** The stop button now ends a reply that is sitting waiting for you, such as one paused on a tool approval, rather than leaving the conversation stuck part way through. [Commit](https://github.com/open-webui/open-webui/commit/f7767d6be774720e16265c6d016bf6a24a1a8759)
- 🏷️ **Folder names with unusual characters.** Naming a folder is no longer refused because another folder's name happens to be similar, and a name ending in a backslash no longer fails outright on PostgreSQL, because names are now compared exactly rather than treated as a search pattern. [#28695](https://github.com/open-webui/open-webui/pull/28695), [#28694](https://github.com/open-webui/open-webui/issues/28694)
- 🤔 **Reasoning carried back to Ollama.** A model's earlier thinking is now passed back to Ollama in its own native field rather than pasted into the message as tagged text, so reasoning models keep their train of thought across turns. [Commit](https://github.com/open-webui/open-webui/commit/3258330729942b533dc5fe141e876cee7e5eb40d)
- 📎 **Default pinned models taking effect.** Changing the default pinned models now reaches people who have never chosen their own, where simply having opened the interface once was enough to freeze the list they first saw, and reordering a pin no longer moves the wrong one or reopens the sidebar section afterwards. [#28069](https://github.com/open-webui/open-webui/pull/28069), [#28067](https://github.com/open-webui/open-webui/discussions/28067)
- ✏️ **Editing other people's channel messages.** Asking a model to work on a message in a channel now only applies to your own messages, where write access to the channel had been enough to reach anyone's. [#28631](https://github.com/open-webui/open-webui/pull/28631)
- 🍪 **Signed-in tool servers.** A tool server that relies on your session now receives the credentials belonging to its own connection, rather than whichever were most recently prepared. [#28630](https://github.com/open-webui/open-webui/pull/28630)
- 🏗️ **Editing a folder from its page.** Renaming a folder, changing its icon, creating a subfolder, or deleting it from the folder's own page now updates the sidebar straight away, instead of leaving the old name and icon there, and the new subfolder missing, until a reload. [Commit](https://github.com/open-webui/open-webui/commit/a40f6f2860b49b4c7f11e369f11bd46e9451eabf), [Commit](https://github.com/open-webui/open-webui/commit/736e38338ede1fca5af0829e35585a72f8517d32), [#28692](https://github.com/open-webui/open-webui/pull/28692), [#28690](https://github.com/open-webui/open-webui/issues/28690)
- 📣 **Long channel names in the sidebar.** A channel with a long name no longer squeezes its own menu button out of the row. [Commit](https://github.com/open-webui/open-webui/commit/b5da50f3df51786972010c75c7736d5f95362e58), [#28671](https://github.com/open-webui/open-webui/pull/28671), [#28670](https://github.com/open-webui/open-webui/issues/28670)
- 📬 **Mark as unread in chat search.** Marking a chat unread from the search dialog now works and updates the sidebar, where the menu entry looked normal but did nothing at all. [#28136](https://github.com/open-webui/open-webui/pull/28136), [#28135](https://github.com/open-webui/open-webui/issues/28135)
- ⬆️ **Scroll to top on the first click.** In a long chat where older messages had not been loaded yet, one click of scroll to top now reaches the first message instead of stopping short and needing a second. [#28659](https://github.com/open-webui/open-webui/pull/28659), [#28658](https://github.com/open-webui/open-webui/issues/28658)
- 🔘 **Double bullets in the release notes.** Each entry in the what's new dialog shows a single bullet again, rather than two sitting at different heights. [#28676](https://github.com/open-webui/open-webui/pull/28676), [#28675](https://github.com/open-webui/open-webui/issues/28675)
- 📚 **Knowledge search and shared files in chat.** A model searching your knowledge bases or reading a file shared with you through a group now works, where it had failed since 0.11.0 and quietly answered as though the knowledge were empty, affecting instances that forward user details to their embedding service and, for shared files, every instance regardless of settings. [#27642](https://github.com/open-webui/open-webui/pull/27642), [#27641](https://github.com/open-webui/open-webui/issues/27641)
- 🔖 **Skill identifiers that cannot be reached.** Creating a skill whose identifier contains a character that is not allowed in a web address is now refused outright, rather than accepted and then permanently impossible to open, edit, turn off, delete, or recreate. [#27660](https://github.com/open-webui/open-webui/pull/27660), [#27655](https://github.com/open-webui/open-webui/issues/27655)
- 🔤 **Model names on connections with a prefix.** A connection that adds a prefix to its model names now strips it before sending a request through the responses endpoint, where the prefixed name was passed on and rejected as unknown. [#28575](https://github.com/open-webui/open-webui/pull/28575), [#28574](https://github.com/open-webui/open-webui/issues/28574)
- 🔓 **Turning on open sharing.** The open sharing permission can now be switched on in the default user permissions, where saving appeared to work but the setting was discarded and came back off. [#27609](https://github.com/open-webui/open-webui/pull/27609), [#27607](https://github.com/open-webui/open-webui/issues/27607)
- 🖌️ **White boxes behind model icons.** Model icons with a transparent background no longer sit on a white square in the admin models list, matching how they already appeared everywhere else. [#27612](https://github.com/open-webui/open-webui/pull/27612), [#27611](https://github.com/open-webui/open-webui/issues/27611)
- 🪞 **Matching the right account at sign-in.** Looking up an account by its identity provider details now matches the exact value, where the stored details were searched as loose text and a value contained within another's could be matched instead. [#28624](https://github.com/open-webui/open-webui/pull/28624)
- 🔄 **Syncing a model catalogue more than once.** Syncing models now updates the ones that already exist, where any repeat of a previous sync silently did nothing at all while still reporting success. [#28036](https://github.com/open-webui/open-webui/pull/28036), [#28033](https://github.com/open-webui/open-webui/issues/28033)
- 🕰️ **Saving a calendar event without a date.** Creating or editing an event with the date cleared now asks for one, where it was sent anyway, refused by the server, and reported as an unreadable error. [Commit](https://github.com/open-webui/open-webui/commit/f100edb70874808c93fab84eae1810595f9e9dc3), [#28133](https://github.com/open-webui/open-webui/issues/28133)
- 🔗 **Deleting a message in a looping chat.** Deleting a message no longer hangs when the conversation contains a cycle in its reply structure. [#28035](https://github.com/open-webui/open-webui/pull/28035)
- 🫀 **Scheduled work stopping without warning.** The routine that runs automations and calendar alerts can no longer be discarded while the application is running, which had silently stopped them firing with nothing reported, and it now stops cleanly on shutdown. [#28053](https://github.com/open-webui/open-webui/pull/28053), [#28052](https://github.com/open-webui/open-webui/issues/28052)
- 🔋 **Session and usage records left uncleared.** The routines that clear out stale sessions and finished model usage can no longer be discarded while the application is running, so those records stop accumulating unnoticed, and both now stop cleanly on shutdown. [#28053](https://github.com/open-webui/open-webui/pull/28053), [#28052](https://github.com/open-webui/open-webui/issues/28052)
- ⚗️ **Reasoning carried between turns.** A model's earlier thinking is now recognised from providers that report it in their own nested field, and reasoning that cannot be sent back without a signature is left out rather than being passed on and rejected. [Commit](https://github.com/open-webui/open-webui/commit/b6dc70c93b0d36e2659438e7a55aa8b21fee27f8)
- 🛎️ **Losing all your settings.** Your interface settings are no longer wiped by a session that failed to load them, which could happen with no action on your part and cleared everything from your theme to your model parameters; saving now changes only the settings you actually changed, and a session that cannot load them tells you instead of carrying on as though you had none. [#27766](https://github.com/open-webui/open-webui/issues/27766), [Commit](https://github.com/open-webui/open-webui/commit/ad8c79f68657bd3bcf5db6be650e498bb904b36b)
- 🖲️ **Losing the collapsed sidebar.** With the sidebar collapsed, opening a chat no longer pushes the narrow sidebar strip off the edge of the screen, which left no way to reopen the sidebar short of shrinking the window to phone size. [#28501](https://github.com/open-webui/open-webui/pull/28501), [#28500](https://github.com/open-webui/open-webui/issues/28500)
- 🧯 **Timers that fail without saying so.** A timer whose reply cannot be generated, such as one set against a model that has since been removed, is now recorded as failed with the reason, instead of being marked as completed while the reply never arrives. [#27785](https://github.com/open-webui/open-webui/pull/27785), [#27783](https://github.com/open-webui/open-webui/issues/27783)
- 🖊️ **Message buttons in channels.** The buttons that appear when you hover a channel message now sit above the message rather than over its content, so they can be clicked on a message that starts with a code block or a table, and so the code and table controls stay clickable too. [#27737](https://github.com/open-webui/open-webui/pull/27737), [#27736](https://github.com/open-webui/open-webui/issues/27736)
- 🔡 **Searching for non-English tags and text.** Searching workspace models by tag, or prompts and automations by their contents, now finds entries containing characters outside the English alphabet, where roughly half were missed depending on which settings were in force when each one was saved. [#28399](https://github.com/open-webui/open-webui/pull/28399)
- 🔭 **Searching the calendar without an end date.** Asking a model to search your calendar without naming an end date now works on PostgreSQL, where the open-ended range was too large for the database to accept and the search failed outright. [Commit](https://github.com/open-webui/open-webui/commit/9550731cc17759f6862595b8cd849ae48695b5c1), [#27717](https://github.com/open-webui/open-webui/issues/27717)
- 🫧 **Attachments replaced by a loading dot.** Pinning a channel message, or otherwise updating one, no longer replaces its attachment with a loading indicator that never resolves until you reload or leave the channel. [Commit](https://github.com/open-webui/open-webui/commit/76d01602950a9e40823b53278557708d5bcd1036), [#27734](https://github.com/open-webui/open-webui/pull/27734), [#27731](https://github.com/open-webui/open-webui/issues/27731)
- 🛠️ **Rebuilding empty server lists on every request.** An instance with no tool servers or no terminal servers configured no longer rebuilds that empty list on every request that needs it. [Commit](https://github.com/open-webui/open-webui/commit/f1a64ccfc2eb2a58086c55fe413a455bb488c35a), [#28568](https://github.com/open-webui/open-webui/issues/28568)
- 🪫 **Errors logged for a cache that was simply empty.** A shared cache that has not been filled yet no longer logs an error suggesting its stored value is broken. [Commit](https://github.com/open-webui/open-webui/commit/f1a64ccfc2eb2a58086c55fe413a455bb488c35a), [#28568](https://github.com/open-webui/open-webui/issues/28568)
- ⚓ **Starting up as a non-root user.** Deployments that run the container as a non-root user, such as Kubernetes setups using runAsNonRoot, start again, where a bundled speech model file that only root could read had stopped them since 0.11.0, and a bundled text corpus is now stored somewhere a non-root user can reach. [#27651](https://github.com/open-webui/open-webui/issues/27651), [Commit](https://github.com/open-webui/open-webui/commit/0480ca9653f0d566eaedadf0af0d785a9938480b), [#28866](https://github.com/open-webui/open-webui/pull/28866)
- 📋 **Finding notes shared read only.** A note shared publicly for reading now appears in the read only view of your notes, where it was readable by anyone with the link but listed nowhere at all. [#27637](https://github.com/open-webui/open-webui/pull/27637), [#27487](https://github.com/open-webui/open-webui/issues/27487)
- 🫂 **Signing in from another application.** Signing in through an application that exchanges a token from your identity provider now applies your role and group memberships the same way signing in through the browser does, and an account whose provider sends no role keeps the one it has rather than being reset to the default. [Commit](https://github.com/open-webui/open-webui/commit/d799e81edbdc971c6deb096b6474cd95b93504bf), [Commit](https://github.com/open-webui/open-webui/commit/e9684458125c3202ebc3378aef32f0b6015171d8)
- 🗄️ **Empty models section in the sidebar.** The models section no longer appears with nothing in it when every pinned model has since been removed, renamed, or hidden. [#27634](https://github.com/open-webui/open-webui/pull/27634), [#27633](https://github.com/open-webui/open-webui/issues/27633)
- 🚧 **The files pane reopening by itself.** With a terminal selected, closing the files pane now keeps it closed, where saving any setting reopened it, including something as incidental as picking an emoji for a folder. [#28693](https://github.com/open-webui/open-webui/pull/28693), [#28691](https://github.com/open-webui/open-webui/issues/28691), [Commit](https://github.com/open-webui/open-webui/commit/c1c81f8127466a4cd59a16d8658155b1b8faa2d0)
- 🚰 **Watching file processing tying up the database.** Waiting for a file or a knowledge base to finish processing no longer holds a database connection open for as long as the page is watching, which on busy instances could use up every available connection and leave the rest of the application unable to reach the database. [#28183](https://github.com/open-webui/open-webui/pull/28183)
- 🗜️ **Embedding settings for other providers.** Saving your embedding settings now writes only the provider you have selected, where it also overwrote the address and key stored for the other two, losing them if their fields were not filled in. [Commit](https://github.com/open-webui/open-webui/commit/87d9b7e84e71b097eadf1df4f9852359104f17ed)
- ↔️ **Connectors in the side-by-side overview.** With the conversation overview laid out left to right, the lines between messages now join at the sides rather than the top and bottom, so they no longer cut across the boxes. [Commit](https://github.com/open-webui/open-webui/commit/6e468c5b9539d3b6057bab63d81e8d2974acff7b)
- 💫 **Thinking indicator with the fade turned off.** Turning off the fade effect for streaming text no longer hides the thinking indicator and its spinner, which had made a reasoning model look as though it had already finished from the first moment it started. [#28559](https://github.com/open-webui/open-webui/issues/28559), [Commit](https://github.com/open-webui/open-webui/commit/b1dc945bd6cf97a7067e603fc1b797301e626fb6)
- 🈳 **Conversations compacted too early on llama.cpp.** A conversation served by llama.cpp is no longer shortened at roughly half the size you configured, where its cached input was counted twice, so a chat showing 39,000 tokens was treated as 77,000 against a 70,000 limit. [#28590](https://github.com/open-webui/open-webui/issues/28590), [Commit](https://github.com/open-webui/open-webui/commit/0b27fa5e873c9f3cff7b14e4eeab8321bdd8970c)
- ◻️ **Settings tabs spilling past the corner.** Scrolling the list of tabs in settings no longer paints a tab or part of an icon across the dialog's rounded bottom corner, where it appeared to sit outside the dialog. [#27617](https://github.com/open-webui/open-webui/pull/27617), [#27616](https://github.com/open-webui/open-webui/issues/27616)
- 🎰 **Settings for a switched-off function.** A function that has been turned off no longer offers its per-user settings, and saving them is refused, where doing so loaded the function's code and stored settings that had no effect. [Commit](https://github.com/open-webui/open-webui/commit/a3a81fee03ba7ec0ceb4d1f456e80a8f4fc2320f)
- 🗃 **Shared folders reordering themselves.** The list of folders shared with you now keeps a consistent order, where on PostgreSQL renaming a folder could shuffle the ones beside it. [#28804](https://github.com/open-webui/open-webui/pull/28804)
- 📜 **System prompt repeated after a tool call.** On models served by a pipe or manifold, the system prompt is no longer added again each time a tool runs, where it built up one extra copy per round and was sent to the provider that way. [#28739](https://github.com/open-webui/open-webui/pull/28739), [#28736](https://github.com/open-webui/open-webui/issues/28736)
- 🔕 **Calendar reminders stopping for everyone.** A single event whose reminder time was stored as something other than a number no longer stops reminders being sent, for that event or for anyone else's, and falls back to the usual reminder window instead. [#28790](https://github.com/open-webui/open-webui/pull/28790)
- 🗨 **Attached conversations reaching the model.** A conversation attached to your message is now listed among its attachments, where it was left out entirely and the model was never told it was there. [#28788](https://github.com/open-webui/open-webui/pull/28788)
- ↔ **Dragging a side panel closed.** Dragging the chat controls, the note chat, or a channel thread panel closed by its edge no longer floods the browser console with errors and leaves stray handlers behind, and the divider can now be moved with the arrow keys once focused. [#28759](https://github.com/open-webui/open-webui/issues/28759), [Commit](https://github.com/open-webui/open-webui/commit/33dff414e829d10dc6691b1cac7457077fa6799c)
- 🎫 **Saving a message with unusual characters on PostgreSQL.** A message carrying characters PostgreSQL will not store outside its text no longer fails to save, where those characters were cleaned from the conversation but passed through raw to the separate message record. [#28820](https://github.com/open-webui/open-webui/pull/28820)
- 🔲 **Removing an item in the model editor.** Unticking a tool, skill, action, or filter no longer leaves the next one in the list looking unticked while it is still selected, needing two clicks to remove and passing the same confusion down the list each time. [#28837](https://github.com/open-webui/open-webui/pull/28837), [#28832](https://github.com/open-webui/open-webui/issues/28832)
- 📉 **Usage figures drifting upward on clustered setups.** The routine that clears out finished model usage no longer stops for good across the whole cluster after a brief interruption, which had left the usage figures counting models nobody was using and grew the work every disconnection had to do. [#28834](https://github.com/open-webui/open-webui/pull/28834)
- 🔇 **Voice mode staying silent with reasoning models.** Voice mode now speaks when the emoji option is on and the model behind it reports its answer as thinking rather than text, where the whole reply went unspoken and nothing reached the speech service at all. [#28724](https://github.com/open-webui/open-webui/pull/28724)
- 🏷 **Tags on a chat shared with you.** Opening a chat shared with you, or one in a shared folder, no longer fails to load its tags, and an administrator opening someone else's chat now sees the tags that chat actually carries. [Commit](https://github.com/open-webui/open-webui/commit/7d4747dfd73d7629227b10ae63c6854cc7543bee), [#28767](https://github.com/open-webui/open-webui/issues/28767)
- 🔻 **Message box controls in a narrow panel.** Narrowing the note chat panel, or squeezing the chat with a wide controls pane, no longer hides the attach and integrations buttons behind the model name or pushes the send button outside the box; the model name is shortened to make room instead. [#28912](https://github.com/open-webui/open-webui/pull/28912), [#28911](https://github.com/open-webui/open-webui/issues/28911)
- ⌛ **Replies that are all thinking and no answer.** A reply from a provider using the responses format that ends while the model is still in its reasoning, with no answer text after it, now finishes normally instead of failing the whole turn and leaving an unreadable error in place of the reply. [#28872](https://github.com/open-webui/open-webui/pull/28872), [#28871](https://github.com/open-webui/open-webui/issues/28871)
- 📃 **Word and PowerPoint previews overflowing.** Previewing one of these files now keeps the document inside its frame, with the zoom and slide controls staying put rather than scrolling away, and a presentation opens on its current slide instead of below the visible area. [#28878](https://github.com/open-webui/open-webui/pull/28878), [#28877](https://github.com/open-webui/open-webui/issues/28877)
- 🎞 **Workspace models in the admin models list.** Workspace models appear in the admin models list again, so they can be ordered, set as the default, and pinned for everyone; choosing one opens its own editor, where it opened the base model editor and could strip the model's base model, turning it into something else. [Commit](https://github.com/open-webui/open-webui/commit/ccbb3303f2ec5db0b16573bbb665a12f6764da66), [#27702](https://github.com/open-webui/open-webui/issues/27702)
- ⌨ **Errors after sending a long message.** With prompt autocompletion on, sending or clearing a message of several paragraphs within a second of typing no longer throws an error in the browser console. [#28824](https://github.com/open-webui/open-webui/pull/28824), [#28823](https://github.com/open-webui/open-webui/issues/28823)
- ❌ **Tool calls that failed looking successful.** A tool call that returned an error is now marked as failed with a red cross rather than a green tick, so a reply built on a failed call is easier to spot. [Commit](https://github.com/open-webui/open-webui/commit/f3f76095d18e07a3a944f22a4b25993bbffe90a3), [#28016](https://github.com/open-webui/open-webui/issues/28016)
- 🗜 **Download links in a cited source doing nothing.** A link in a citation shown as formatted content now downloads the file when clicked, where it silently did nothing at all. [Commit](https://github.com/open-webui/open-webui/commit/3c66d639e31ba8a7477d337b37d8d671ea2430cf), [#28924](https://github.com/open-webui/open-webui/issues/28924)
- 🗳 **Web searches failing without saying why.** A web search that fails now explains itself instead of returning nothing at all, which most often happens when a search engine has been selected without its key being configured. [#28942](https://github.com/open-webui/open-webui/pull/28942)
- ✅ **Checklists in notes.** A checklist in a note now previews and downloads as a proper checklist, where each item carried a stray second pair of brackets and its text began two lines below the box. [#27671](https://github.com/open-webui/open-webui/pull/27671), [#26067](https://github.com/open-webui/open-webui/issues/26067)
- 🧿 **Shortening a conversation with the wrong model.** Choosing to shorten long conversations with the model you are chatting with now does that, where it used the configured task model instead on any instance that has one. [Commit](https://github.com/open-webui/open-webui/commit/5093a9938937153e287db27a671f5ba1fb5d7592), [#27603](https://github.com/open-webui/open-webui/issues/27603)
- 🖥 **Stopping a reply after the shared cache restarts.** Stopping a reply now keeps working across a cluster after the shared cache restarts or its connection drops, where the part that carries a stop between instances gave up for good and silently, and only restarting the application brought it back. [Commit](https://github.com/open-webui/open-webui/commit/bf3a58dbcd18ddc2c7f130d8f9529477fe7cb042), [#28909](https://github.com/open-webui/open-webui/issues/28909)
- 📼 **Attached links to media and archives.** Attaching a link that leads to something other than a web page, such as a video or an archive, now reads it as the file it is rather than trying to treat it as text. [Commit](https://github.com/open-webui/open-webui/commit/886248de36e3a60c3687d1bee6af110e14110eca)
- 🗒 **Editing a workflow from settings.** Opening the code editor for a ComfyUI workflow from the images settings now brings it to the front, where it opened behind the settings dialog and could not be reached at all. [#27648](https://github.com/open-webui/open-webui/pull/27648), [#27647](https://github.com/open-webui/open-webui/issues/27647)
- 🖼 **Downloading a generated image.** Downloading an image from its preview now saves the image, where it could silently save a small file containing an authentication error instead, and a download that does fail now says so. [Commit](https://github.com/open-webui/open-webui/commit/2578174637e48cafa4bcb09adbb1b7f4b545a8d5), [#27723](https://github.com/open-webui/open-webui/pull/27723), [#27722](https://github.com/open-webui/open-webui/issues/27722)
- 🗂 **Directory sync listing local accounts.** A directory service syncing accounts over SCIM now sees only the accounts that came from a directory, where it also listed and could modify accounts created with a password in Open WebUI itself. [Commit](https://github.com/open-webui/open-webui/commit/fb4f476316a2f83e4d2914d535c19b3440cbc490)
- ↕ **Sorting a list of people.** Sorting the admin user list, or a channel's member list, now works when no search term has been entered, where the chosen order was ignored unless something was being searched for. [Commit](https://github.com/open-webui/open-webui/commit/fb4f476316a2f83e4d2914d535c19b3440cbc490)
- 🧶 **Text dropped from a reply by a filter.** A filter that rewrites a reply as it streams, or a provider that sends something other than plain text in a chunk, no longer causes that part of the reply to vanish without explanation. [#28840](https://github.com/open-webui/open-webui/pull/28840)
- 🏗 **Timers firing twice after a fork.** Branching a conversation that has a timer set no longer leaves the copy able to fire that timer as well. [#27663](https://github.com/open-webui/open-webui/pull/27663), [#27622](https://github.com/open-webui/open-webui/issues/27622), [#27745](https://github.com/open-webui/open-webui/issues/27745)
- 🗣 **Sentences skipped in voice mode.** Voice mode now speaks every sentence of a reply, where any sentence that completed in the same piece of the reply as another was silently never read out, which happened routinely with providers that send whole paragraphs at a time. [Commit](https://github.com/open-webui/open-webui/commit/495296346edfcd96a506a22fed4bb5b8faf3cd4d), [#28730](https://github.com/open-webui/open-webui/issues/28730), [#19861](https://github.com/open-webui/open-webui/issues/19861)
- 🖊 **Dragging a side panel wider than the window.** A side panel can no longer be dragged so wide that the chat beside it is squeezed away, and dragging one below its minimum width now closes it rather than sticking. [Commit](https://github.com/open-webui/open-webui/commit/ef455fcef9d6cb1136275c26e49bc3c5d6661795), [#28965](https://github.com/open-webui/open-webui/issues/28965)
- 🎙 **The wrong model selected when reopening a chat.** Reopening a conversation where a reply was regenerated with a different model now selects the model behind the reply you are looking at, where it picked the one used for the first attempt, which might be a model no longer available. [#27674](https://github.com/open-webui/open-webui/pull/27674), [#25052](https://github.com/open-webui/open-webui/issues/25052), [Commit](https://github.com/open-webui/open-webui/commit/8a170897bad569d93e069226a996942583ebde80)
- 🛜 **Coding tools that speak Anthropic's format.** A tool such as Cline pointed at Open WebUI using Anthropic's own message format can reach models again, where every request failed before it was even sent, and once that was corrected the request was rejected by Anthropic for being signed the wrong way. [#27675](https://github.com/open-webui/open-webui/pull/27675), [#27595](https://github.com/open-webui/open-webui/issues/27595), [#27695](https://github.com/open-webui/open-webui/issues/27695)
- 👯 **Code in pinned messages.** Opening the pinned messages of a channel now shows the code in those messages, where each block appeared empty and its contents were drawn into the channel behind the dialog instead, doubling them there. [#27740](https://github.com/open-webui/open-webui/pull/27740), [#27739](https://github.com/open-webui/open-webui/issues/27739)
- 🧻 **Logs flooded by an unreachable server.** A terminal or tool server that cannot be reached now records one line per attempt rather than a full stack trace, where a few minutes of downtime could fill the log with hundreds of megabytes and drown out everything else. [#27755](https://github.com/open-webui/open-webui/pull/27755), [#27751](https://github.com/open-webui/open-webui/issues/27751), [#27757](https://github.com/open-webui/open-webui/pull/27757), [#27756](https://github.com/open-webui/open-webui/issues/27756)
- 🖱 **Clicking inside a chat preview.** Clicking an image or a source in the preview that appears when you hover a chat in the sidebar no longer flashes a viewer open and shut, since the preview is meant only to be read. [#27770](https://github.com/open-webui/open-webui/pull/27770), [#27769](https://github.com/open-webui/open-webui/issues/27769)
- 🛢 **Using pgvector with database access by role.** An instance on Amazon RDS that signs in to its database with a temporary credential rather than a stored password now starts when pgvector is the vector store, where the two could not be used together and the container exited on startup. [#27754](https://github.com/open-webui/open-webui/pull/27754), [#27752](https://github.com/open-webui/open-webui/issues/27752)
- 📗 **Opening knowledge attached to a model or folder.** Clicking a knowledge item attached to a model or a folder opens it again, so it can be read and its retrieval mode changed between focused retrieval and the whole document, where since 0.11.0 neither was possible outside a chat. [#27686](https://github.com/open-webui/open-webui/pull/27686), [#27684](https://github.com/open-webui/open-webui/issues/27684), [#27801](https://github.com/open-webui/open-webui/issues/27801), [#28825](https://github.com/open-webui/open-webui/issues/28825)
- 🎚 **Retrieval mode shown from another item.** The retrieval mode shown when opening a knowledge item is now that item's own, where it could show the setting of whichever item was opened before it. [#27686](https://github.com/open-webui/open-webui/pull/27686), [#27684](https://github.com/open-webui/open-webui/issues/27684), [#27801](https://github.com/open-webui/open-webui/issues/27801), [#28825](https://github.com/open-webui/open-webui/issues/28825)
- 🔩 **A new chat shown as nearly full.** The indicator of how full a conversation is now counts tokens the same way the shortening does, where the two read different figures from providers that report both and a fresh chat could appear close to its limit. [#27620](https://github.com/open-webui/open-webui/pull/27620), [#27608](https://github.com/open-webui/open-webui/issues/27608), [Commit](https://github.com/open-webui/open-webui/commit/978d2572140e4fe31ebbc33274f699c71b8cdc29)
- 🫱 **Abandoned changes to a group's sharing setting.** Closing the edit dialog for a user group without saving now discards a change to who can share to that group, where the change stayed on screen and was written to the database the next time anything else about the group was saved. [#28076](https://github.com/open-webui/open-webui/pull/28076), [#28075](https://github.com/open-webui/open-webui/issues/28075)
- 📛 **Editing the wrong group.** The dialog for editing a user group now stays with the group it was opened for, where a reordering of the list beneath it could leave it saving to a different group. [#28076](https://github.com/open-webui/open-webui/pull/28076), [#28075](https://github.com/open-webui/open-webui/issues/28075)
- 🪢 **Signing people out from the identity provider.** A sign-out sent by an identity provider to end someone's session now works, where the check that the message was genuine could fail against providers whose signing keys need the same authentication as everything else, leaving the person signed in. [Commit](https://github.com/open-webui/open-webui/commit/aeda6ff13a25d3b3ba1b303609f35382db22142c)
- 🪣 **Files left behind when a knowledge base is emptied.** Emptying a knowledge base now removes the files it held, along with their stored copies and their search data, where all three were left behind with nothing in the interface to clear them. [Commit](https://github.com/open-webui/open-webui/commit/363ad352fec9553469852d111bc0506b896504a6), [#27988](https://github.com/open-webui/open-webui/issues/27988)
- ♻ **Blank errors when a tool server's saved sign-in cannot be read.** A tool server whose stored sign-in details cannot be decrypted, which happens when "WEBUI_SECRET_KEY" changes since the key protecting them follows it, now names the server and says to reconnect it, where every startup logged two errors with no message at all. [Commit](https://github.com/open-webui/open-webui/commit/91917b23952af8ca4457f8bb3db70c75ab838fbf), [#28666](https://github.com/open-webui/open-webui/pull/28666), [#28665](https://github.com/open-webui/open-webui/issues/28665)
- 🖍 **Clearing the supported media types.** Emptying the supported media types in the documents settings now stays empty, where the previous value came back on the next visit, so images kept being sent to the extraction engine instead of straight to a model that can read them. [#28750](https://github.com/open-webui/open-webui/pull/28750), [#28747](https://github.com/open-webui/open-webui/issues/28747), [Commit](https://github.com/open-webui/open-webui/commit/ecad20b77f9bd700bd818a2f447176f5994320c3)
- 🫳 **Dropping a chat where it already was.** Dragging a chat in the sidebar and releasing it where it started no longer reloads the whole sidebar, which took around a dozen requests for a move that changed nothing. [#28664](https://github.com/open-webui/open-webui/pull/28664), [#28663](https://github.com/open-webui/open-webui/issues/28663)
- 🖲 **Filtering while on a later page.** Changing a filter in the workspace, such as showing only what you created, now returns to the first page, where the list could come back empty with the page controls gone and no way back. [#28734](https://github.com/open-webui/open-webui/issues/28734), [Commit](https://github.com/open-webui/open-webui/commit/a914868e3c83b08be1399dbae1e4da25a0c00c87)
- 🪺 **Workspace counts left behind.** The number beside each workspace tab now follows its list, where creating, copying, importing, or deleting something left the old number in place until you moved to another tab or reloaded, and the tools count ignored its search entirely. [#28983](https://github.com/open-webui/open-webui/pull/28983), [#28981](https://github.com/open-webui/open-webui/issues/28981)
- 📞 **Links that start a voice call.** Opening a link that starts a call now starts one, where it opened the controls pane and stopped there, leaving the only way to begin a call from outside the application broken. [#28721](https://github.com/open-webui/open-webui/pull/28721), [#28677](https://github.com/open-webui/open-webui/issues/28677), [Commit](https://github.com/open-webui/open-webui/commit/8c1f3d382470edb81fa1efdf6cf4fb6bb188c460)
- 🆔 **Signing in where the provider uses a numeric account id.** Signing in through GitHub, or any other provider that identifies people by a number, works again on PostgreSQL, where every attempt failed outright since 0.10.2, and on SQLite the existing account was not matched; accounts stored the old way are corrected on the next sign-in. [Commit](https://github.com/open-webui/open-webui/commit/a6834f089bc2980fced99a617670e785c988cc9f), [#28954](https://github.com/open-webui/open-webui/pull/28954), [#27760](https://github.com/open-webui/open-webui/issues/27760)
- ☎ **Chats opening halfway up.** A conversation whose recent messages are short now opens at the latest message rather than somewhere in the middle, and older messages loaded while scrolling up no longer shift what you were reading. [#28657](https://github.com/open-webui/open-webui/pull/28657), [#28656](https://github.com/open-webui/open-webui/issues/28656)
- 🛤 **The terminal picker vanishing mid-reply.** The terminal picker now stays in place while a reply is being written, greyed out until it finishes, where it disappeared from the message box entirely and took the name of the selected terminal with it. [Commit](https://github.com/open-webui/open-webui/commit/7a533d0d5b85981f8c5668636f931f8b4a97d604)
- 🎧 **Who a channel says its members are.** The member list of a channel now includes its owner and everyone in a group that was granted access, where the owner never appeared and granting both a person and a group they were not in listed nobody at all while the count beside it said two. [#28289](https://github.com/open-webui/open-webui/pull/28289), [#28288](https://github.com/open-webui/open-webui/issues/28288), [Commit](https://github.com/open-webui/open-webui/commit/e3e82b14714661ba5bd24e2c47e42cef5594b132)
- ⛳ **Embedding servers behind a password.** An embedding server protected by a username and password rather than a key now works, where an empty key still sent an authorisation header, which such a server rejected and which made document uploads fail. [#28684](https://github.com/open-webui/open-webui/pull/28684), [#28683](https://github.com/open-webui/open-webui/issues/28683), [Commit](https://github.com/open-webui/open-webui/commit/97466deea105d6fdde60bf4d9401703fd32e03e9)
- ✒ **Note edits lost without warning.** Typing in a note now reaches the database, where a save already waiting could be cancelled without a replacement, leaving the editor showing text that was never stored and nothing to say so; this affected notes written by a model or through the API, and any instance without Redis after a restart. [#28669](https://github.com/open-webui/open-webui/pull/28669), [#28667](https://github.com/open-webui/open-webui/issues/28667)
- 🗄 **Browsing outside the home directory in a terminal.** A terminal server set to allow browsing the whole filesystem can be browsed above the home directory again, where the file panel pinned itself there and opening a file elsewhere quietly did nothing. [#29006](https://github.com/open-webui/open-webui/pull/29006), [#29000](https://github.com/open-webui/open-webui/issues/29000)
- 🏞 **Clearing a model's picture.** A model's picture can be reset to the default logo again, where since 0.11.0 a custom one could only ever be replaced. [#29007](https://github.com/open-webui/open-webui/pull/29007), [#27685](https://github.com/open-webui/open-webui/issues/27685), [Commit](https://github.com/open-webui/open-webui/commit/06e7aac219b3027a9d578624c473b041ad26a692)
- 👤 **Fallback profile pictures.** A profile picture that fails to load, such as one belonging to a deleted account, now falls back to the default avatar instead of showing clipped placeholder text beside the message. [#28270](https://github.com/open-webui/open-webui/pull/28270), [#28269](https://github.com/open-webui/open-webui/issues/28269)
- ⏱️ **Unanswered prompts in tools.** On deployments that set "WEBSOCKET_EVENT_CALLER_TIMEOUT", a question a tool asks you that goes unanswered now reports a timeout rather than an empty reply, and waiting too long no longer risks disconnecting a tab that is still open. [#28311](https://github.com/open-webui/open-webui/pull/28311)
- 👪 **Group member counts updating.** Adding or removing someone from a group in the admin panel now updates that group's member count straight away, where it stayed at the old number until the page was reloaded. [Commit](https://github.com/open-webui/open-webui/commit/18bf0ade7b35fb25ff7920ad3713624d93f04401)
- 🕳️ **Blank messages left in a conversation.** Streamed events that never fill in an item no longer leave an empty assistant message saved in the conversation and sent back to the model on every later turn. [Commit](https://github.com/open-webui/open-webui/commit/2a0274a0a039dbe0a1ad4d24003b085aae7b896b)
- 📓 **Notes opening blank.** A note whose shared editing session has not been started yet now opens with its stored content even when several people open it at once, where previously anyone but a lone first viewer got an empty document. [Commit](https://github.com/open-webui/open-webui/commit/5078d987f83943671f1d23ebe66bcc8d2af902c3)
- 🤫 **Replies stopping silently after a tool ran.** When a provider rejects the follow-up request made after a tool finishes, the reason is now shown in the chat instead of the reply simply ending with nothing said. [Commit](https://github.com/open-webui/open-webui/commit/a610d77137fabf60a2e0daa961a8ef8f7320293a), [#28633](https://github.com/open-webui/open-webui/issues/28633)
- 🎟️ **Connections whose tags were saved as plain text.** A connection with tags stored as plain text no longer breaks its editor panel or silently blanks the tags on every model coming from it. [Commit](https://github.com/open-webui/open-webui/commit/8be4c5fa6a849a9ff4a74f6a2870a115a836e68a), [#28749](https://github.com/open-webui/open-webui/issues/28749)
- 🔌 **Stream filters on direct API calls.** A filter reading a streamed event as an object now works on requests made straight to the chat completions endpoint, matching every other path, where it used to raise and end the reply partway. [Commit](https://github.com/open-webui/open-webui/commit/684111715f742f56a3c7efac9eb7a1e68e54a3ec)
- 🔇 **Filter failures that said nothing.** When a filter's outlet or stream hook raises, the failure is now reported with the filter's name and a traceback at the default log level, where it was swallowed and left plugin authors with nothing to go on. [Commit](https://github.com/open-webui/open-webui/commit/35fbde0a3fb303b7076b03465b1a2b0ba832df53)
- 🪜 **Falling back when a base model is gone.** Chatting with a workspace model whose base model has been removed now falls back to the default model for everyone, where the fallback previously applied only for administrators and anyone else was refused whenever that base model had no workspace entry of its own, such as one supplied by a pipe. [Commit](https://github.com/open-webui/open-webui/commit/20fe43d9da621957c48fc92104bc8f1cc0d691b7)
- 🧠 **Conversations breaking after a model switch.** Switching to a different model once a reasoning model has answered no longer leaves the conversation unusable, where the earlier model's stored reasoning was replayed to a provider that then rejected every later request. [Commit](https://github.com/open-webui/open-webui/commit/c4b3e6840f34a5d267bc12c0ec1a82156a324d45), [#28240](https://github.com/open-webui/open-webui/issues/28240)
- 👻 **Messages disappearing from a conversation.** Two things saving the same conversation at once no longer discard each other's changes, where a message could vanish from the screen while still counting toward the model's context and a file attached during a reply was lost on the next save. [Commit](https://github.com/open-webui/open-webui/commit/1c13fedb16e74c5888c52ccd928a5eb5bbb1068d), [#28742](https://github.com/open-webui/open-webui/issues/28742)
- 🧬 **Workspace models pointing at themselves.** A workspace model whose base model was set to its own identifier is now saved without that reference, where the entry was thrown away while models were being combined so none of its settings ever took effect. [Commit](https://github.com/open-webui/open-webui/commit/eadce55e343df69520e6bc86dbbdfdcdfdcafb30), [#28952](https://github.com/open-webui/open-webui/issues/28952), [#28923](https://github.com/open-webui/open-webui/issues/28923)
- 📭 **A short model list sticking on every worker.** When one worker briefly reports fewer models than it should, that shorter list is no longer left in place for the whole deployment, where chatting with one of the missing models failed on every server until something restarted. [Commit](https://github.com/open-webui/open-webui/commit/6330350a406d8c1fd603f725e59a88324c8e2256), [#28777](https://github.com/open-webui/open-webui/issues/28777)
- 📋 **Copying from the terminal file browser.** Copying a file path or a file's contents now works again, where it silently did nothing on deployments the browser does not treat as a secure origin. [Commit](https://github.com/open-webui/open-webui/commit/aa3d56961049618b28717d87acff1d9360cad47a), [Commit](https://github.com/open-webui/open-webui/commit/2f97c9fce36b3794d14c925086eca78ff92adf4a), [#29015](https://github.com/open-webui/open-webui/issues/29015)
- 🗺️ **Browsing the web through Microsoft Web IQ.** Fetching a page with the web loader set to Microsoft Web IQ now works, where every attempt failed before a single request was made and had done so ever since that loader was added. [Commit](https://github.com/open-webui/open-webui/commit/6dcc2d52692c3fa1993ff1b3ae84c07d35fd9f6b), [Commit](https://github.com/open-webui/open-webui/commit/140d2cf4b59e71d2e9f4986d4d8649fccb47c83d), [#28688](https://github.com/open-webui/open-webui/issues/28688)
- 🔛 **Enable or disable all automations only reaching the ones on screen.** Turning every automation on or off now covers every automation matching your current search and filter, where it only ever touched the ones loaded on the page you were looking at and left the rest running as they were. [Commit](https://github.com/open-webui/open-webui/commit/f4a0d3c9734d1662a3f78891f21934f2b82aed1e)
### Changed
- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
- 🏋️ **What "THREAD_POOL_SIZE" now sizes.** The setting now governs both of the pools that background work runs in, where it previously governed only one and the other, carrying most of the blocking work including knowledge searches, sign-ins and file storage, was fixed at a small ceiling no setting could raise, so an instance that set it high will now use more threads than before, up to twice the configured value across the two pools, and one that relied on the old ceiling to hold thread use down should review it. [Commit](https://github.com/open-webui/open-webui/commit/4ec6ee14418edd04eaba9e34bd5868453f61df40), [#28168](https://github.com/open-webui/open-webui/issues/28168)
- 💾 **Saving replies as they stream.** The "ENABLE_REALTIME_CHAT_SAVE" setting no longer has any effect, because a reply in progress is now held outside the database and written once when it finishes. [Commit](https://github.com/open-webui/open-webui/commit/a1579a01ff43cacb357269707d36267ad35e01d6)
- 🎭 **Playwright web loader egress.** When a page is fetched with the Playwright loader, the page's own requests for its images, scripts, and stylesheets are now made by the Open WebUI backend instead of by the browser, so administrators using a remote browser through "PLAYWRIGHT_WS_URL" should expect that traffic to leave from the backend's address rather than the browser host, those using a private certificate authority should expect it to be trusted through "AIOHTTP_CLIENT_SSL_CERT_FILE" rather than the browser's own store, and those who set a proxy on the loader should know it no longer applies to these requests, which follow the environment's proxy settings instead. [#28634](https://github.com/open-webui/open-webui/pull/28634)
- 🐌 **Slower Playwright page loads without async.** A page fetched with the Playwright loader on the synchronous path now fetches its images, scripts, and stylesheets one at a time rather than together, which in the change's own measurements took a page with thirty assets from 2.0 to 3.0 seconds, and one with eight slow assets from 1.1 to 4.5 seconds; the asynchronous path is unaffected. [#28634](https://github.com/open-webui/open-webui/pull/28634)
- 🐳 **Test-only packages removed from the image.** The container no longer ships pytest, pytest-docker, the Docker SDK, or netcat, none of which anything in Open WebUI used, so the image is smaller; anyone whose own tools or functions relied on those being present will need to install them themselves. [#28726](https://github.com/open-webui/open-webui/pull/28726)
- 🛡 **Forms in embedded pages now work by default.** A page shown inside a chat, such as an artifact or an HTML preview, may now submit forms unless you turn that off, where it was blocked unless you turned it on. [Commit](https://github.com/open-webui/open-webui/commit/3c66d639e31ba8a7477d337b37d8d671ea2430cf)
- 🅰 **Connection prefixes now show in model names.** A connection's prefix appears in the name shown in the model picker, not only in its identifier, where whether it did depended on the shape of the provider's reply and so worked on some connections and not others; models on prefixed connections will now read differently than before. [#28950](https://github.com/open-webui/open-webui/pull/28950), [#28929](https://github.com/open-webui/open-webui/issues/28929)
- 📊 **Default usage statistics range.** Usage statistics now cover the past two years by default for everyone, instead of starting from the date the account was created. [Commit](https://github.com/open-webui/open-webui/commit/8dbbc206c5a0706789722c42827479e5db10bb2b)
## [0.11.0] - 2026-07-27
### Added
- 🎨 **Redesigned interface.** Open WebUI has been visually rebuilt from the ground up. All aspects of the User Interface, from the chat view to the admin panel. Now with a narrower conversation column, lighter typography, tidier spacing, consistent menus and dropdowns, clearly outlined text boxes, and settings rearranged. [Commit](https://github.com/open-webui/open-webui/commit/aedb6bef4e2eb12234c02085a545ff395d96db18), [Commit](https://github.com/open-webui/open-webui/commit/b3255a36569f295766271b8a2b0bd969b4083b9f), [Commit](https://github.com/open-webui/open-webui/commit/ba067258dea2229a9956077b3b0d7b1c68b56f66), [Commit](https://github.com/open-webui/open-webui/commit/8dd862d3383978f21111e63fb2d6029711abed9a), [Commit](https://github.com/open-webui/open-webui/commit/263bbc77d803e83b9af4b04c0cae29705af5f072), [Commit](https://github.com/open-webui/open-webui/commit/f8ea15b84a274712dca33daa970f63ed7368043e), [Commit](https://github.com/open-webui/open-webui/commit/9f17c5960a0e47a09773da4bba12997a31222fc8), [Commit](https://github.com/open-webui/open-webui/commit/6772b1cb4f4e0d3dc166956014e6e7b9bddc721a), [Commit](https://github.com/open-webui/open-webui/commit/d3fd860c131846a9458888f9c256a9a29f3767f2), [Commit](https://github.com/open-webui/open-webui/commit/f1584b5a3764f72de2de6caad507e7c39ad19c23), [Commit](https://github.com/open-webui/open-webui/commit/2e8d92c7b1a9bb8d35f4a27ba3c73368d735c480), [Commit](https://github.com/open-webui/open-webui/commit/e58a4633b15ae53d33fc3b46cb97c76d86be325f), [Commit](https://github.com/open-webui/open-webui/commit/04b146f2cec7e6a01e9d3590eb83655c128fa3c7), [Commit](https://github.com/open-webui/open-webui/commit/e5e2cd78769639b2df83776f1b991966f922f8b4), [Commit](https://github.com/open-webui/open-webui/commit/3316ba76aabe5429596ffd130fd36be4d5c3aa6c), [Commit](https://github.com/open-webui/open-webui/commit/6fcb38fe2e0aded9b85f655cd8f3279e9f4e765e), [Commit](https://github.com/open-webui/open-webui/commit/421da674468f638f72cc5266c5a3874aa3bca3b7), [Commit](https://github.com/open-webui/open-webui/commit/d0bea60581eaa07d41f92ad8f86007e83247e061), [Commit](https://github.com/open-webui/open-webui/commit/21e180182a5096481d4cbb1a8f94212c0a515a40), [Commit](https://github.com/open-webui/open-webui/commit/d027a32ed134ae104f2f142ba45ff38e56215c5f), [Commit](https://github.com/open-webui/open-webui/commit/437c06c4795a72700295d7690d5fd65d1153372c), [Commit](https://github.com/open-webui/open-webui/commit/1bf05ebc7d135d74969438824778c9f73243ba8d), [Commit](https://github.com/open-webui/open-webui/commit/fd07e3a8e3e619f3712067765b416f0925fa80d3), [Commit](https://github.com/open-webui/open-webui/commit/d3ea51fd466a8741afc4dfd4f0d0f2f77fb6467f), [Commit](https://github.com/open-webui/open-webui/commit/4da2ff2655d9abb851805da127cf60b4d9ad1aa7), [Commit](https://github.com/open-webui/open-webui/commit/2fcb36267f034f2b83f936bfacedc20b680a2710), [Commit](https://github.com/open-webui/open-webui/commit/1428a4ddce4998cb3664a5ce37e176442dd426fa), [Commit](https://github.com/open-webui/open-webui/commit/bc8d24c951e9a2c973fc2dd1f2832a2b0855bc0e), [Commit](https://github.com/open-webui/open-webui/commit/704d07e9a20a830aad7bfc5131b0d92621cf0691), [Commit](https://github.com/open-webui/open-webui/commit/e88d2e053c2a63cce3823c9b6006f4a184c4fef2), [Commit](https://github.com/open-webui/open-webui/commit/6940297486d4a5de127efd1a5148b0adcebe87e3), [Commit](https://github.com/open-webui/open-webui/commit/9ca8cf528af1c49da3f0a2bc3c6ca95c1dedbcf5), [Commit](https://github.com/open-webui/open-webui/commit/c4efa81d08c425678c810c51b4d62716e1e57117), [Commit](https://github.com/open-webui/open-webui/commit/bb12b1a18b77d80829cedb2d5bf965808222415b), [Commit](https://github.com/open-webui/open-webui/commit/49abfbdd155dc22882fdcb09989e4f4964db16ee), [#27178](https://github.com/open-webui/open-webui/pull/27178), [Commit](https://github.com/open-webui/open-webui/commit/dcc7fb1e8ef144205531829f8a56e52171c4d63d), [Commit](https://github.com/open-webui/open-webui/commit/5c505c1119fec6170c1bc092ed162f86262a887e)
- 🤖 **Sub-agents.** Administrators can now enable sub-agents, which let a model hand parts of a task to background helper agents that run their own tool-driven conversations and report results back into the chat, tuned through new "ENABLE_SUBAGENTS", concurrency, iteration, and system-prompt settings. [Commit](https://github.com/open-webui/open-webui/commit/7088d245bb45fc69c0b22748563b9f3c6f0daa73), [Commit](https://github.com/open-webui/open-webui/commit/2f37e853d1259a901f736a823bad29dcc2c3b130), [Commit](https://github.com/open-webui/open-webui/commit/959558fd82eb2a3c980231acd500b73ba4b698b3), [Commit](https://github.com/open-webui/open-webui/commit/3005b7bc71fcbd5abc6e73c3e4caa4ea781cdb76)
- 📂 **Folder pages.** Opening a folder now takes you to its own page, where its chats load a page at a time, can be sorted by title or last updated, and you can start a new chat straight from the folder. [Commit](https://github.com/open-webui/open-webui/commit/409fb39717be9ab7becd9e8c01801a08c5bae318)
- ⏲️ **Chat timers.** The assistant can now set a timer that brings a prompt back into the conversation later, after a delay or at a set time, and can drop it automatically if you read the chat or reply before it fires. [Commit](https://github.com/open-webui/open-webui/commit/b23ddeb2800098c6352203ec8fbe9fca40ba415c)
- 🔔 **Notification targets.** Notifications now have their own settings tab where you can send them to several webhook destinations, each picking which events it wants, from chats finishing or failing to channel messages and calendar alerts, with a test button and a choice between always notifying or only when you are away, and any webhook you already had is carried over for you. [Commit](https://github.com/open-webui/open-webui/commit/c55e373b994d3a14c99a97f44261422012f63266), [Commit](https://github.com/open-webui/open-webui/commit/cf235738f5a44db415012b3b0ebc1f6e752f5439), [Commit](https://github.com/open-webui/open-webui/commit/200d447f6289faca42f2a666bbabae2c7f3ebadf), [#24750](https://github.com/open-webui/open-webui/issues/24750)
- 🗯️ **Full replies in channels.** A reply from the assistant in a channel is now saved and shown in full, with its reasoning, tool calls and other structured parts, where it previously came through blank. [Commit](https://github.com/open-webui/open-webui/commit/498cdab9a548d7d2fd19c389204ee26236fc7efe), [#26720](https://github.com/open-webui/open-webui/pull/26720), [#27409](https://github.com/open-webui/open-webui/pull/27409), [#26707](https://github.com/open-webui/open-webui/issues/26707), [#26656](https://github.com/open-webui/open-webui/issues/26656)
- 📣 **Notifications from the assistant.** The assistant can now send you a notification itself when something is worth your attention, so a long task can reach you after you have moved on to something else. [Commit](https://github.com/open-webui/open-webui/commit/c55e373b994d3a14c99a97f44261422012f63266), [Commit](https://github.com/open-webui/open-webui/commit/200d447f6289faca42f2a666bbabae2c7f3ebadf)
- 🌎 **Share a chat with anyone holding the link.** A shared chat can now be set to Open so it opens without signing in, with visitors no longer bounced to the sign-in page on their way to it, which administrators must first allow through a new "Chats Open Sharing" permission that stays off by default, and such pages ask search engines not to index them. [Commit](https://github.com/open-webui/open-webui/commit/1f0dc90abe879a55f654f2333e29fb0f630831c7), [Commit](https://github.com/open-webui/open-webui/commit/0e0d08382ac0d05b1ad98c47e8a2e37df2a185bb)
- 🔖 **Chat variables.** A model's system prompt can now declare fields such as text boxes and dropdown lists that you fill in for a conversation, with the values saved alongside the chat and carried over when it is forked or cloned. [Commit](https://github.com/open-webui/open-webui/commit/bef8ae4b2f05ca49ed88a02ab7a3cdc11b62c4f1), [Commit](https://github.com/open-webui/open-webui/commit/4e869011cd5040b5d6a197fc83d5f50d2425dbc2), [Commit](https://github.com/open-webui/open-webui/commit/1e88367cc837b39c0e9958fefbe053803336dce2), [Commit](https://github.com/open-webui/open-webui/commit/8cbb7f765cfc9c9b3237a6c5593cd93f849033f0), [Commit](https://github.com/open-webui/open-webui/commit/b35e2d265a4e4a2f2a075b31917d48be1dd9ef19), [Commit](https://github.com/open-webui/open-webui/commit/239cb740077a14e452ad002a1e671a09ab558e40), [#26915](https://github.com/open-webui/open-webui/discussions/26915)
- 🗄️ **LDAP group synchronization.** Administrators can now map LDAP groups to Open WebUI groups from the authentication settings, with optional automatic creation of missing groups, so a user's group memberships are kept in step with the directory each time they sign in. [#27263](https://github.com/open-webui/open-webui/pull/27263), [#18015](https://github.com/open-webui/open-webui/issues/18015)
- 👥 **Restrict sharing with groups.** Admins can now stop resources from being shared with entire groups through a new "USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS" permission, which stays enabled by default so existing group sharing keeps working untouched. [Commit](https://github.com/open-webui/open-webui/commit/4ed19d504bd30c0fc801e9228d9816669ec1c09c), [Commit](https://github.com/open-webui/open-webui/commit/f84dabe3d97ff701c097055023f28f3f2f7ebd07), [Commit](https://github.com/open-webui/open-webui/commit/77da3d8c81b9a6fda4354619de94f5d433328d8e), [Commit](https://github.com/open-webui/open-webui/commit/84e4d6ef8277f4b4f3ac4d355b3219e9b5a37268), [#27124](https://github.com/open-webui/open-webui/pull/27124)
- 🤝 **Shared folder collaboration.** People with access to a shared folder can now use its files and system prompt as knowledge in chat and, with write access, rename and manage the folder, all according to their read or write permission. [Commit](https://github.com/open-webui/open-webui/commit/797293c74957bd79e42262d1dc0fd637a45d0357), [Commit](https://github.com/open-webui/open-webui/commit/caa2457c17e592587b804f21054cc000944af75c), [Commit](https://github.com/open-webui/open-webui/commit/009715cd63d1c8b5320aba68e9a70afcde519016), [Commit](https://github.com/open-webui/open-webui/commit/53ccd718a53de25bb6d61476a6617bfb3f130a44)
- 👁️ **Chat previews in the sidebar.** Hovering a chat in the sidebar now shows a compact preview of its recent messages, so you can find the conversation you want without opening it. [Commit](https://github.com/open-webui/open-webui/commit/d0f7da4f45b8831b09b2ab3ec8f91aa354d90ba3), [Commit](https://github.com/open-webui/open-webui/commit/aaf2834db758bfec69408ab4cabcf324965c221c), [Commit](https://github.com/open-webui/open-webui/commit/1513ddaf58fe18029086880461d7cad0649a699c), [Commit](https://github.com/open-webui/open-webui/commit/93bd05271c07c249978d69abf3297fd2841900f9)
- 🕗 **Local message timestamps.** Message timestamps now appear on hover in your device's local date and time format, with the full weekday and date shown in a tooltip. [Commit](https://github.com/open-webui/open-webui/commit/797293c74957bd79e42262d1dc0fd637a45d0357), [Commit](https://github.com/open-webui/open-webui/commit/f84dabe3d97ff701c097055023f28f3f2f7ebd07)
- 📇 **User variables.** You can now store your own values in account settings, such as your role or how you like answers written, and a model's system prompt can insert them wherever they are needed. [Commit](https://github.com/open-webui/open-webui/commit/bd5d7b2e879511882429075d804d9222956f4a1a), [Commit](https://github.com/open-webui/open-webui/commit/212eec408ca320edfa2271e604d10e14b6a9bc1a), [Commit](https://github.com/open-webui/open-webui/commit/793a43d9c48225925929eb312fe2b70d5914d1da)
- 🧺 **Automations that file their chats away.** An automation can now be pointed at one of your folders, from the dialog, the editor or by asking the assistant, so each run lands there instead of loose in your chat list, and the folder is cleared automatically if it is later deleted. [Commit](https://github.com/open-webui/open-webui/commit/f798d05586a140f1a6b51f1e51b2b2a63d079d45), [Commit](https://github.com/open-webui/open-webui/commit/bab71ed08b5af6f4a8ff2daa02792baae9edab03), [Commit](https://github.com/open-webui/open-webui/commit/db5c092299471444c356216d5ef39b382ba1aa1e)
- 🔵 **See what you have not read yet.** Folders in the sidebar now carry a count of chats with something new in them, a folder's own page marks unread chats with a dot, shows a spinner on any still generating, clears the dot as you open one, and keeps itself up to date as replies finish elsewhere, unread chats sort to the top of a folder, and you can mark a single chat unread again mark everything in a folder read, or mark every chat read at once from the sidebar. [Commit](https://github.com/open-webui/open-webui/commit/f798d05586a140f1a6b51f1e51b2b2a63d079d45), [Commit](https://github.com/open-webui/open-webui/commit/f867825bf3b7699bc2bd967ef46b2bb63e48b098), [Commit](https://github.com/open-webui/open-webui/commit/1de36d600f7191c28a98bf4b347b44cf8f1bef43), [Commit](https://github.com/open-webui/open-webui/commit/85c47fb467177ed811ba77dfe62461dfbe8e2548), [Commit](https://github.com/open-webui/open-webui/commit/b7489bbc6c4e376c017edffd8da3c2eb4e6c1c8e), [Commit](https://github.com/open-webui/open-webui/commit/3cd72ee6a8e93dc39a4d4c173117056e25326c8a), [Commit](https://github.com/open-webui/open-webui/commit/6f93ecd4fd77b0d51a5fbbd2fc3fd6d151036a55), [Commit](https://github.com/open-webui/open-webui/commit/e5a08d52208e8b1ed07ff94906e27d174146b1ca), [Commit](https://github.com/open-webui/open-webui/commit/8ddf119570b3c0b04b673d41cb2363370c23939b)
- 🗜️ **Compact a chat on demand.** Typing a compact command in a long conversation now summarizes the earlier turns straight away, instead of waiting for it to happen automatically once the conversation grows past the threshold. [Commit](https://github.com/open-webui/open-webui/commit/7a9928ef172b7c280c377c86cb52957e39340158), [Commit](https://github.com/open-webui/open-webui/commit/75894161e46aafe30a7db4ecc946af60f04495e4)
- 🌿 **Fork a chat.** Every response now has a fork button that copies the conversation up to that point into a new chat which remembers where it branched, so you can carry on down a different path without touching the original. [Commit](https://github.com/open-webui/open-webui/commit/63ada247066dfc51e0e9559366f0cfd9a98db40b), [Commit](https://github.com/open-webui/open-webui/commit/cf887b68ea58bcd1d8b035842c4ea35a5113ed8b), [Commit](https://github.com/open-webui/open-webui/commit/73421c5b42ac5c2ebc5faa520b7ed3fa0e39f10d), [Commit](https://github.com/open-webui/open-webui/commit/e769f9ff4f9fa7b0faebdd4f34ff98fe0dcc300d)
- 📌 **Pin the conversation map.** The chat overview now has a pin control that stops it recentring on the newest message, so you can keep looking at the branch you were reading while a reply comes in. [#25736](https://github.com/open-webui/open-webui/pull/25736)
- 📊 **Chat status at a glance.** The slash menu now shows how full the context window is, and a new status command opens a panel with context usage, queued messages, running tasks, and the chat ID. [Commit](https://github.com/open-webui/open-webui/commit/7a9928ef172b7c280c377c86cb52957e39340158), [Commit](https://github.com/open-webui/open-webui/commit/263bbc77d803e83b9af4b04c0cae29705af5f072)
- 🎹 **Customizable keyboard shortcuts.** Most keyboard shortcuts can now be rebound to key combinations of your choosing in settings, which saves them to your account, warns you when two actions share a combination, and offers a reset to the defaults, with moving to the previous or next chat and opening the controls panel available to bind as well. [Commit](https://github.com/open-webui/open-webui/commit/343eb1d659262cc9d762a2ce49bef25434a03bfa), [Commit](https://github.com/open-webui/open-webui/commit/de681aa543b356d456b83c13ad88c7f9941319e7), [#26624](https://github.com/open-webui/open-webui/pull/26624)
- ⌨️ **Turn keyboard shortcuts off.** A new switch in the keyboard settings disables every configurable shortcut and hides its hint, so combinations that clash with your browser or operating system pass straight through. [#27300](https://github.com/open-webui/open-webui/pull/27300), [#1008](https://github.com/open-webui/open-webui/issues/1008)
- ⌨️ **Skills in slash commands.** Typing a slash in the message input now lists your skills alongside your prompts, grouped under headings and with descriptions on hover, so you can attach a skill without leaving the keyboard. [Commit](https://github.com/open-webui/open-webui/commit/9588c97e64e10d161a9a0ab1ab9ba3fee6cbb94d)
- 📎 **Attach anything with the at menu.** Typing an at sign in the message input now searches your folders, knowledge collections, and individual files as well as your models, and pasting a link offers it as a web page or YouTube attachment. [Commit](https://github.com/open-webui/open-webui/commit/e8b4c7f9e212253267b8c79dc5360894f0d91bec)
- 📝 **Chat with a note.** Chatting with a note now gives you the full chat experience, including model choice, tools and file attachments, alongside suggested prompts, a button to insert a response straight into the note, edits that appear in the note as the assistant makes them, and as many separate conversations per note as you want to keep. [Commit](https://github.com/open-webui/open-webui/commit/423cafd4e75e34b487f3b5d10ec1c506f073b3da), [Commit](https://github.com/open-webui/open-webui/commit/185bca8552ee3f87ea95fdcad32a433924881b9a)
- ↕️ **Sort your lists.** The notes, prompts, models, knowledge, skills, tools and functions lists can now be sorted by title or by when they were last updated, in either direction, by clicking the column headings. [Commit](https://github.com/open-webui/open-webui/commit/30c91e46e5d237bee3c8805bd54749408cc2727a), [Commit](https://github.com/open-webui/open-webui/commit/56f2cb530259df5393ea1ae844bad5cc6b2c810c), [#27457](https://github.com/open-webui/open-webui/pull/27457), [#27456](https://github.com/open-webui/open-webui/discussions/27456)
- 🗒️ **Notes without stored contents.** A note whose contents were never filled in now opens and saves normally instead of failing. [Commit](https://github.com/open-webui/open-webui/commit/6c59ef313fdaac7fa8e9089be758c4651f8b9412)
- 📄 **Note attachments.** Notes now have an upload option in their menu and show attached files above the note itself, where you can open or remove them, instead of only accepting files dropped onto the page. [Commit](https://github.com/open-webui/open-webui/commit/c4c4ab57e33bb4359b51a4c255f4569fa38a5058), [Commit](https://github.com/open-webui/open-webui/commit/0dc93b8ae798ad834e7f3327571452bfdf4c218f)
- 🗂️ **The assistant can search your attachments.** A new Files capability lets the model list the files attached to the chat and search them by meaning or by exact text, and read the parts it needs, rather than having their whole contents pushed into the conversation up front, and knowledge collections or notes attached to a chat are now announced to the model so it can query those the same way. [Commit](https://github.com/open-webui/open-webui/commit/57e60423b9963c4a69fdfda7ae5799efc5583010), [Commit](https://github.com/open-webui/open-webui/commit/55e0801dab8fe5f8bceff7d7c49772676724b8b1), [#26711](https://github.com/open-webui/open-webui/pull/26711), [#27232](https://github.com/open-webui/open-webui/issues/27232), [#26708](https://github.com/open-webui/open-webui/issues/26708)
- 🔎 **Search in the attachment menu.** The attachment menu now lets you search your knowledge bases, notes, files, and chats instead of scrolling to find them, with matching text shown for chats. [Commit](https://github.com/open-webui/open-webui/commit/668f9fe3905fea5fdfccb2b9b308c8f4d7d2f061)
- ⚗️ **Default file upload mode.** You can now choose in settings how attached files are handled by default, rather than picking that on each upload. [#20900](https://github.com/open-webui/open-webui/pull/20900), [#18431](https://github.com/open-webui/open-webui/issues/18431)
- ⬇️ **Response auto-scroll toggle.** A new interface setting lets you stop the view following a reply as it is written, so you can read earlier text while generation continues. [Commit](https://github.com/open-webui/open-webui/commit/cea991260f279f004489dab5d930bc25b0abf612), [#26826](https://github.com/open-webui/open-webui/pull/26826)
- 📜 **Client certificates for SearXNG.** Web search can now present a client certificate to a SearXNG instance that requires one, through new "SEARXNG_CLIENT_CERT_FILE" and "SEARXNG_CLIENT_KEY_FILE" settings. [Commit](https://github.com/open-webui/open-webui/commit/def26ce266c2e1d80e31b3ad12099db61a674832), [#26992](https://github.com/open-webui/open-webui/issues/26992)
- 🔭 **OpenSERP web search.** Web search can now run against a self-hosted OpenSERP instance, which returns results from several major search engines without any API key, configured through a new "OPENSERP_BASE_URL" setting. [#27437](https://github.com/open-webui/open-webui/pull/27437), [#27438](https://github.com/open-webui/open-webui/issues/27438)
- 🥇 **Model order as a setting.** Administrators can now set the order models appear in through a new "MODEL_ORDER_LIST" variable, so the arrangement survives a restart on instances that do not persist configuration. [#27420](https://github.com/open-webui/open-webui/pull/27420), [#27206](https://github.com/open-webui/open-webui/issues/27206)
- ⏱️ **Idle cap for streamed replies.** Administrators can now set an "AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT" that ends a streamed reply when the provider stops sending anything for that long, instead of holding the connection open until the overall timeout expires. [Commit](https://github.com/open-webui/open-webui/commit/4a7d4ebadac27d652ec200fa3939f10e9a5c17ed), [Commit](https://github.com/open-webui/open-webui/commit/c727643e05f3597395ee1a60b17117d04f693a18)
- 🖼️ **Media types an extraction engine may handle.** Administrators can now list which image and video types the configured content extraction engine is allowed to process, instead of media being passed to it only when the engine is the external one, so an engine with its own text recognition can take images. [Commit](https://github.com/open-webui/open-webui/commit/db2d24896b0682191a54f41c6b9f0b9d2971637f), [#26940](https://github.com/open-webui/open-webui/pull/26940), [#14768](https://github.com/open-webui/open-webui/issues/14768)
- 🧵 **Where a channel reply lands.** Administrators can now choose whether a reply to a mention posts in a thread under that message or straight into the channel. [Commit](https://github.com/open-webui/open-webui/commit/db2d24896b0682191a54f41c6b9f0b9d2971637f), [#27410](https://github.com/open-webui/open-webui/pull/27410)
- 📚 **Limits for knowledge tools.** Administrators can now set how much a knowledge search or file view may return, how many files one search may scan, and how many matches are reported, and a knowledge command's whole output is now capped so a single call cannot flood the conversation. [Commit](https://github.com/open-webui/open-webui/commit/11e61b69ebd922602edc37ded7b42fdd43bb8456), [#27524](https://github.com/open-webui/open-webui/pull/27524), [#27327](https://github.com/open-webui/open-webui/issues/27327), [#26139](https://github.com/open-webui/open-webui/issues/26139)
- 🎛️ **File streaming chunk size.** Administrators can now tune how large each chunk of a streamed file transfer is through a new "AIOHTTP_FILE_STREAM_CHUNK_SIZE" setting. [Commit](https://github.com/open-webui/open-webui/commit/429f2df50cd2f0ec8d0a1bb4136a46e2f94a4bf5)
- 🪛 **Model for summarizing long chats.** Administrators can now pick a dedicated model to write context compaction summaries, separate from the task model, with the conversation's own model used when none is chosen. [#26806](https://github.com/open-webui/open-webui/pull/26806), [#27051](https://github.com/open-webui/open-webui/issues/27051)
- 📏 **Context compaction token cap.** Administrators can now set a "Token Cap" that limits how high per-model context compaction thresholds are allowed to reach, giving finer control over long-conversation summarization. [Commit](https://github.com/open-webui/open-webui/commit/5c389ad93f0668d4bab717d14bd189b679338ef2), [Commit](https://github.com/open-webui/open-webui/commit/31996a5acfe1458720fa19f1b9fb4da95749b5e6), [Commit](https://github.com/open-webui/open-webui/commit/44c2a27ce0695d8e9c7e72f9a84dc325cb15096a)
- ⚖️ **Retained messages after compaction.** Administrators can now set what share of recent messages survives when a long conversation is summarized, between a tenth and half of it. [Commit](https://github.com/open-webui/open-webui/commit/33cf3fbb7f017ab1b79dce5c5ca4d4e1c3092844), [#27050](https://github.com/open-webui/open-webui/issues/27050)
- 🧠 **Memory as a per-model capability.** Whether a model receives your stored memories is now a switch on the model itself, so it can be left on for everyday assistants and off for ones that should start from nothing. [Commit](https://github.com/open-webui/open-webui/commit/6732852ce6c1a2a445bc001b122c60cf12e0278b), [#26861](https://github.com/open-webui/open-webui/pull/26861), [#18610](https://github.com/open-webui/open-webui/discussions/18610)
- ☑️ **Searchable model pickers.** When editing a model, the Tools, Skills, Knowledge, Voice, Filters and Actions pickers now let you search and toggle items in place, select or clear them all at once, and see what is active at a glance. [Commit](https://github.com/open-webui/open-webui/commit/e355959e9156fd61a1105953d731d007fbb4bae3), [Commit](https://github.com/open-webui/open-webui/commit/e1f96aa20ef80c8b01e3a81039d2cca3001d0ef5), [Commit](https://github.com/open-webui/open-webui/commit/fa889837e9e90c7284def56fac2daed20a3ce699), [Commit](https://github.com/open-webui/open-webui/commit/5424ac58917d2a21f21256b62d1c42f1a8c51c51), [Commit](https://github.com/open-webui/open-webui/commit/ea2e3d0afc76fa99f2af665fd50425dd28d000d5), [Commit](https://github.com/open-webui/open-webui/commit/cda5bdb9d42886dfe74d05907179e3f96097030c), [#26758](https://github.com/open-webui/open-webui/issues/26758)
- 🎚️ **Switch for single sign-on.** OAuth and OIDC now have their own on and off switch in the authentication settings, matching the LDAP one above it, so sign-in through a provider can be turned off without clearing the configuration. [#26988](https://github.com/open-webui/open-webui/pull/26988)
- 🖲️ **One sign-in attempt at a time.** The sign-in, sign-up and LDAP form now disables its buttons while a request is in flight, so a slow response no longer turns repeated clicks or Enter presses into several concurrent attempts. [#27416](https://github.com/open-webui/open-webui/pull/27416), [#27264](https://github.com/open-webui/open-webui/issues/27264)
- 🛂 **Trusted clients for token exchange.** Administrators can now list which OAuth clients may have their tokens exchanged for a session, through a new "OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS" setting, so a token a person obtained by signing in to an unrelated application of the same provider can no longer be turned into a session as that person. [#27546](https://github.com/open-webui/open-webui/pull/27546), [Commit](https://github.com/open-webui/open-webui/commit/b190dcf3caa00dc8b7b9c7312828298d9143f60d), [Commit](https://github.com/open-webui/open-webui/commit/c4332be71e6e9c314e8a13b9d2819a6932561630)
- 🚪 **Throttle for token exchange.** Administrators can now cap how often the OAuth token exchange endpoint may be called from one address through new "OAUTH_TOKEN_EXCHANGE_RATE_LIMIT" and "OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW" settings, which bound automated attempts with leaked or guessed tokens and stay off until set. [Commit](https://github.com/open-webui/open-webui/commit/453b9fb0291c0de8957a2713988c7c53dbcc5465)
- 🔏 **PKCE for every sign-in provider.** The code challenge setting now applies to Google, Microsoft and GitHub sign-in as well as OpenID Connect, so the same protection covers every provider. [Commit](https://github.com/open-webui/open-webui/commit/40320c113637f80e0466e30cae63ab9ac1ba596e), [#27302](https://github.com/open-webui/open-webui/pull/27302)
- 🔤 **Embeddings through the OpenAI-compatible API.** Integrations built on OpenAI client libraries can now create embeddings through the Ollama proxy, so embedding requests go through the same sign-in and model access rules as chat instead of needing direct access to Ollama. [#27332](https://github.com/open-webui/open-webui/pull/27332), [Commit](https://github.com/open-webui/open-webui/commit/9f00b62b3a005b030ffaf638fd1da4c27e3c0586), [#27328](https://github.com/open-webui/open-webui/discussions/27328), [Docs:#1331](https://github.com/open-webui/docs/pull/1331)
- 🎚️ **Passthrough parameters per connection.** Administrators can now list request parameters that a connection should receive untranslated, under a new Advanced section in connection settings, so provider-specific options reach the upstream API intact. [Commit](https://github.com/open-webui/open-webui/commit/bb12b1a18b77d80829cedb2d5bf965808222415b)
- 🅰️ **Anthropic requests passed straight through.** Requests to the Anthropic-compatible API aimed at an Anthropic or LiteLLM connection now reach the provider untouched rather than being translated on the way, and LiteLLM is selectable as a connection type. [Commit](https://github.com/open-webui/open-webui/commit/b81627b2c95aad184a6abf59145ca3b18a32bb2c)
- 💭 **Reasoning in Anthropic responses.** Responses from the Anthropic-compatible API now carry the model's reasoning as thinking blocks, in both streamed and complete responses. [Commit](https://github.com/open-webui/open-webui/commit/bb12b1a18b77d80829cedb2d5bf965808222415b)
- 🧩 **Structured output through the Anthropic-compatible API.** Requests can now ask for a JSON schema or JSON object response and set a reasoning effort, which are carried through to the upstream model. [Commit](https://github.com/open-webui/open-webui/commit/bb12b1a18b77d80829cedb2d5bf965808222415b)
- 🪧 **Group names in forwarded headers.** Custom headers on a connection can now carry the groups a person belongs to, by name or by id, so an upstream service or gateway can apply its own rules per group. [#27236](https://github.com/open-webui/open-webui/pull/27236), [#26834](https://github.com/open-webui/open-webui/issues/26834)
- 🪪 **User identity forwarded to Mistral OCR.** Document extraction through Mistral OCR now carries the requesting user's identity when user info forwarding is enabled, so a gateway in front of it can attribute requests per user like other outbound integrations already do. [#27253](https://github.com/open-webui/open-webui/pull/27253), [#27250](https://github.com/open-webui/open-webui/issues/27250)
- 🔢 **Anthropic token-counting endpoint.** The Anthropic-compatible API now offers a token-counting endpoint, so integrations can check how many input tokens a request will use before sending it. [Commit](https://github.com/open-webui/open-webui/commit/08dacd19da1b0eefd9d274d24ed59ec1e5d5a2de), [Commit](https://github.com/open-webui/open-webui/commit/23062e9fcaace42cf06db33f9533127bbbcd33d9)
- 🖲️ **Terminal instructions read fresh.** The instructions a terminal server provides are now fetched for each request, so changing them on the server takes effect immediately instead of after re-saving the connection or restarting. [#27242](https://github.com/open-webui/open-webui/pull/27242)
- 🖥️ **Live terminal server policies.** Administrators can now read an orchestrator terminal server's current policy and lifecycle settings directly in connection settings rather than relying on a locally cached copy. [Commit](https://github.com/open-webui/open-webui/commit/2f37e853d1259a901f736a823bad29dcc2c3b130), [Commit](https://github.com/open-webui/open-webui/commit/3005b7bc71fcbd5abc6e73c3e4caa4ea781cdb76)
- 🌍 **Model privacy at a glance.** Admins can now make a model public or private straight from its menu in the model list, where each model is marked as public, shared, or private. [Commit](https://github.com/open-webui/open-webui/commit/fb2ea272952ed96b0db5ac59a861637204100cb6)
- 📈 **Personal usage dashboard.** A new Usage tab in settings shows your own activity over time, including a token-activity heatmap, current and longest streaks, lifetime and peak token counts, your longest active chat, and your most used models and tools. [Commit](https://github.com/open-webui/open-webui/commit/af9a315ac30b83241f3df5556d7a2abfcd5d25b0)
- 🧠 **Memories in settings.** Your memories are now listed directly in personalization settings where you can search, add, edit, and remove them, instead of being tucked behind a separate manage dialog. [Commit](https://github.com/open-webui/open-webui/commit/db934a3b4ff16532b48670d7cc75e7048a6db993)
- 💾 **Import notes and automations.** Notes can now be brought in from text and markdown files, and automations can be exported and imported as files, so you can move them between instances. [Commit](https://github.com/open-webui/open-webui/commit/f8350360dfd60ff890b73fe2f39aaf20a52ad28b), [Commit](https://github.com/open-webui/open-webui/commit/2018546a7baeb853bb4d98e2fb2a092ef20aa431)
- 🧮 **Counts in the tabs.** The workspace tabs now show how many models, knowledge bases, prompts, skills, and tools you have, and the admin tabs do the same for users, groups, leaderboard entries, and feedback, so you can see the size of each section without opening it. [Commit](https://github.com/open-webui/open-webui/commit/05e3f713175c1eea43a99f29521eb01700c21d3c), [Commit](https://github.com/open-webui/open-webui/commit/727041da78bcfcb44c0e4c83c4e6a641b7061b90), [Commit](https://github.com/open-webui/open-webui/commit/f8ea15b84a274712dca33daa970f63ed7368043e)
- 🧾 **Group permissions at a glance.** The groups list now shows whether each group uses custom or default permissions, without opening it. [Commit](https://github.com/open-webui/open-webui/commit/ccb1ab7739fbeb77c810036dcac240570034a56a), [Commit](https://github.com/open-webui/open-webui/commit/1d1f60ab440b167b9c6ab8f4011b884caa99f27d)
- 📤 **Streamed file transfers.** Uploading a model, pipeline or audio file now sends it in chunks instead of holding the whole thing in memory, and reading and writing files no longer blocks other requests, so large transfers no longer spike memory or stall the server. [Commit](https://github.com/open-webui/open-webui/commit/429f2df50cd2f0ec8d0a1bb4136a46e2f94a4bf5), [#27351](https://github.com/open-webui/open-webui/pull/27351), [#27349](https://github.com/open-webui/open-webui/issues/27349)
- 🧰 **Built-in tool descriptions built once.** The descriptions handed to the model for the built-in tools are now worked out once at startup rather than rebuilt on every message. [Commit](https://github.com/open-webui/open-webui/commit/d727ee4d1febb2b72d5f6c26668c562eadca54f1), [Commit](https://github.com/open-webui/open-webui/commit/12974c9e4ed97b2d68c4ad129c057ff0e774254e), [#27374](https://github.com/open-webui/open-webui/pull/27374), [#27396](https://github.com/open-webui/open-webui/pull/27396)
- 🪺 **Records read without a double pass.** Loading a model, tool, prompt, skill, note, knowledge base, channel or calendar no longer converts the record twice on the way out. [Commit](https://github.com/open-webui/open-webui/commit/f1409266feb224e74fe023d7459f1e2b5aad0b29), [#27377](https://github.com/open-webui/open-webui/pull/27377)
- 🔧 **Faster tool and knowledge base listings.** Listing tools no longer loads each one's full source, and working out which tools and knowledge bases you can see takes a single check rather than one per item. [#27387](https://github.com/open-webui/open-webui/pull/27387)
- 🧊 **Quicker collection checks on Chroma.** Checking whether a collection exists now asks for that one collection instead of listing them all, which grew slower with every knowledge base and file. [Commit](https://github.com/open-webui/open-webui/commit/48ee357156fd7f567ea13eb5ddba0a25701c0351), [#27394](https://github.com/open-webui/open-webui/pull/27394)
- 🔠 **Tokenizer loaded once.** The tokenizer used to split documents is now kept after first use rather than being loaded again for every file. [Commit](https://github.com/open-webui/open-webui/commit/7e31f64bc81b2264136a85efc7bb86c00f346784), [#27394](https://github.com/open-webui/open-webui/pull/27394)
- 📗 **Faster knowledge base file lists.** Opening a knowledge base now loads just the file names and details instead of the entire extracted text of every document, so large collections appear almost instantly. [#27386](https://github.com/open-webui/open-webui/pull/27386), [#26144](https://github.com/open-webui/open-webui/issues/26144)
- 🗝️ **Faster file access checks.** Working out whether you can open a file no longer scales with how many workspace models and knowledge bases exist, so opening files and listing folder contents stays quick on large instances. [#27383](https://github.com/open-webui/open-webui/pull/27383)
- 🕰️ **Faster automation scheduling.** Working out when an automation that repeats every few minutes or hours runs next is now near instant, instead of taking twenty seconds or more and slowing further each year. [Commit](https://github.com/open-webui/open-webui/commit/b3aead23da6cf8ebeedbd9fa3b97c7ac1a3f54ec), [#26954](https://github.com/open-webui/open-webui/issues/26954)
- 📁 **Faster folder loading.** Your folder list no longer runs a separate lookup for every folder to check where it sits, so it loads in a single pass. [Commit](https://github.com/open-webui/open-webui/commit/9a49b271aaf5d6eeaec24ab974be38a8c68ddd76)
- 🎯 **One round of requests per folder click.** Selecting a folder in the sidebar now fetches the folder, the folder tree, and each expanded folder's chats once instead of two to four times. [#27540](https://github.com/open-webui/open-webui/pull/27540), [#27539](https://github.com/open-webui/open-webui/issues/27539)
- 🎧 **No wasted work when nobody is listening.** Updates for a chat whose tab has been closed, or for requests made through the API, are no longer packaged up only to be discarded, which matters most on long streamed replies. [#27366](https://github.com/open-webui/open-webui/pull/27366), [Commit](https://github.com/open-webui/open-webui/commit/858e9236df1c3d84782e373c22b56cfc312b6db8)
- 📑 **Cheaper audit logging.** With audit logging on, each request is no longer authenticated a second time just to record the log entry, so audited instances carry noticeably less overhead. [#27373](https://github.com/open-webui/open-webui/pull/27373)
- 🪧 **Cheaper tagging after each reply.** Saving the tags generated for a conversation now updates just that field instead of loading, rewriting and re-reading the whole conversation, which cost more the longer the chat. [#27382](https://github.com/open-webui/open-webui/pull/27382)
- ✍️ **Faster saves across the app.** Saving a chat, note, prompt, tool or user setting no longer re-reads the record it just wrote, so writes finish sooner, most noticeably on long conversations. [#27381](https://github.com/open-webui/open-webui/pull/27381), [#27379](https://github.com/open-webui/open-webui/pull/27379), [Commit](https://github.com/open-webui/open-webui/commit/c182a95ffdb87bae3d47d93387ec1a26c97740a2), [Commit](https://github.com/open-webui/open-webui/commit/977c7930623b860949410a73922579daf81705a7)
- 🛢️ **Less database overhead per request.** SQLite installations no longer run a connection check before every database call, and requests that never touch the database skip the bookkeeping that used to run regardless. [#27385](https://github.com/open-webui/open-webui/pull/27385)
- ⚡ **Faster memory lookups.** Stored memories are now indexed so retrieving them stays quick as the number you have grows. [Commit](https://github.com/open-webui/open-webui/commit/28bdcb063b8d5d6a0b10943b1b2f87b16ff63621), [#26957](https://github.com/open-webui/open-webui/pull/26957)
- 🪪 **Fewer checks before a reply starts.** Working out whether you may use a model now looks up the model and your group memberships once instead of repeating both, including for every model a workspace model is built on. [#27378](https://github.com/open-webui/open-webui/pull/27378)
- 👤 **Lighter user activity checks.** Checking whether someone is currently active now reads only that timestamp rather than their whole profile, including their profile image. [Commit](https://github.com/open-webui/open-webui/commit/c8f2e09fdcafc800c1e3af6da1cd6f2581cd9191), [#27224](https://github.com/open-webui/open-webui/pull/27224)
- 📨 **Fewer settings lookups when sending a message.** Sending a chat message now reads the settings behind tools, file retrieval, voice, skills and the code interpreter in fewer trips to the database, so replies start sooner. [#27223](https://github.com/open-webui/open-webui/pull/27223)
- 🪄 **Lighter conversion for Ollama requests.** Preparing a request for an Ollama model no longer copies the entire conversation before sending it, which cost more with every message and repeated on each tool-call round. [#27371](https://github.com/open-webui/open-webui/pull/27371)
- 🦙 **Fewer settings lookups on Ollama requests.** Ollama chat, generation and embedding requests now read their connection settings once instead of up to four times, so each request reaches the server sooner. [#27226](https://github.com/open-webui/open-webui/pull/27226)
- 🧹 **Less repeated work on every response.** Security headers are now worked out once at startup rather than rebuilt for each response, and ordinary page requests skip the redirect handling they never needed, so responses carry less overhead. [#27229](https://github.com/open-webui/open-webui/pull/27229)
- 🚀 **Lower per-request overhead.** Requests no longer each perform a settings lookup before they are handled, trimming a little latency from everything the app does. [Commit](https://github.com/open-webui/open-webui/commit/4493b56e424db29fa9e72310b1ca6b025c3e5f8b), [#27395](https://github.com/open-webui/open-webui/pull/27395), [Commit](https://github.com/open-webui/open-webui/commit/6ff1df326c76824f0706671b0974df4035cb453f), [Commit](https://github.com/open-webui/open-webui/commit/85664f650cc111a6b170b97ed0c391d962717bec), [#27227](https://github.com/open-webui/open-webui/pull/27227)
- 💨 **Leaner filter handling while streaming.** Filters applied to a streaming reply no longer re-read their settings and each plugin's full source from the database for every chunk, so responses with filters enabled cost the server far less work. [#27228](https://github.com/open-webui/open-webui/pull/27228), [#27372](https://github.com/open-webui/open-webui/pull/27372), [Commit](https://github.com/open-webui/open-webui/commit/f9107edeebc7ee545d7e3c1f1b7d449c123ab398), [Commit](https://github.com/open-webui/open-webui/commit/f578d8d67ec2c109b0d8c38d90eaeb4448f83610), [Commit](https://github.com/open-webui/open-webui/commit/9acbe3aa0f258a3bda593bb98ec81c6acc458d20), [#27392](https://github.com/open-webui/open-webui/pull/27392)
- 🚦 **No filter bookkeeping without filters.** Streamed API responses only build up the full reply for outlet filters when the model actually has one configured, instead of doing it for every request. [Commit](https://github.com/open-webui/open-webui/commit/315a6b5995663eabe1c96776d66b6593860d47d6), [#27391](https://github.com/open-webui/open-webui/pull/27391)
- ✂️ **Cheaper tag detection while streaming.** Watching a reply for reasoning and code blocks now examines only the newly arrived text rather than rescanning the whole answer on every chunk, so a long answer no longer costs progressively more as it grows. [#27360](https://github.com/open-webui/open-webui/pull/27360)
- 🌊 **Steadier long responses.** Building up a streamed reply no longer costs more work as it grows, so long answers keep pace instead of slowing down toward the end. [#27231](https://github.com/open-webui/open-webui/pull/27231), [#27359](https://github.com/open-webui/open-webui/pull/27359), [Commit](https://github.com/open-webui/open-webui/commit/ba556bd8f0517881cb250bb512631ddf8a0c82c3), [#27390](https://github.com/open-webui/open-webui/pull/27390)
- 📦 **Faster JSON handling as an option.** Administrators can now switch the whole application to a faster encoder through a new "ENABLE_ORJSON" setting, covering request bodies, responses, upstream provider payloads and live updates, where the encoding of live updates was the largest single cost on the workers handling them in clustered deployments; it stays off by default because the faster encoder is stricter about what it accepts. [#27583](https://github.com/open-webui/open-webui/pull/27583)
- ⚙️ **Faster Redis handling.** The compiled "hiredis" parser now ships as a dependency and is used automatically, so deployments backed by Redis spend noticeably less processor time reading responses. [#27282](https://github.com/open-webui/open-webui/pull/27282)
- 🔗 **Fewer Redis round trips per chat.** Deployments backed by Redis now look up the model and connected sessions once per request instead of twice, and fetch the model list in a single call. [#27225](https://github.com/open-webui/open-webui/pull/27225)
- 🛰️ **Fewer Sentinel lookups.** Redis Sentinel deployments no longer ask which server is the primary and open a fresh connection before every single command, which had caused heavy connection churn and stalls under load. [Commit](https://github.com/open-webui/open-webui/commit/75a8a0046b5b2ebd9942b25035b346aa953f81cc), [#27213](https://github.com/open-webui/open-webui/pull/27213), [#27210](https://github.com/open-webui/open-webui/issues/27210)
- 📡 **Lighter live connection handling.** Typing indicators, shared document edits and reconnections no longer re-read your account or copy the full participant list each time, and idle sessions are no longer rewritten every few seconds. [Commit](https://github.com/open-webui/open-webui/commit/021c4c7a2e8b5b213f49800ecd331b1b18c2ef99), [#27393](https://github.com/open-webui/open-webui/pull/27393)
- 🏎️ **Faster chat search on PostgreSQL.** Searching chats on PostgreSQL now reads from the message table instead of unpacking each conversation's stored data row by row, so results stay quick as your history grows. [Commit](https://github.com/open-webui/open-webui/commit/cc9a44569ef08b64ff44d15607c43966f362ce75), [#27221](https://github.com/open-webui/open-webui/issues/27221)
- ⚡ **Lighter model lists.** Model lists no longer carry embedded profile images in their data, so they load faster. [Commit](https://github.com/open-webui/open-webui/commit/9281adc5647b7046e3ddcc53ac4b84be7f650221), [Commit](https://github.com/open-webui/open-webui/commit/f3a35507845e4a911c3d278d680a9989bb8d99ad)
- 🏗️ **Fewer queries when building the model list.** Assembling the model list now makes fewer database round trips and no longer fetches every plugin's source code along the way, so it comes together faster. [Commit](https://github.com/open-webui/open-webui/commit/6b655689ccbdf2111620d005a8e6dedb8fb673f8), [#27389](https://github.com/open-webui/open-webui/pull/27389)
- 🪶 **Model lists without knowledge text.** Model lists no longer include the extracted text of files attached to a model as knowledge, so they stay small regardless of how large those knowledge bases are. [Commit](https://github.com/open-webui/open-webui/commit/48625e657ff11161c3588af2747598f102c1a4d1), [#27287](https://github.com/open-webui/open-webui/issues/27287)
- 🔛 **Functions can react to being switched on or off.** Two new events fire just before a function is enabled or disabled, and the function being enabled receives its own event even though it is not active yet, so it can run whatever setup or teardown it needs. [Commit](https://github.com/open-webui/open-webui/commit/94a60b04573acf6423e9c0519997b779f82e0560), [#26754](https://github.com/open-webui/open-webui/pull/26754), [#26748](https://github.com/open-webui/open-webui/discussions/26748)
- 🔛 **Multiple choice settings in plugins.** A tool or function can now offer a setting where you tick several options from a list, fixed or worked out at the time it is shown, instead of asking you to type a comma-separated list of allowed values. [#26884](https://github.com/open-webui/open-webui/pull/26884), [#26848](https://github.com/open-webui/open-webui/issues/26848)
- 🔌 **Disable plugins entirely.** Administrators can now completely turn off the built-in Tools and Functions plugin surfaces through a new "ENABLE_PLUGINS" setting, which hides them across the workspace and admin areas and removes their execution paths. [Commit](https://github.com/open-webui/open-webui/commit/bd6e0b61c2ae073aba9556ae46c345f4749acb84), [Commit](https://github.com/open-webui/open-webui/commit/8e46450acd7ae11a4dee166d19a7c9833d991e79), [Commit](https://github.com/open-webui/open-webui/commit/951f96021a970fbd4837a4ee441565c0cf3d2824), [Commit](https://github.com/open-webui/open-webui/commit/252e6fd855099e1c880f4def18aa09aedbe1733a)
- 🧵 **Lighter chat listings and search.** Building a page of chat search results or a folder listing no longer copies each full conversation to read its title and dates, so those pages come together faster and use far less memory while they are built. [#27388](https://github.com/open-webui/open-webui/pull/27388)
- 📮 **Name lookups off the thread pool.** Looking up a hostname no longer occupies one of the limited threads shared by every other piece of blocking work, so model calls, searches, page fetches and tool calls stop queueing behind each other once a few lookups are slow. [#27440](https://github.com/open-webui/open-webui/pull/27440)
- 🥬 **Faster web page parsing.** Pages pulled in by web search and web retrieval are now read with a faster parser, cutting roughly a tenth off the time spent on a ten result search. [#27439](https://github.com/open-webui/open-webui/pull/27439)
- 🧭 **No pointless lookups when filtering search results.** Filtering web search results against a domain list no longer resolves every result to an address first, which had turned a three second search into half a minute wherever the resolver was slow or a name did not resolve. [Commit](https://github.com/open-webui/open-webui/commit/42ea8a5a2f04b6a57ccb47a61611a62479b60b78), [#26920](https://github.com/open-webui/open-webui/issues/26920)
- 🚄 **Leaner passthrough streaming.** Responses the server only relays now go straight through in whole network reads instead of being split line by line, roughly halving the work spent shuttling a streamed reply on those routes. [#27384](https://github.com/open-webui/open-webui/pull/27384)
- 🧶 **Web page parsing off the critical path.** Reading those pages no longer holds up everything else on the server, so other people's replies, live updates and health checks keep flowing during a search instead of stalling for a second or more. [#27446](https://github.com/open-webui/open-webui/pull/27446)
- 🈶 **Faster uploads of non-English text files.** Working out the encoding of an uploaded text file now samples the part that needs it rather than scanning the whole file, taking a four megabyte Japanese or Chinese document from several seconds down to well under one. [#27445](https://github.com/open-webui/open-webui/pull/27445)
- ♿ **Improved UI accessibility.** Keyboard and screen reader users can now tell which chat in the sidebar is the one being viewed, open reasoning and detail blocks in a response, expand sidebar sections and open a folder without a mouse, sort the admin user list from the keyboard and hear which column it is sorted by, open a dropdown and its submenus with the keyboard, close them again with Escape and land back where they started, hear which value a dropdown is set to rather than only its label, hear what each admin settings switch, group permission toggle, checkbox, API key field and advanced model parameter slider controls, have the message box announced by its placeholder instead of as an unnamed field, press Enter on Cancel in a confirmation dialog without triggering the delete, reach the regenerate control, jump straight past the sidebar to the conversation with a skip link, hear what an icon-only button does across chat, calls, file previews, modals and the admin pages rather than an unlabelled button, placeholder text, section headings, field descriptions, inactive tab labels, timestamps, counters and icons are now readable against their background when High Contrast Mode is on, and sidebar buttons across notes, automations, the playground, and admin pages announce whether they open or close the sidebar. [#27510](https://github.com/open-webui/open-webui/pull/27510), [#27513](https://github.com/open-webui/open-webui/pull/27513), [#27503](https://github.com/open-webui/open-webui/pull/27503), [#27494](https://github.com/open-webui/open-webui/pull/27494), [#27491](https://github.com/open-webui/open-webui/pull/27491), [#27490](https://github.com/open-webui/open-webui/pull/27490), [#27489](https://github.com/open-webui/open-webui/pull/27489), [#27488](https://github.com/open-webui/open-webui/pull/27488), [#27555](https://github.com/open-webui/open-webui/pull/27555), [#27556](https://github.com/open-webui/open-webui/pull/27556), [#27554](https://github.com/open-webui/open-webui/pull/27554), [#27558](https://github.com/open-webui/open-webui/pull/27558), [#27501](https://github.com/open-webui/open-webui/pull/27501), [#27492](https://github.com/open-webui/open-webui/pull/27492), [#27509](https://github.com/open-webui/open-webui/pull/27509), [#27502](https://github.com/open-webui/open-webui/pull/27502), [#26769](https://github.com/open-webui/open-webui/pull/26769), [Commit](https://github.com/open-webui/open-webui/commit/89caa7c849c471561dfd76140d0c3c3ce7a68df8), [Commit](https://github.com/open-webui/open-webui/commit/7801909d27b18331a9a2bc399e1d618ab99ba5bf), [#26768](https://github.com/open-webui/open-webui/pull/26768), [#26770](https://github.com/open-webui/open-webui/pull/26770), [Commit](https://github.com/open-webui/open-webui/commit/421834b2de287b8d5291d4b695ba6ed4528a5e7f), [Commit](https://github.com/open-webui/open-webui/commit/7bfc4bb2c25249d3922acd54d5bc516db52c8519), [Commit](https://github.com/open-webui/open-webui/commit/e8fda1c7a07d1a0f91201ff977b77fd5a43e014a), [#27508](https://github.com/open-webui/open-webui/pull/27508)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Slovenian is now available, and translations for English (UK), Finnish, German, Japanese, Portuguese (Brazil) and Portuguese (Portugal) were enhanced and expanded.
### Fixed
- 🛡️ **Security Advisory**: This release includes security and access-control fixes. We recommend updating production deployments at your earliest convenience. Not all security fixes in this version may be enumerated in the fixed section. Some may be withheld for a short time to give administrators time to upgrade. [Advisories](https://github.com/open-webui/open-webui/security)
- 🔒 **Terminal file preview isolation.** Previewing an HTML file in the system terminal now runs it in an isolated context by default, closing a cross-site scripting hole that could expose your login session or, for privileged accounts, run code on the server. [#26907](https://github.com/open-webui/open-webui/pull/26907)
- ➗ **Malformed maths in a message.** Maths that fails to render is now shown as plain text rather than being placed into the page as markup, closing a way for a crafted formula in a chat, channel or shared conversation to run code in the browser of anyone reading it. [#26718](https://github.com/open-webui/open-webui/pull/26718)
- 🔩 **Updated file upload parsing library.** The library that parses file uploads and form submissions has been updated to a release that addresses a security advisory affecting that parsing path. [#26991](https://github.com/open-webui/open-webui/pull/26991)
- 🛑 **Deactivated accounts lose live access.** Real-time connections now apply the same role check as the rest of the application, so an account moved out of the user or admin role can no longer keep its channels and shared notes open on an existing token. [#27537](https://github.com/open-webui/open-webui/pull/27537)
- 🛅 **Writing into someone else's chat.** Completion and action requests now confirm you own the chat they name before anything is written to it, so a filter or action can no longer be pointed at another person's conversation. [#27486](https://github.com/open-webui/open-webui/pull/27486)
- 🎟️ **Ollama version no longer readable anonymously.** Reading the configured Ollama backend's version now requires signing in, closing a route that let anyone learn the version in use and count how many backends are configured. [#27199](https://github.com/open-webui/open-webui/pull/27199)
- 🔐 **Folder sharing permission.** The folder sharing setting in default and group permissions now saves instead of being silently discarded, so allowing or restricting folder sharing actually takes effect. [#27296](https://github.com/open-webui/open-webui/pull/27296), [#27120](https://github.com/open-webui/open-webui/issues/27120)
- 🔕 **Webhook permission enforcement.** People without permission to use webhooks can no longer save webhook notification destinations to their settings, so the permission is enforced when settings are saved rather than only reflected in the interface. [#27297](https://github.com/open-webui/open-webui/pull/27297), [Commit](https://github.com/open-webui/open-webui/commit/af629177f46fa4595175f914c971c47701d1a676)
- 🛎️ **Stopping someone else's generation.** Deleting a chat now checks who you are before anything is cancelled, so knowing another person's chat id no longer lets you cut off their reply or title generation on a request that is refused anyway. [#27006](https://github.com/open-webui/open-webui/pull/27006)
- 🚥 **Automation limits in chat.** Automations that the assistant creates or reschedules on your behalf now respect the same maximum count and minimum interval as the ones you set up yourself, instead of being able to exceed both. [#27523](https://github.com/open-webui/open-webui/pull/27523), [#27121](https://github.com/open-webui/open-webui/issues/27121)
- ⏲️ **Cancelling someone else's timers.** Marking a chat as read now only clears your own pending timers on it, instead of clearing everyone's, which had let another person's scheduled prompt be silently cancelled without them being told. [#27472](https://github.com/open-webui/open-webui/pull/27472)
- 🗑️ **Deleting a shared folder's subfolders.** Deleting a folder is now limited to its owner or an administrator at every level, so someone with write access to a shared folder can no longer delete a subfolder and take the owner's chats with it. [#27003](https://github.com/open-webui/open-webui/pull/27003)
- 📕 **Tool source shown to people who can only use it.** Opening a tool you were given read access to no longer returns its source code, which read access was never meant to include. [#27005](https://github.com/open-webui/open-webui/pull/27005)
- 🎯 **Model settings in the list endpoint.** Listing models no longer includes each one's parameters and system prompt for people with read access only, matching what opening a single model already returned. [#27004](https://github.com/open-webui/open-webui/pull/27004)
- 🖌️ **Image generation and web search without permission.** Turning on image generation or web search through the older request format now checks your permission first, so someone denied those features can no longer trigger them, and the billing that comes with them, by asking for that format. [#26703](https://github.com/open-webui/open-webui/pull/26703)
- 🎗️ **Terminal single sign-on tokens.** The token forwarded to a terminal server for single sign-on is now taken from your own session on the server rather than from a header the browser supplied, so a caller can no longer send someone else's token in its place. [#26719](https://github.com/open-webui/open-webui/pull/26719)
- 🫗 **Web search results scoped to you.** The temporary collections holding a web search's pages are now tied to the person who ran the search, closing the one place where that scoping was not applied. [#26706](https://github.com/open-webui/open-webui/pull/26706)
- 🧺 **Knowledge base cleanup reaching other collections.** Tidying up a knowledge base now acts only on files and folders that belong to it, so someone with write access to one knowledge base can no longer delete folders or search data belonging to another. [#26722](https://github.com/open-webui/open-webui/pull/26722)
- ⌛ **Searches that could stall the server.** A search pattern inside knowledge base commands now runs under a time budget, so a pattern that would take minutes to evaluate can no longer hold up everyone else on the instance. [#27471](https://github.com/open-webui/open-webui/pull/27471)
- 🚫 **Disabled terminal servers are refused.** A terminal connection an administrator has turned off can no longer be reached by browsing its files, opening a session, or calling its tools, rather than only disappearing from the interface. [Commit](https://github.com/open-webui/open-webui/commit/7537989235675ac84a40bf70c91ec3e16fc0d8db)
- 🧫 **Files attached to a shared folder.** Adding files to a folder is now refused unless the folder's owner can read them, and a folder's files are checked against what its owner can still read before they are used as knowledge in chat, so a collaborator can no longer place files into someone else's folder or keep serving files the owner has since lost access to. [#27464](https://github.com/open-webui/open-webui/pull/27464), [Commit](https://github.com/open-webui/open-webui/commit/56183fcb17142088e2a34d1e35228f013749030c)
- 🧷 **Knowledge claimed by a direct connection.** Files listed as knowledge on a model supplied by the browser for a direct connection are now filtered against your own access before anything is retrieved, so a crafted request can no longer pull in documents you cannot otherwise open. [Commit](https://github.com/open-webui/open-webui/commit/305880f2e2aeb2dda2f4b2a18a20bdcd558f7134), [#26723](https://github.com/open-webui/open-webui/pull/26723)
- 🪜 **Reaching a restricted model through a shared one.** A shared workspace model can no longer be used to reach an underlying model the person could not otherwise use, which previously slipped through when that model had no entry of its own. [#26905](https://github.com/open-webui/open-webui/pull/26905), [#26900](https://github.com/open-webui/open-webui/issues/26900)
- 🖌️ **Shared image checkpoint changes.** Only administrators can now change the instance-wide Automatic1111 checkpoint, so an ordinary image generation request no longer switches the image model for everyone. [#27244](https://github.com/open-webui/open-webui/pull/27244)
- 💬 **Channel message ownership.** Only the author of a channel message, or an administrator, can now edit or delete it, instead of anyone able to post in that channel. [#27197](https://github.com/open-webui/open-webui/pull/27197)
- 🗄️ **Chats shared with an administrator.** An administrator can now open a chat that was deliberately shared with them even when broad admin access to other people's chats is turned off, instead of being refused a chat any other recipient could read. [#27127](https://github.com/open-webui/open-webui/pull/27127)
- 📓 **Notes in folder knowledge are access checked.** Notes attached to a folder are now filtered against your own access before the list reaches the assistant, rather than relying on later checks further along. [#26739](https://github.com/open-webui/open-webui/pull/26739)
- 🧱 **Code interpreter module blocking.** Modules an administrator has blocked for the code interpreter are now actually blocked, and other imports inside interpreter code work again. [#27245](https://github.com/open-webui/open-webui/pull/27245)
- 📉 **Charts in the code interpreter.** Code that draws a chart now runs in the default code interpreter setup, instead of failing with a syntax error unless file persistence was turned on. [#26800](https://github.com/open-webui/open-webui/pull/26800), [#26660](https://github.com/open-webui/open-webui/issues/26660)
- 🎬 **Chat action availability.** Chat actions can no longer be triggered when they are disabled, not assigned to the model in use, or on a model the caller cannot access, matching the actions the interface actually offers. [#27243](https://github.com/open-webui/open-webui/pull/27243)
- 🗨️ **Response text where it was missing.** Assistant replies are no longer stored without their text, so copying, exporting, searching and reusing a conversation return the reply instead of nothing. [Commit](https://github.com/open-webui/open-webui/commit/33cf3fbb7f017ab1b79dce5c5ca4d4e1c3092844), [#26799](https://github.com/open-webui/open-webui/pull/26799), [#26436](https://github.com/open-webui/open-webui/issues/26436)
- 🧪 **Filter edits that survive a reload.** A change a filter makes to a finished response is now saved with the conversation, instead of showing on screen and reverting the next time the chat is opened. [#27414](https://github.com/open-webui/open-webui/pull/27414), [#27017](https://github.com/open-webui/open-webui/issues/27017)
- 📃 **Action functions receive the response text.** Running an action on a response now passes the assistant's text to the function, instead of handing it an empty message. [#26798](https://github.com/open-webui/open-webui/pull/26798), [#26672](https://github.com/open-webui/open-webui/issues/26672)
- 🍎 **Blank messages on Safari.** Assistant responses no longer render as empty in Safari and on iPhone and iPad, where a browser painting bug left on-screen messages unpainted. [#26805](https://github.com/open-webui/open-webui/pull/26805), [#26712](https://github.com/open-webui/open-webui/issues/26712), [#26844](https://github.com/open-webui/open-webui/issues/26844)
- ➡️ **Prompts opened from a link.** A prompt passed in through a link that sends automatically now waits for tool servers to finish loading, so external tools are available on that first message instead of the model reporting it has none. [Commit](https://github.com/open-webui/open-webui/commit/d7513e4ce81ada1936c0c34115f947e115d6f2cf), [#24176](https://github.com/open-webui/open-webui/issues/24176)
- 🪟 **Tool result prompt submission.** Interactive tool result embeds that send a prompt back to the chat work again, showing the confirmation dialog before submitting instead of silently doing nothing. [#26914](https://github.com/open-webui/open-webui/pull/26914), [#26912](https://github.com/open-webui/open-webui/issues/26912)
- 📻 **Live updates in a second tab.** Opening Open WebUI again while already connected now joins the new tab to your event stream, so notifications and chat updates reach every open tab instead of only the first one. [Commit](https://github.com/open-webui/open-webui/commit/d14fddf25405cd58184fdef3d2af012503e4edd8)
- 🔁 **Connection recovery on new chats.** Chats started from the home page now recover automatically after a dropped connection, such as from mobile backgrounding, a VPN or IP change, or waking from sleep, instead of getting stuck loading until a manual refresh. [#26913](https://github.com/open-webui/open-webui/pull/26913), [#26844](https://github.com/open-webui/open-webui/issues/26844)
- 🪫 **Terminal choice cleared on load.** Your selected terminal is no longer dropped while the list of terminals is still loading, so it survives a page refresh. [Commit](https://github.com/open-webui/open-webui/commit/9707d3a5c21d2fedada602f2cfd8a104cdc5e5d1), [Commit](https://github.com/open-webui/open-webui/commit/f59d86a10cb31e48d2dae0033081a09dbbaafc8c), [Commit](https://github.com/open-webui/open-webui/commit/2f2bf38e3481077597b5ad60f57685813e8d71b8), [#26775](https://github.com/open-webui/open-webui/pull/26775), [#26677](https://github.com/open-webui/open-webui/issues/26677)
- 🔌 **Dropped sessions during keepalive.** Live connections no longer break on a routine keepalive check, which had cut the session so that anything the server needed to run in your browser failed afterwards, most visibly the code execution tool reporting the client as disconnected on every run. [#27553](https://github.com/open-webui/open-webui/pull/27553), [#27550](https://github.com/open-webui/open-webui/issues/27550)
- ✂️ **Context compaction turn boundaries.** Long-conversation compaction now summarizes only completed earlier turns instead of sometimes cutting through the middle of a single turn, keeping the current turn's tool calls and results intact. [#27035](https://github.com/open-webui/open-webui/issues/27035), [Commit](https://github.com/open-webui/open-webui/commit/959558fd82eb2a3c980231acd500b73ba4b698b3), [Commit](https://github.com/open-webui/open-webui/commit/17e6496538e5f3147203b7520012a985cab044b7)
- 🪆 **Summaries on a direct connection.** Summarizing a long conversation on a direct connection can now use the configured summary model rather than being limited to the connection's own model. [#26806](https://github.com/open-webui/open-webui/pull/26806)
- 🪟 **System prompt through compaction.** The system message now stays at the front of the conversation when a long chat is summarized, instead of being folded into the summary and lost from that point on. [Commit](https://github.com/open-webui/open-webui/commit/70549c5c8a50315aa3bf909ebedea8cc3e602077), [Commit](https://github.com/open-webui/open-webui/commit/15688686af9dd73ec974e35f96a7ea24294dbe4f), [Commit](https://github.com/open-webui/open-webui/commit/44f4f9dce48f1ad2af0c5f3210fa5aa701bad624), [#26713](https://github.com/open-webui/open-webui/pull/26713), [#26710](https://github.com/open-webui/open-webui/issues/26710)
- 🧷 **Context compaction continuity.** After a compaction, the retained recent messages now stay in the prompt on every following turn instead of disappearing after the first, preserving conversational continuity and prompt caching. [#27037](https://github.com/open-webui/open-webui/issues/27037), [Commit](https://github.com/open-webui/open-webui/commit/0c23466a3e9a1fb7d32875a0614f1ca8e583bc73), [Commit](https://github.com/open-webui/open-webui/commit/f730733bc44eff5812eff0e51ebca0bbcfa1bc6e)
- 🔟 **Context size after tool calls.** The context meter and long-conversation compaction now read the size of the latest request rather than adding up every call in a tool loop, and understand the counts reported by Ollama and llama.cpp as well as the OpenAI-style ones, so compaction no longer fires far below its threshold, or never at all, and the usage shown is no longer inflated. [Commit](https://github.com/open-webui/open-webui/commit/df94268e892cbb66675170a6c78846aef23f6e89), [Commit](https://github.com/open-webui/open-webui/commit/e8f2c123e63c9073c9ae4ee00573144ce6d4b2e9), [#27031](https://github.com/open-webui/open-webui/issues/27031), [#26752](https://github.com/open-webui/open-webui/pull/26752), [#24410](https://github.com/open-webui/open-webui/discussions/24410)
- 💭 **Reasoning that arrives late or empty.** Reasoning sent by a provider after the answer has started is now shown in its proper place above the answer rather than appended after it, and reasoning notes carrying nothing no longer open an empty thinking block. [Commit](https://github.com/open-webui/open-webui/commit/051a1f6c41d1d37e5a12ce2068fc6f8488940591), [#26687](https://github.com/open-webui/open-webui/pull/26687), [#26645](https://github.com/open-webui/open-webui/issues/26645)
- 📐 **System prompt lost during tool calls.** A model's system prompt now stays in place through every round of tool calls, instead of being dropped after the first one and, with memories enabled, replaced by the memory block alone. [#26857](https://github.com/open-webui/open-webui/pull/26857), [#26836](https://github.com/open-webui/open-webui/issues/26836)
- 🪶 **Memories from structured replies.** A reply delivered as structured output is now read when memories are reviewed after a turn, so nothing worth remembering is skipped just because of how the answer arrived. [Commit](https://github.com/open-webui/open-webui/commit/3fe03583a3b240da3cc42364088f22bc3a059487), [#26705](https://github.com/open-webui/open-webui/pull/26705), [#26651](https://github.com/open-webui/open-webui/issues/26651)
- 🎲 **Stable skill ordering.** Skills available to a model are now listed in the same order on every request, instead of shuffling between requests and quietly defeating prompt caching. [Commit](https://github.com/open-webui/open-webui/commit/b9d72741bb2f649cb67942ce7c635ea173f32b70), [#26986](https://github.com/open-webui/open-webui/issues/26986)
- 🛑 **Stopping an answer the moment it starts.** Each answer in a chat now carries its own task identifier from the first event onward, so stopping one immediately after sending no longer misses. [Commit](https://github.com/open-webui/open-webui/commit/aadab2f480a8c17a9265a244d262947da23ddc79)
- ⏸️ **Deleting while a reply is being written.** The delete control is now hidden on messages while a response is generating or a task is running, so a conversation can no longer be left with the finished reply detached from the messages before it. [Commit](https://github.com/open-webui/open-webui/commit/b4d13793a3af2d75aaf33fe9793cbbf278958940), [#26668](https://github.com/open-webui/open-webui/issues/26668)
- 🎁 **Feedback while a download is prepared.** Downloading a file or folder from the terminal now tells you it is being prepared, will not start the same archive twice if you click again, and reports a failure instead of quietly giving up or leaving a preview spinning. [#27421](https://github.com/open-webui/open-webui/pull/27421), [#27055](https://github.com/open-webui/open-webui/issues/27055)
- 📥 **Moving an archived chat into a folder.** Moving an archived chat into a folder now takes it out of the archive so it appears there, and the folder's contents refresh straight away after a move from the menu. [#27485](https://github.com/open-webui/open-webui/pull/27485), [#27484](https://github.com/open-webui/open-webui/issues/27484)
- 📜 **Chats past the first sixty in a folder.** Folder listings now page through every chat instead of stopping at a fixed limit, so older chats no longer appear to vanish from a folder once it grows past sixty. [#26786](https://github.com/open-webui/open-webui/issues/26786), [Commit](https://github.com/open-webui/open-webui/commit/409fb39717be9ab7becd9e8c01801a08c5bae318)
- 📌 **Sidebar highlight follows the open chat.** The sidebar no longer keeps a chat highlighted after you move to another page, so deleting or archiving it there no longer throws you back to a new chat, and cloning no longer leaves two chats looking selected. [#26977](https://github.com/open-webui/open-webui/pull/26977)
- 🔀 **Sidebar ordering during replies.** Background updates such as follow-up suggestions, sources, and status no longer bump a chat to the top of the sidebar or change its last-updated time, and neither does saving a chat's variables or settings, nor the automatic title generation on a new chat. [Commit](https://github.com/open-webui/open-webui/commit/f1ded9409a5523ec27d99635d8b7e7e1a297a4eb), [Commit](https://github.com/open-webui/open-webui/commit/a9617ca2187920734e2be5f90c5f119c09850ff5)
- 🖱️ **One hover preview at a time.** Moving between chats in the sidebar, or between avatars in the admin user list, channel messages and member lists, no longer leaves an earlier preview open behind the new one. [#27549](https://github.com/open-webui/open-webui/pull/27549), [#27548](https://github.com/open-webui/open-webui/issues/27548), [#27578](https://github.com/open-webui/open-webui/pull/27578), [#27577](https://github.com/open-webui/open-webui/issues/27577)
- ✨ **Folder lists no longer flash.** Clicking a folder title in the sidebar no longer empties the chat lists of your expanded folders for a moment before they reappear. [#27535](https://github.com/open-webui/open-webui/pull/27535), [#27533](https://github.com/open-webui/open-webui/issues/27533)
- 🫧 **Flickering sidebar rows.** Moving the pointer across a chat in the sidebar no longer makes its title and timestamp flicker in and out, or draw the timestamp underneath the action buttons. [#27474](https://github.com/open-webui/open-webui/pull/27474), [#27473](https://github.com/open-webui/open-webui/issues/27473)
- ⭐ **Rating scale in multi-model replies.** The rating scale in the feedback panel is no longer cut off when several models answer side by side, so every score can be picked. [#26846](https://github.com/open-webui/open-webui/issues/26846)
- 🧑‍🤝‍🧑 **Duplicate models side by side.** Adding the same model twice in a side-by-side chat now keeps each column's own answer after a reload, instead of every column collapsing onto the first one. [#26980](https://github.com/open-webui/open-webui/pull/26980)
- ⬅️ **Back button after opening admin or workspace.** Going back in the browser now returns you to the page you came from, instead of being pushed forward again to where you just were. [#27478](https://github.com/open-webui/open-webui/pull/27478), [#27477](https://github.com/open-webui/open-webui/issues/27477)
- 🎛️ **Typing a top_k value.** The top_k box in advanced parameters now accepts whole numbers up to its limit and rejects anything else, instead of letting the slider and the box disagree over what is allowed. [Commit](https://github.com/open-webui/open-webui/commit/34920213619eb66467470105cbe3a275c812ccbc), [#26669](https://github.com/open-webui/open-webui/issues/26669)
- 🌙 **Date pickers in dark mode.** The calendar and clock icons on date and time fields are now visible in dark mode, across the calendar, automation schedules, account settings and analytics. [#27275](https://github.com/open-webui/open-webui/pull/27275), [#27274](https://github.com/open-webui/open-webui/issues/27274)
- 🪞 **Settings content stays inside the window.** Long chat titles in Archived Chats now shorten with the full title on hover, and the admin analytics tables and chart no longer stretch past the edge of the settings window. [#27306](https://github.com/open-webui/open-webui/pull/27306), [#27305](https://github.com/open-webui/open-webui/issues/27305), [#27329](https://github.com/open-webui/open-webui/issues/27329)
- 🔗 **Settings links that open in place.** A link to a settings tab now opens it without a page refresh, and the Add Terminal button in the terminal menu goes straight to the Integrations tab instead of flashing the admin panel and doing nothing. [#27552](https://github.com/open-webui/open-webui/pull/27552), [#27551](https://github.com/open-webui/open-webui/issues/27551)
- 🎰 **Model choice on a fresh chat.** Starting a new chat now falls back to your default model when the previous selection is no longer available, instead of leaving the picker empty, while a model named in the link still wins. [Commit](https://github.com/open-webui/open-webui/commit/f91ac068d09eed381e14d35472d80ae4670fe52b), [#26697](https://github.com/open-webui/open-webui/pull/26697)
- 📱 **Model selector on small screens.** The model list now stays fully on screen and sizes itself to the space available, instead of running past the edge or hiding behind the on-screen keyboard on phones. [Commit](https://github.com/open-webui/open-webui/commit/79d3e34eea6dc2828d1945cc2b9fca5d662d825b), [Commit](https://github.com/open-webui/open-webui/commit/e39ff71532651438c32b2aa7ffe2b6068c94e6b2), [Commit](https://github.com/open-webui/open-webui/commit/ea31a3bd61fdf1a72206f9ed5f7252486d554c9b)
- 📲 **Sidebar stays open over the calendar.** Opening the calendar from the account menu on a phone now closes the sidebar, as every other entry in that menu already did. [#26979](https://github.com/open-webui/open-webui/pull/26979)
- 🗓️ **Automation dialog on narrow screens.** The buttons along the bottom of the automation dialog now sit on their own row on a phone, instead of the schedule and model pickers wrapping and pushing Cancel into the middle. [#27027](https://github.com/open-webui/open-webui/pull/27027)
- 📐 **Input menu with keyboard open.** The message input's attachment menu now stays on screen and resizes to fit when the on-screen keyboard is open on mobile, instead of running off the edge. [Commit](https://github.com/open-webui/open-webui/commit/6e5efc1f757c614814ba88bbae6caa3aaddda528)
- 🎈 **Dropdowns that follow their content.** A menu now stays in place as its contents grow or shrink, instead of running past the edge of the screen when a submenu swaps in taller content, and no longer bounces as it opens. [#27460](https://github.com/open-webui/open-webui/pull/27460), [#27458](https://github.com/open-webui/open-webui/issues/27458)
- 🧾 **Attachment menus load once.** Opening a submenu of the attachment menu now requests its list a single time instead of twice. [#27461](https://github.com/open-webui/open-webui/pull/27461), [#27459](https://github.com/open-webui/open-webui/issues/27459)
- 🔦 **Chat search on PostgreSQL.** Searching your chats now finds matches in current conversations on PostgreSQL setups, instead of only matching chats still stored in the older format. [Commit](https://github.com/open-webui/open-webui/commit/cc9a44569ef08b64ff44d15607c43966f362ce75)
- 🧲 **Search quality with prefix-based embedding models.** Memories, knowledge base descriptions and searches against an external vector database now carry the query and content markers your embedding model expects, so results are no longer quietly worse than they should be on models that rely on them. [Commit](https://github.com/open-webui/open-webui/commit/c4f5ac65ee3cd20dd1d507eda04fca21a866910a), [#26958](https://github.com/open-webui/open-webui/pull/26958), [#26353](https://github.com/open-webui/open-webui/issues/26353)
- 🥄 **Counting matches in knowledge base commands.** Piping text into a search inside knowledge base commands now honours the count and filenames-only flags, instead of returning the matching lines regardless. [#26721](https://github.com/open-webui/open-webui/pull/26721), [#26715](https://github.com/open-webui/open-webui/issues/26715)
- 🔍 **Knowledge base file search.** Searching inside knowledge base files now returns matching lines with correct line numbers, and patterns that list alternatives separated by a pipe find matches instead of silently returning none. [#27249](https://github.com/open-webui/open-webui/pull/27249), [Commit](https://github.com/open-webui/open-webui/commit/e18e249d5da3d8fe701a885edc64341cc5dbf813), [Commit](https://github.com/open-webui/open-webui/commit/8d2fee5d4559d030b53575d377a0013e2c67b9fe), [#26795](https://github.com/open-webui/open-webui/pull/26795), [#26781](https://github.com/open-webui/open-webui/issues/26781), [#26744](https://github.com/open-webui/open-webui/issues/26744)
- 🖨️ **PDF text recognition.** The text recognition package is now included again, so the application starts and PDFs with image text extraction enabled upload correctly instead of failing. [#26851](https://github.com/open-webui/open-webui/pull/26851), [#26646](https://github.com/open-webui/open-webui/issues/26646), [#26994](https://github.com/open-webui/open-webui/issues/26994)
- 🧿 **Mistral OCR on a stock install.** Extracting documents with Mistral OCR now works out of the box, instead of failing on a missing name resolution library that the code assumed was present. [#27440](https://github.com/open-webui/open-webui/pull/27440)
- 📧 **Outlook message uploads.** Uploading a .msg email now works, where it previously failed because the package it relied on could not be installed alongside the rest of the application at all. [#26704](https://github.com/open-webui/open-webui/pull/26704), [#26690](https://github.com/open-webui/open-webui/issues/26690)
- 🖇️ **Uploads with PaddleOCR-VL selected.** With PaddleOCR-VL chosen as the document loader, only PDFs and images now go to it and everything else falls back to the usual handling, so text, markdown, spreadsheet and Word files index instead of being rejected. [#27529](https://github.com/open-webui/open-webui/pull/27529), [#24988](https://github.com/open-webui/open-webui/issues/24988), [#26759](https://github.com/open-webui/open-webui/issues/26759)
- 🪙 **Documents containing special tokens.** Splitting text by tokens no longer fails when the content contains reserved marker sequences, so those pages and files can be fetched and added to a knowledge base. [Commit](https://github.com/open-webui/open-webui/commit/33cf3fbb7f017ab1b79dce5c5ca4d4e1c3092844), [#27094](https://github.com/open-webui/open-webui/issues/27094)
- 📚 **Knowledge base upload reliability.** Adding a file directly to a knowledge base now finishes processing and linking the file before reporting success, so uploaded files are reliably searchable. [Commit](https://github.com/open-webui/open-webui/commit/f5b196c060805fd22e1aa1c9f738b60221ef0fd8)
- 🛠️ **Web loader settings from the admin panel.** The web loader picked in admin settings is now actually used, along with its certificate checking, request pacing and proxy settings, so instances that fetch pages through an external loader work again instead of trying to reach the internet directly with whatever was configured at startup. [#26749](https://github.com/open-webui/open-webui/pull/26749), [#26747](https://github.com/open-webui/open-webui/issues/26747), [Commit](https://github.com/open-webui/open-webui/commit/304cbe4569cddbc9e4641186e51bc9e8d5154533), [#27083](https://github.com/open-webui/open-webui/pull/27083), [#27025](https://github.com/open-webui/open-webui/pull/27025), [#27061](https://github.com/open-webui/open-webui/issues/27061)
- 🚧 **Quoted entries in the web fetch filter list.** Stray quote marks around a filter entry, which Docker Compose passes through literally, no longer turn the list into one that blocks every web address. [#26910](https://github.com/open-webui/open-webui/pull/26910), [#26908](https://github.com/open-webui/open-webui/issues/26908)
- 🌐 **Web fetching with certain plugins installed.** Fetching a web page and loading web search results work again on instances where a tool or function pulls in a replacement networking library, which previously made every fetch fail and return nothing. [#26796](https://github.com/open-webui/open-webui/pull/26796), [#26791](https://github.com/open-webui/open-webui/issues/26791)
- 📢 **Web search failures explained.** When a search finds pages but cannot store them, the chat now says what went wrong and points at the document settings, instead of reporting sites searched and then no sources found. [#26883](https://github.com/open-webui/open-webui/pull/26883)
- 🕸️ **Mixed web page extraction.** Fetching several web pages at once now reads each one according to its own format, instead of applying the first page's format to the whole batch and garbling the rest. [#27367](https://github.com/open-webui/open-webui/pull/27367)
- 🧯 **Leftover browser sessions on web fetches.** Fetching pages through a remote Playwright server now closes each page and the browser even when a page times out or the search is abandoned partway, instead of leaving sessions open and slowing every later search until that server was restarted. [#27526](https://github.com/open-webui/open-webui/pull/27526), [#25880](https://github.com/open-webui/open-webui/issues/25880)
- 🖇️ **Sign-in profile pictures fetched safely.** The profile picture pulled in when someone signs in through a provider is now fetched through the same protected path as other outbound requests, so a host that changes its address between the check and the fetch can no longer point it at an internal service, taking the forwarded sign-in token with it. [#26699](https://github.com/open-webui/open-webui/pull/26699)
- 🪃 **Backslashes in terminal proxy paths.** A request to the terminal proxy containing a backslash is now refused, closing a way to smuggle directory traversal past the path check to an upstream that treats it as a separator. [#27198](https://github.com/open-webui/open-webui/pull/27198)
- 🧱 **Internal addresses disguised as public ones.** A web address that hides an internal target inside an IPv6 address, through the mapped, 6to4, Teredo or NAT64 forms, is now recognised and refused like any other internal address. [Commit](https://github.com/open-webui/open-webui/commit/1717b493d83c86afa82aa8bc50139250852dd2f3)
- 🪤 **Tighter checks when a page is fetched.** Every request a fetched page makes is now checked against the address rules rather than only the page itself, each hop of a redirect is checked in turn, and background workers and socket connections the page tries to open are refused. [Commit](https://github.com/open-webui/open-webui/commit/bef63a2ae915571d50d2722a635e8bfa753d7877), [#27042](https://github.com/open-webui/open-webui/pull/27042), [#27008](https://github.com/open-webui/open-webui/pull/27008)
- 🐢 **Dropped pages when fetches are paced.** Pages fetched through Firecrawl, Tavily, Microsoft Web IQ or Playwright are no longer discarded whenever the loader has to pause between requests, which quietly lost any page following close behind another and sometimes blamed it on a failed security check. [#27528](https://github.com/open-webui/open-webui/pull/27528), [#26079](https://github.com/open-webui/open-webui/issues/26079)
- 🎙️ **Dictation repeating earlier speech.** Dictating into the message box no longer re-inserts everything you said in previous recordings, and cancelling a recording no longer inserts the text anyway. [#26793](https://github.com/open-webui/open-webui/pull/26793), [#26784](https://github.com/open-webui/open-webui/issues/26784)
- 🧩 **Order of long transcriptions.** A long recording split into pieces for transcription is now reassembled in the order it was spoken, instead of sections sometimes appearing out of sequence in the transcript and everything read from it. [#27417](https://github.com/open-webui/open-webui/pull/27417), [#27143](https://github.com/open-webui/open-webui/issues/27143)
- 🔊 **Text-to-speech reliability.** Text-to-speech playback and other streamed responses no longer intermittently cut out partway through when several requests run at once. [#26924](https://github.com/open-webui/open-webui/pull/26924), [#26922](https://github.com/open-webui/open-webui/issues/26922)
- 🧮 **Anthropic usage reporting.** Responses from the Anthropic-compatible API now report accurate input and output token counts, pass through cache and server tool figures where the provider gives them, and leave the input count out entirely rather than reporting zero when it is unknown. [Commit](https://github.com/open-webui/open-webui/commit/e8b59b2ef35ecb727fa760cd565d6da20c9e7e79), [Commit](https://github.com/open-webui/open-webui/commit/51ff386fd6461c07225d10a0de530019eebdd157), [Commit](https://github.com/open-webui/open-webui/commit/0576e8eeb5797a36b43eba5790a4e2a5dd8e5a4d), [Commit](https://github.com/open-webui/open-webui/commit/8e74cac8decc0a54137d7214e70c33b0dd52a99a), [Commit](https://github.com/open-webui/open-webui/commit/93a34bb25b32ae0b3a876fd3161c7778227b76bf), [Commit](https://github.com/open-webui/open-webui/commit/4c2d864b3f4c1ae6c4c9bd93aea078d4bf520463), [#26790](https://github.com/open-webui/open-webui/pull/26790), [#27293](https://github.com/open-webui/open-webui/pull/27293), [Docs:#1328](https://github.com/open-webui/docs/issues/1328)
- 📨 **Non-streaming requests to strict providers.** A request that is not streaming no longer carries the streaming-only usage option, which some providers reject outright.
- 🪝 **Tool calls with structured arguments.** A provider that sends a tool call's arguments as an object, or as nothing at all, no longer breaks the reply partway through. [#27195](https://github.com/open-webui/open-webui/issues/27195)
- 🧬 **Shared pipe model tool calls.** Non-admin users of a shared model built on a pipe or manifold model no longer see the response silently stop right after a tool call. [#26906](https://github.com/open-webui/open-webui/pull/26906), [#26900](https://github.com/open-webui/open-webui/issues/26900)
- 🧑‍🔧 **Startup as an arbitrary user.** Running the image as a non-root account, as OpenShift and similar setups do, no longer fills the boot log with permission errors while it writes its own icons and manifest. [#26664](https://github.com/open-webui/open-webui/pull/26664), [#26662](https://github.com/open-webui/open-webui/issues/26662)
- 🩹 **Startup with an ownerless tool or function.** A tool or function left without an owner no longer prevents the application from starting, which had blocked all chat responses until it was removed. [#26850](https://github.com/open-webui/open-webui/pull/26850), [#26843](https://github.com/open-webui/open-webui/issues/26843)
- 🏷️ **Model names containing a connection prefix.** A prefix set on a connection is now removed only from the front of the model name, so a model whose own name contains that text is no longer mangled before the request is sent. [Commit](https://github.com/open-webui/open-webui/commit/ed663f16ecaaf99de194922d1634ecc5d906c703)
- 🦙 **Newly pulled Ollama models.** Sending a message to a model that was pulled after the list was last built now refreshes the list and proceeds, instead of reporting the model as not found. [Commit](https://github.com/open-webui/open-webui/commit/ed663f16ecaaf99de194922d1634ecc5d906c703), [#27353](https://github.com/open-webui/open-webui/pull/27353)
- 🗑️ **Deleting a model from the selector.** Removing a workspace model from the model selector menu now deletes just that model and leaves the underlying one in place, instead of failing with a not found error. [#26819](https://github.com/open-webui/open-webui/pull/26819)
- 🔑 **Connecting a remote MCP server over OAuth.** Setting up a remote MCP server now reports plainly when its sign-in details cannot be discovered, rather than saving an unusable connection that failed with a server error the moment you tried to authorise it. [#26654](https://github.com/open-webui/open-webui/pull/26654), [#26647](https://github.com/open-webui/open-webui/issues/26647)
- 🪢 **Tool servers with cross-referencing types.** A tool server whose description defines types that refer to each other now loads its tools instead of failing outright, so the integration appears in model and tool selection again. [#27413](https://github.com/open-webui/open-webui/pull/27413), [#27239](https://github.com/open-webui/open-webui/issues/27239)
- 👥 **Previewing what someone can use.** The preview of a person's access now includes the models, knowledge bases and tools they own, not just the ones shared with them. [Commit](https://github.com/open-webui/open-webui/commit/a9a3e5b95c8e641881fedc1ce7431eedab9a371b), [#27423](https://github.com/open-webui/open-webui/pull/27423), [#27407](https://github.com/open-webui/open-webui/discussions/27407)
- 🧰 **Model editor loading.** The model editor no longer fails to open when its tool list can't be loaded, falling back gracefully instead. [Commit](https://github.com/open-webui/open-webui/commit/10724d057af13a826c52e92b1c01a031656768d5)
- 🗃️ **Milvus Lite collection creation.** Setting up collections now succeeds on embedded Milvus Lite, which previously could fail while creating the resource index. [#26911](https://github.com/open-webui/open-webui/pull/26911)
- 🧽 **Milvus log noise.** Instances backed by Milvus no longer fill their logs with deprecation warnings while indexing and retrieving, and keep working with future PyMilvus releases that drop the old interface entirely. [#27521](https://github.com/open-webui/open-webui/pull/27521), [#26978](https://github.com/open-webui/open-webui/issues/26978)
- 🚏 **Stray terminal containers.** Terminal orchestrator connections that use a policy now send every request through that policy, so each person no longer ends up with a second unintended container alongside the intended one. [#26945](https://github.com/open-webui/open-webui/issues/26945), [Commit](https://github.com/open-webui/open-webui/commit/7088d245bb45fc69c0b22748563b9f3c6f0daa73)
- 🔦 **Connections on hardened instances.** With the admin access bypass turned off, a connection that has no access grants yet is now reachable by administrators again, instead of being hidden from everyone including the admin who created it. [#27581](https://github.com/open-webui/open-webui/pull/27581), [#27580](https://github.com/open-webui/open-webui/issues/27580), [#27064](https://github.com/open-webui/open-webui/issues/27064)
- ♻️ **Connection changes take effect immediately.** Saving connection settings now refreshes the model list straight away, instead of leaving the previous models in place until the server was restarted.
- 🚫 **Disabled OpenAI connections are enforced.** Turning off the OpenAI API now blocks chat requests to it and clears its models, rather than only hiding it from the interface.
- 🪛 **Deleting an Ollama connection.** Removing an Ollama connection now saves straight away, instead of reappearing until the Ollama API switch was toggled afterwards. [#27483](https://github.com/open-webui/open-webui/pull/27483), [#27482](https://github.com/open-webui/open-webui/issues/27482)
- 🧹 **Orphaned sessions get cleaned up.** The instance that reaps sessions left behind by a crashed worker now keeps trying if another instance holds the job, rather than one instance giving up for good and leaving stale sessions to accumulate, and the lock it uses can no longer be released or renewed by an instance that does not hold it. [Commit](https://github.com/open-webui/open-webui/commit/bf35f64a7f14161933dfa608577a977d107b1569), [Commit](https://github.com/open-webui/open-webui/commit/846ba80a9d5e75837d3db37185e9a23b1e6bfe78)
- 🧊 **Redis cluster connections.** A deployment using Redis in cluster mode is no longer handed a connection built for a single server, or the reverse, when both point at the same address. [Commit](https://github.com/open-webui/open-webui/commit/fc4906c9e9df3fa42bb9073ac197383347caa853)
- 🚏 **Stopping a reply when Redis is configured.** The stop button now actually halts generation on Redis-backed deployments, where the listener that carries stop requests between instances quietly died after a few idle seconds and left tokens streaming on, and a new "REDIS_SOCKET_TIMEOUT" setting controls that timeout. [#27104](https://github.com/open-webui/open-webui/pull/27104), [#26779](https://github.com/open-webui/open-webui/issues/26779)
- 🛟 **Redis failover on timeouts.** A Redis connection that times out now retries against a freshly resolved primary instead of failing, so Sentinel setups recover from a failover rather than erroring out. [Commit](https://github.com/open-webui/open-webui/commit/75a8a0046b5b2ebd9942b25035b346aa953f81cc), [#27210](https://github.com/open-webui/open-webui/issues/27210)
- 👣 **First sign-in through a trusted header.** Two requests arriving together for someone signing in for the first time through a trusted header no longer create two accounts for the same person, and the database now refuses a second account for an address that already exists, whatever its capitalisation. [Commit](https://github.com/open-webui/open-webui/commit/b190dcf3caa00dc8b7b9c7312828298d9143f60d), [Commit](https://github.com/open-webui/open-webui/commit/50e050e1957de40caa9df479b4c0d9b814f1f623), [#27571](https://github.com/open-webui/open-webui/pull/27571), [#27117](https://github.com/open-webui/open-webui/issues/27117)
- 🔧 **Sign-on settings from environment variables.** Single sign-on settings supplied through environment variables are no longer overridden by stale values saved at first startup, so changing them takes effect. [#26928](https://github.com/open-webui/open-webui/pull/26928), [#26917](https://github.com/open-webui/open-webui/issues/26917)
- 🎫 **Expired identity tokens sent to tools.** A sign-in session is now refreshed before the earliest of its tokens expires, so tools and pipes that forward your identity no longer hand a downstream service a token it rejects. [#27520](https://github.com/open-webui/open-webui/pull/27520), [#27066](https://github.com/open-webui/open-webui/issues/27066)
- 🎫 **Sign-in tokens that never expire.** A provider that returns no expiry and no way to refresh is now taken at its word, instead of being given an invented one-hour lifetime that left the session unusable afterwards. [Commit](https://github.com/open-webui/open-webui/commit/98656b7c5e29383b61d2113164466b8d4ab1d424), [#26802](https://github.com/open-webui/open-webui/pull/26802), [#26141](https://github.com/open-webui/open-webui/issues/26141)
- 🔓 **Single sign-on after a key rotation.** Signing in with OIDC now recovers when the provider rotates its signing key, refreshing the cached keys and retrying instead of failing with an invalid credentials error. [#27310](https://github.com/open-webui/open-webui/pull/27310), [#26407](https://github.com/open-webui/open-webui/issues/26407)
- 🔑 **Signing in after a session expires.** An expired session now cleanly returns you to the sign-in page and back to where you were afterwards, instead of bouncing you away from the sign-in page or leaving a stale session behind. [Commit](https://github.com/open-webui/open-webui/commit/609cc6ad9b597c6a3f4df6f9dba93d6ff6ec1f18), [Commit](https://github.com/open-webui/open-webui/commit/29782aba01b8f34625949170dc9ee9e1c5872893), [#26751](https://github.com/open-webui/open-webui/pull/26751), [#26731](https://github.com/open-webui/open-webui/issues/26731)
- 🫥 **Temporary chats and channels write nothing.** Generating or editing an image and status updates in a temporary chat or a channel message no longer try to save themselves against a conversation that was never stored, and the task list tools are no longer offered there at all rather than being offered and then failing. [Commit](https://github.com/open-webui/open-webui/commit/d484a2a99e3a0c21fdcad007a50ebc412fffbb2e), [Commit](https://github.com/open-webui/open-webui/commit/d2936c880cfc8cb71bb5c235926048ae189bba25), [Commit](https://github.com/open-webui/open-webui/commit/b45c020f68a9499b66e598e854b17a3f232b6cf2), [Commit](https://github.com/open-webui/open-webui/commit/71c4da8c065491a96e41da3c9f0c663e5f759468), [#27432](https://github.com/open-webui/open-webui/issues/27432)
- 🎞️ **Artifacts panel reopening itself.** The artifacts panel now opens once when a finished block is detected, so closing it partway through a reply no longer sees it forced back open on every word that follows. [Commit](https://github.com/open-webui/open-webui/commit/4856afcef8251969f751ade5760cefea9577c051), [#27399](https://github.com/open-webui/open-webui/issues/27399)
- 🏞️ **Images returned by a tool.** Images a tool produces are now passed to the model in a form the OpenAI-compatible providers accept, so it can actually look at them instead of receiving a result it cannot read. [Commit](https://github.com/open-webui/open-webui/commit/dd86b984bd508cf2841f08dae90411dfb5fe407f)
- 🖼️ **External message images.** Images hosted on other sites and referenced in a message now display inline instead of being replaced with a placeholder. [Commit](https://github.com/open-webui/open-webui/commit/890bfd0d9771d1919ce24f04e11b6c96589fca5b)
- 🔣 **Names containing a vertical bar.** What you insert with the at sign or a slash is now recorded by the key you typed rather than guessed from its name, so a prompt or model whose name contains a vertical bar is no longer mistaken for a skill. [Commit](https://github.com/open-webui/open-webui/commit/e28b391e514384ec329ca871d02189aa81fb1a00)
- 〰️ **Text above a collapsible block.** A line written directly above a collapsible section is no longer turned into a large heading, and the section itself still renders as a collapsible widget rather than leaking its markup. [Commit](https://github.com/open-webui/open-webui/commit/7d77efe0f1cfa4782893ffde78419a96a95240f4), [#27148](https://github.com/open-webui/open-webui/pull/27148), [#27001](https://github.com/open-webui/open-webui/issues/27001)
- ✳️ **Asterisks in the message input.** Wrapping a word in asterisks no longer silently turns it italic and swallows the asterisks, so your prompt reaches the model exactly as you typed it. [Commit](https://github.com/open-webui/open-webui/commit/001775d8e868ce44f125e0d683dd50363f0e8318)
- 📶 **Reconnect warnings on mobile.** Switching back to Open WebUI after using another app no longer flashes a connection lost warning while the tab wakes up and reconnects on its own. [Commit](https://github.com/open-webui/open-webui/commit/63ada247066dfc51e0e9559366f0cfd9a98db40b)
- 🧭 **Sidebar access from the automation editor.** Opening an automation on a phone no longer hides the sidebar button, so you can move around without leaving the editor first.
- 🔣 **Chats containing unusual characters.** Broken character sequences are now cleaned out of text before it is stored, so a conversation that picked one up still saves and still opens instead of failing to load. [Commit](https://github.com/open-webui/open-webui/commit/43e7eefa959918baf9fbf867a12b5d721fd782af), [#27201](https://github.com/open-webui/open-webui/pull/27201), [#27081](https://github.com/open-webui/open-webui/issues/27081)
- 📛 **Failures after a tool call.** A reply that fails while continuing after a tool call or a code interpreter run now says so, instead of stopping mid-answer with nothing to explain why. [Commit](https://github.com/open-webui/open-webui/commit/8ab44ed3b153dd8d8d57a444c98d100f281f7f7e), [#27426](https://github.com/open-webui/open-webui/pull/27426), [#27411](https://github.com/open-webui/open-webui/issues/27411)
- 💾 **Errors kept after reloading.** An error that ends a streamed reply is now saved to the conversation, so it is still there when you reload instead of disappearing. [#27365](https://github.com/open-webui/open-webui/pull/27365), [#27074](https://github.com/open-webui/open-webui/issues/27074)
- 💬 **Readable error messages.** Errors in a conversation now always show readable text that wraps instead of running off the edge, including errors that arrive wrapped inside another error.
- 🪝 **Blocked webhook targets look like failures.** A webhook pointing at an address that is not publicly reachable is now skipped with a short warning, instead of an error and a full traceback that read like the server crashing on startup. [Commit](https://github.com/open-webui/open-webui/commit/0671b7aa2b59f5c6235bfa85d6aba54e2ba91353), [#26975](https://github.com/open-webui/open-webui/issues/26975)
- 🕵️ **Values printed in error logs.** A failure no longer prints the contents of nearby variables alongside its traceback, which could put keys and message content into the logs, and a new "LOGURU_DIAGNOSE" setting turns that detail back on for debugging. [Commit](https://github.com/open-webui/open-webui/commit/6aebfd88e938d1cd139068b5737a83a82ff393ed), [#26814](https://github.com/open-webui/open-webui/pull/26814)
- 🪵 **Empty audit exclusion list.** Clearing the list of paths excluded from audit logging no longer switches off auditing altogether, so requests are recorded as intended. [#27370](https://github.com/open-webui/open-webui/pull/27370), [Commit](https://github.com/open-webui/open-webui/commit/2ef6c76f5126ccdef5d1c814004920941275f45d)
- 🗒️ **Readable audit log bodies.** Audit logs that record response bodies now store them as readable text instead of compressed data, so entries are legible whenever a browser requested compression. [#27369](https://github.com/open-webui/open-webui/pull/27369)
- 👍 **Rating in feedback events.** Events sent when someone rates a response now carry the rating that was given, instead of reporting it as empty. [Commit](https://github.com/open-webui/open-webui/commit/300302d43259e119cd88247b3f246bea82b4dd8e), [#26840](https://github.com/open-webui/open-webui/issues/26840)
- ⏱️ **Accurate request timing header.** The processing time reported on each response now includes fractions of a second instead of rounding everything under a second down to zero. [#27368](https://github.com/open-webui/open-webui/pull/27368)
- 📋 **Provider rejection logging.** When a model provider rejects a request, the reason it gave is now recorded in the server logs, so administrators can diagnose failures without querying the provider directly. [#27238](https://github.com/open-webui/open-webui/pull/27238), [#27237](https://github.com/open-webui/open-webui/issues/27237), [#26253](https://github.com/open-webui/open-webui/issues/26253)
- ⏳ **Faster licensed startup.** Instances with a license key no longer wait on the license server during startup, so the app becomes ready to serve traffic without that delay. [Commit](https://github.com/open-webui/open-webui/commit/8f7753331752e72b17ef8f055318ab548a73f4b8), [Commit](https://github.com/open-webui/open-webui/commit/0c7ddbdb4f7dbd46f1dadc3242dbb13b81b47758)
- 📅 **Calendar invitation responses.** Whether you have accepted an invitation is now decided by your own response rather than by whoever created the event, and invitations you decline disappear from your calendar. [#27007](https://github.com/open-webui/open-webui/pull/27007)
- 🗓️ **Schedules written by hand.** A recurrence rule is now read the same way whether it is written in upper or lower case, a start date in the rule is respected, second-by-second rules are understood, and a rule that cannot be supported is refused with a clear message instead of behaving unpredictably. [Commit](https://github.com/open-webui/open-webui/commit/c4ae8c86786fed521960466f6d8eef8af22c2946), [Commit](https://github.com/open-webui/open-webui/commit/2d928df30443516a9a3d6b71b0f31426a2362499), [#27470](https://github.com/open-webui/open-webui/pull/27470)
- 📅 **One calendar event stalling the server.** Working out when a repeating event happens next now walks its rule once rather than re-counting from the beginning for every occurrence, so an event repeating every minute from an old start date can no longer occupy the server for everyone. [#27468](https://github.com/open-webui/open-webui/pull/27468)
- ⏰ **Recurring automation scheduling.** Automations that repeat every few minutes or hours now align to the clock and are no longer wrongly rejected as having no upcoming runs when the server clock is ahead of your timezone. [Commit](https://github.com/open-webui/open-webui/commit/b3aead23da6cf8ebeedbd9fa3b97c7ac1a3f54ec), [#26954](https://github.com/open-webui/open-webui/issues/26954)
### Changed
- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
- 🛠️ **Admin settings moved into settings.** Admin settings and the analytics dashboard are no longer separate pages and now open alongside your personal settings in the settings window, under their own Admin section, with the old links redirecting there. [Commit](https://github.com/open-webui/open-webui/commit/c1460570b7e2897a3e648441a0206ad25e603b8c), [Commit](https://github.com/open-webui/open-webui/commit/3ce3c529365a3cd9e5631b14bfffead6baa2b1ed), [Commit](https://github.com/open-webui/open-webui/commit/667cba1a9561166941f59faf3bfa24038288b449)
- 📁 **Workspace actions in one menu.** Creating, importing, and exporting workspace items no longer have their own buttons on each page and are now reached from a single Create menu in the workspace header, with creating a prompt or knowledge base opening a dialog rather than a separate page. [Commit](https://github.com/open-webui/open-webui/commit/05e3f713175c1eea43a99f29521eb01700c21d3c), [Commit](https://github.com/open-webui/open-webui/commit/f8350360dfd60ff890b73fe2f39aaf20a52ad28b), [Commit](https://github.com/open-webui/open-webui/commit/91277726cd666276ce4b41a722a142eb539089b8), [Commit](https://github.com/open-webui/open-webui/commit/1760b073c7595d4075a91b986520ff8eeeaebc35)
- 🔐 **Administrators no longer reach other people's automations.** Viewing, editing, running and deleting an automation is now limited to the person who created it, so an administrator with a link to someone else's automation is refused rather than allowed through. [Commit](https://github.com/open-webui/open-webui/commit/f798d05586a140f1a6b51f1e51b2b2a63d079d45)
- 🏷️ **Shorter titles without emojis.** Automatically generated titles for chats and notes are now two to four words and no longer include an emoji, and anyone who prefers the old style can restore it by editing the title generation prompt in admin settings. [Commit](https://github.com/open-webui/open-webui/commit/50d3c927bfed3b8dd94fd9f79bff258a84ecbd92), [Commit](https://github.com/open-webui/open-webui/commit/7a9928ef172b7c280c377c86cb52957e39340158)
- 🗂️ **Archived chats moved to settings.** The Archived Chats shortcut is no longer in the user menu, and your archived conversations are now reached through Settings, where they can also be searched and sorted. [Commit](https://github.com/open-webui/open-webui/commit/9f17c5960a0e47a09773da4bba12997a31222fc8), [Commit](https://github.com/open-webui/open-webui/commit/8dd862d3383978f21111e63fb2d6029711abed9a)
- 🔢 **Usage now reports the latest call separately.** In a response's usage block, "prompt_tokens" and "completion_tokens" now carry the counts from the most recent model call rather than the running total, while "input_tokens", "output_tokens" and "total_tokens" stay cumulative, so anything reading the first pair for billing should read the second set instead. [Commit](https://github.com/open-webui/open-webui/commit/df94268e892cbb66675170a6c78846aef23f6e89), [#27031](https://github.com/open-webui/open-webui/issues/27031)
- 🧳 **The "python-jose" library is no longer installed.** Nothing in Open WebUI imports it anymore, so it and the two packages it pulled in have been dropped from the image, and any tool or function that imports it directly now needs to install it itself. [#27444](https://github.com/open-webui/open-webui/pull/27444)
- 📦 **Storage emulator no longer bundled.** The optional Google Cloud Storage emulator is no longer installed as part of the full package, so anyone who relied on it for local storage testing now needs to install "gcp-storage-emulator" themselves. [Commit](https://github.com/open-webui/open-webui/commit/30415c925a18b1ea1c3f2739bd944dd939f020cf)
## [0.10.2] - 2026-07-01
### Added
- 💭 **Streamed reasoning display.** Models that emit thinking or reasoning now show that content as it streams, and it renders correctly in the chat overview and in exported conversations. [Commit](https://github.com/open-webui/open-webui/commit/0b75445ff9a42e37640c034812d0de9b84039e60), [Commit](https://github.com/open-webui/open-webui/commit/af1c0eee89810fa4c36e3eb7e4eba6de685bd7ca), [Commit](https://github.com/open-webui/open-webui/commit/4b08d65597e5b634b7191b0bd6d28feeafcc2a48), [Commit](https://github.com/open-webui/open-webui/commit/fa2abe4cb6a085a4c9045bf8d8ff4b6beffdef6a)
- 🗂️ **Folder uploads to knowledge bases.** Dragging a folder into a knowledge base, or syncing one, now recreates its subfolder structure instead of flattening everything into loose files. [#26130](https://github.com/open-webui/open-webui/issues/26130), [Commit](https://github.com/open-webui/open-webui/commit/2ed8934f5b2bc8a11c74ef2f34cdb62ef024809e)
- 🧠 **Memory system context toggle.** Administrators can now keep memory tools available while choosing not to add stored memories to the system context, using the new 'Memory System Context' toggle in admin settings. [Commit](https://github.com/open-webui/open-webui/commit/4067e357b2ff9e2fb59866d24656e832908fb6fe)
- 🧹 **Tidier automatic memories.** Automatically saved memories now focus on enduring details like preferences and goals and skip one-off things like meals, routine events, or passing mood unless you ask to remember them. [Commit](https://github.com/open-webui/open-webui/commit/80af65c24adac5140a39a2b5687a3b669b86719f)
- 🎙️ **Speech-to-text request format.** OpenAI-compatible speech-to-text can now send audio as either a multipart upload or base64 JSON, selectable in admin audio settings. [Commit](https://github.com/open-webui/open-webui/commit/989c6c13f5d4c5cc255aec77aea14725104d9cb3)
- 🧰 **API configs via environment.** Administrators can now set per-connection Ollama and OpenAI API configurations through the "OLLAMA_API_CONFIGS" and "OPENAI_API_CONFIGS" environment variables. [Commit](https://github.com/open-webui/open-webui/commit/19d8f03bd2c64013b510f2a0eeb3513d452814a3)
- 📡 **Provider failure events.** Failed Ollama and OpenAI-compatible provider requests now emit a structured event describing the error type, provider, and status, giving administrators clearer visibility into upstream failures. [Commit](https://github.com/open-webui/open-webui/commit/4351c78b1e45bb0c5824f9d9eb911b395e343646)
- 🏟️ **Arena models via environment.** Administrators can now define evaluation arena models through the "EVALUATION_ARENA_MODELS" environment variable. [#26174](https://github.com/open-webui/open-webui/issues/26174)
- ♿ **Clearer high-contrast sidebar selection.** With high-contrast mode enabled, the currently selected chat in the sidebar now stands out with stronger colors, making it easier to tell which chat is active. [#26469](https://github.com/open-webui/open-webui/issues/26469), [Commit](https://github.com/open-webui/open-webui/commit/52ee5cb1b3aa8a8ad40b23d72d82db7c3522dbf8)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Thai, Portuguese (Brazil), Catalan, and Spanish were enhanced and expanded.
### Fixed
- 🛡️ **Security Advisory**: This release includes security and access-control fixes. We recommend updating production deployments at your earliest convenience. Not all security fixes in this version may be enumerated in the fixed section — some may be withheld for a short time to give administrators time to upgrade. [Advisories](https://github.com/open-webui/open-webui/security)
- 🔐 **Fewer unexpected logouts.** A single request returning an authorization error no longer signs you out while your session is still valid, since the app now confirms the session status before redirecting to login. [Commit](https://github.com/open-webui/open-webui/commit/56ee875e21cb3b137a5aa1eca6eb3c0731b14045)
- 🔒 **Web search domain filtering.** Domain allow and block rules for web search results are now matched against the host, closing a gap where some URLs could slip past the filter. [Commit](https://github.com/open-webui/open-webui/commit/688bda09fbe26619613aa487beda8059a5fd55ef)
- 🕵️ **Image prompt log privacy.** Image generation workflows are no longer written to server logs at the default log level, keeping user-authored prompt content out of operator-visible logs. [#26400](https://github.com/open-webui/open-webui/issues/26400), [Commit](https://github.com/open-webui/open-webui/commit/64b92ff08a09b1bf338d5d95b7bf6f9db0d7e857)
- 🗄️ **Safer database upgrades.** Upgrading an existing SQLite database no longer crashes during the user-table migration or corrupts saved user settings, resolving failures that could block startup or break login after an upgrade. [#26403](https://github.com/open-webui/open-webui/issues/26403), [Commit](https://github.com/open-webui/open-webui/commit/c416c6cad69cc4ce44faacdad87cc65862625fb4)
- ⚙️ **Saving settings as a non-admin.** Non-admin users can once again save their interface settings, such as the default model and theme, which previously failed with a server error while the interface incorrectly reported success. [#26627](https://github.com/open-webui/open-webui/issues/26627), [Commit](https://github.com/open-webui/open-webui/commit/9866a02863c12c466100aed832f0006225759493)
- 🕒 **Sidebar chat timestamps.** Chats in the sidebar now show when they were last active instead of when they were created, so the time label matches their position in the list and refreshes after each new message. [#26454](https://github.com/open-webui/open-webui/pull/26454), [#26451](https://github.com/open-webui/open-webui/issues/26451)
- 🎯 **Default model after refresh.** Your selected model is no longer cleared when you reload the page, as model selection now waits for the model list to finish loading. [Commit](https://github.com/open-webui/open-webui/commit/b6d4baeb7ea9bc83e5486ca98b3cae07d3869aa7)
- ⏳ **Tool dialogs no longer hang.** Dismissing a tool or function input dialog by clicking outside it now cancels the pending request instead of leaving the chat spinning indefinitely. [#26417](https://github.com/open-webui/open-webui/issues/26417), [Commit](https://github.com/open-webui/open-webui/commit/0016266c0652757e32aa44f2e1fb7311ac08db51)
- 🐍 **Reliable code execution loading.** Running Python code in chat now loads its runtime reliably, fixing sandbox startup failures that broke code execution and the Pyodide file viewer in recent releases. [#26625](https://github.com/open-webui/open-webui/pull/26625), [#26390](https://github.com/open-webui/open-webui/issues/26390)
- 🤖 **Models with null capabilities.** Chatting with a model whose capabilities are unset no longer fails with an error when the memory feature or automations are involved. [#26412](https://github.com/open-webui/open-webui/issues/26412), [Commit](https://github.com/open-webui/open-webui/commit/650b81792582268345073ce3757ac14356189d75), [Commit](https://github.com/open-webui/open-webui/commit/0016266c0652757e32aa44f2e1fb7311ac08db51)
- 🔎 **Searchable responses.** Chat search again finds assistant messages whose text is stored as structured output, which were previously skipped. [#26405](https://github.com/open-webui/open-webui/pull/26405)
- 🔔 **Chat notification previews.** Background chat completion notifications and toasts now show a clean response preview instead of appearing blank for messages stored as structured output. [Commit](https://github.com/open-webui/open-webui/commit/c98d8ecaccc7284573b050e88b3857c4aeba3860)
- 💾 **Banner and config startup.** Setting configuration such as "WEBUI_BANNERS" no longer causes a startup failure, since admin configuration values are now stored correctly regardless of their data type. [#26431](https://github.com/open-webui/open-webui/issues/26431), [Commit](https://github.com/open-webui/open-webui/commit/ab22fe64bdd10ba86845dcd46dfd7f619b22366f)
- 📑 **RAG Template visibility.** The RAG Template editor now stays visible in admin document settings even when Bypass Embedding and Retrieval is enabled, since the template still applies to document content in that mode. [#26126](https://github.com/open-webui/open-webui/issues/26126), [Commit](https://github.com/open-webui/open-webui/commit/8fe480250f649824441a7b4d725a502683134713)
- 🧬 **Editing derived models.** Editing a workspace model no longer clears its base model, including when that base is a preset or the model itself. [Commit](https://github.com/open-webui/open-webui/commit/092b5857bbda01be53619e26f42b1fa9b64a2b44), [Commit](https://github.com/open-webui/open-webui/commit/54f31c630afa75e9c81acaa048e1bd901254ba92)
### Changed
- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
## [0.10.1] - 2026-06-29
### Fixed
- 🤝 **Shared folder read-only chats no longer sign users out.** Opening or reading chats from shared folders now keeps the current session active when a resource-level access error is returned, instead of incorrectly showing "Session expired. Please sign in again."
## [0.10.0] - 2026-06-29
### Added

View file

@ -20,7 +20,7 @@ Examples of behavior that contribute to a positive and professional community in
- **Respecting others.** Be considerate, listen actively, and engage with empathy toward others' viewpoints and experiences.
- **Constructive feedback.** Provide actionable, thoughtful, and respectful feedback that helps improve the project and encourages collaboration. Avoid unproductive negativity or hypercriticism.
- **Recognizing volunteer contributions.** Appreciate that contributors dedicate their free time and resources selflessly. Approach them with gratitude and patience.
- **Recognizing volunteer contributions.** Appreciate that **contributors dedicate their free time and resources selflessly**. Approach them with gratitude and patience.
- **Focusing on shared goals.** Collaborate in ways that prioritize the health, success, and sustainability of the community over individual agendas.
Examples of unacceptable behavior include:
@ -32,6 +32,7 @@ Examples of unacceptable behavior include:
- **Entitlement, demand, or aggression toward contributors.** Volunteers are under no obligation to provide immediate or personalized support. Rude or dismissive behavior will not be tolerated.
- **Unproductive or destructive behavior.** This includes venting frustration as hostility ("tantrums"), hypercriticism, attention-seeking negativity, or anything that distracts from the project's goals.
- **Spamming and promotional exploitation.** Sharing irrelevant product promotions or self-promotion in the community is not allowed unless it directly contributes value to the discussion.
- Posting low-effort, hard to read, essay-length AI generated comments or other forms of low-quality, hard to parse content that puts the burden of understanding on the reader.
### Feedback and Community Engagement

View file

@ -126,7 +126,7 @@ RUN chown -R $UID:$GID /app $HOME
# Install common system dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git build-essential pandoc gcc netcat-openbsd curl jq ca-certificates \
git build-essential pandoc gcc curl jq ca-certificates \
libmariadb-dev \
python3-dev \
ffmpeg libsm6 libxext6 zstd \
@ -149,7 +149,7 @@ RUN set -e; \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
python -c "import nltk; nltk.download('punkt_tab')"; \
python -c "import nltk; nltk.download('punkt_tab', download_dir='/usr/local/share/nltk_data')"; \
else \
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir; \
uv pip install --system -r requirements.txt --no-cache-dir; \
@ -158,12 +158,18 @@ RUN set -e; \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
python -c "import nltk; nltk.download('punkt_tab')"; \
python -c "import nltk; nltk.download('punkt_tab', download_dir='/usr/local/share/nltk_data')"; \
fi; \
fi; \
mkdir -p /app/backend/data; chown -R $UID:$GID /app/backend/data/; \
if [ -d /app/backend/data/cache ]; then chmod -R a+rX /app/backend/data/cache; fi; \
rm -rf /var/lib/apt/lists/*;
# Optional: PPTX parsing through unstructured may need spaCy's English model.
# Keep this out of the default image to avoid the extra image bloat; deployments
# with read-only site-packages can uncomment it and bake the model in.
# RUN python -m spacy download en_core_web_sm
# Install Ollama if requested
RUN if [ "$USE_OLLAMA" = "true" ]; then \
date +%s > /tmp/ollama_build_hash && \
@ -184,6 +190,17 @@ COPY --chown=$UID:$GID --from=build /app/package.json /app/package.json
# copy backend files
COPY --chown=$UID:$GID ./backend .
# The backend rewrites its bundled static assets (favicons, splash, manifest,
# loader.js, ...) under open_webui/static at startup. Make that directory
# writable by an arbitrary UID -- which under OpenShift's restricted SCC is
# always a member of GID 0 -- so those writes don't fail with EACCES and crash
# the boot log with "[Errno 13] Permission denied". `chmod -R g=u` mirrors the
# owner bits onto the group (the Red Hat arbitrary-UID idiom). This is applied
# unconditionally because it targets a directory the app writes on every start;
# the broader, opt-in USE_PERMISSION_HARDENING below covers the rest of /app.
RUN chgrp -R 0 /app/backend/open_webui/static && \
chmod -R g=u /app/backend/open_webui/static
EXPOSE 8080
HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq -ne 'input.status == true' || exit 1

View file

@ -8,7 +8,7 @@
![GitHub top language](https://img.shields.io/github/languages/top/open-webui/open-webui)
![GitHub last commit](https://img.shields.io/github/last-commit/open-webui/open-webui?color=red)
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/open-webui)
![Open WebUI Banner](./banner.png)
@ -93,11 +93,9 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume
Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it:
- **Open Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat.
- 💻 **Open WebUI Computer** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
- 🔒 **Terminals** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
- 💻 **cptr** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
- ⚡ **Open Terminal** and **Terminals (Enterprise)** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal) & [open-webui/terminals](https://github.com/open-webui/terminals)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. Terminals gives you per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
- 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync.

View file

@ -1,3 +1,3 @@
export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
PORT="${PORT:-8080}"
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --ws-per-message-deflate "${UVICORN_WS_PER_MESSAGE_DEFLATE:-true}" --reload

View file

@ -18,6 +18,9 @@ def version_callback(value: bool) -> None:
if value:
from open_webui.env import VERSION
# LICENSE covers this Open WebUI CLI identifier.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
typer.echo(f'Open WebUI version: {VERSION}')
raise typer.Exit()
@ -71,7 +74,7 @@ def serve(
os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH)
import open_webui.main # noqa: F401
from open_webui.env import UVICORN_WORKERS # Import the workers setting
from open_webui.env import UVICORN_WORKERS, UVICORN_WS_PER_MESSAGE_DEFLATE
# On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop,
# which is incompatible with psycopg v3 async. Setting loop='none' lets
@ -84,6 +87,7 @@ def serve(
port=port,
forwarded_allow_ips='*',
workers=UVICORN_WORKERS,
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
loop=loop,
)
@ -94,12 +98,15 @@ def dev(
port: int = 8080,
reload: bool = True,
):
from open_webui.env import UVICORN_WS_PER_MESSAGE_DEFLATE
uvicorn.run(
'open_webui.main:app',
host=host,
port=port,
reload=reload,
forwarded_allow_ips='*',
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
)

View file

@ -1,7 +1,6 @@
from __future__ import annotations
import base64
import json
import logging
import os
import shutil
@ -20,6 +19,7 @@ from pydantic import BaseModel
from open_webui.env import (
DATA_DIR,
DATABASE_URL,
ENABLE_ADMIN_CHAT_ACCESS,
ENABLE_DB_MIGRATIONS,
ENV,
FRONTEND_BUILD_DIR,
@ -35,11 +35,12 @@ from open_webui.env import (
log,
)
from open_webui.models.config import Config
from open_webui.utils.json_codec import JSONCodec
async def seed_registered_defaults():
await Config.rename_prefix('rag.web', 'web')
await Config.repair_flattened_dict_configs()
await Config.repair_config_rows()
await Config.seed_defaults(DEFAULT_CONFIG)
@ -84,7 +85,7 @@ async def import_legacy_config_json():
if not os.path.exists(f'{DATA_DIR}/config.json'):
return
with open(f'{DATA_DIR}/config.json', 'r') as _f:
await Config.upsert(json.load(_f))
await Config.upsert(JSONCodec.loads(_f.read()))
os.rename(f'{DATA_DIR}/config.json', f'{DATA_DIR}/old_config.json')
@ -114,6 +115,9 @@ for file_path in (FRONTEND_BUILD_DIR / 'static').glob('**/*'):
except Exception as e:
logging.error(f'An error occurred: {e}')
# LICENSE covers copied Open WebUI logo/favicon assets.
# Do not alter, remove, obscure, or replace them except as LICENSE permits:
# https://docs.openwebui.com/license.
frontend_favicon = FRONTEND_BUILD_DIR / 'static' / 'favicon.png'
if frontend_favicon.exists():
@ -181,6 +185,9 @@ CACHE_DIR.mkdir(parents=True, exist_ok=True)
# CUSTOM_NAME (Legacy)
####################################
# LICENSE covers this legacy Open WebUI branding path.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
CUSTOM_NAME = os.getenv('CUSTOM_NAME', '')
if CUSTOM_NAME:
@ -270,9 +277,9 @@ def _resolve_ollama_base_url(url: str) -> str:
if not default.result() and fallback.result():
url = url.replace(':11434', ':12434')
log.info(f'Ollama port 11434 unreachable on {host}, falling back to 12434')
log.info('Ollama port 11434 unreachable on %s, falling back to 12434', host)
elif not default.result():
log.info(f'Ollama ports 11434 and 12434 both unreachable on {host}')
log.info('Ollama ports 11434 and 12434 both unreachable on %s', host)
return url
@ -290,6 +297,16 @@ OLLAMA_BASE_URLS = [url.strip() for url in OLLAMA_BASE_URLS.split(';')]
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS
OLLAMA_API_CONFIGS = {}
_ollama_api_configs = os.getenv('OLLAMA_API_CONFIGS', '')
if _ollama_api_configs:
try:
parsed = JSONCodec.loads(_ollama_api_configs)
if isinstance(parsed, dict):
OLLAMA_API_CONFIGS = parsed
else:
log.warning('OLLAMA_API_CONFIGS must be a JSON object, ignoring')
except (JSONCodec.JSONDecodeError, TypeError):
log.warning('OLLAMA_API_CONFIGS is not valid JSON, ignoring')
####################################
# OPENAI_API
@ -327,6 +344,16 @@ OPENAI_API_BASE_URLS = [
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS
OPENAI_API_CONFIGS = {}
_openai_api_configs = os.getenv('OPENAI_API_CONFIGS', '')
if _openai_api_configs:
try:
parsed = JSONCodec.loads(_openai_api_configs)
if isinstance(parsed, dict):
OPENAI_API_CONFIGS = parsed
else:
log.warning('OPENAI_API_CONFIGS must be a JSON object, ignoring')
except (JSONCodec.JSONDecodeError, TypeError):
log.warning('OPENAI_API_CONFIGS is not valid JSON, ignoring')
# Get the actual OpenAI API key based on the base URL
OPENAI_API_KEY = ''
@ -349,7 +376,7 @@ ENABLE_BASE_MODELS_CACHE = os.getenv('ENABLE_BASE_MODELS_CACHE', 'False').lower(
####################################
try:
tool_server_connections = json.loads(os.getenv('TOOL_SERVER_CONNECTIONS', '[]'))
tool_server_connections = JSONCodec.loads(os.getenv('TOOL_SERVER_CONNECTIONS', '[]'))
except Exception as e:
log.exception(f'Error loading TOOL_SERVER_CONNECTIONS: {e}')
tool_server_connections = []
@ -363,12 +390,12 @@ OAUTH_CLIENT_TIMEOUT = os.getenv('OAUTH_CLIENT_TIMEOUT', '')
# TERMINAL_SERVER
####################################
terminal_server_connections = json.loads(os.getenv('TERMINAL_SERVER_CONNECTIONS', '[]'))
terminal_server_connections = JSONCodec.loads(os.getenv('TERMINAL_SERVER_CONNECTIONS', '[]'))
TERMINAL_SERVER_CONNECTIONS = terminal_server_connections
try:
TERMINAL_PROXY_HEADERS = json.loads(os.getenv('TERMINAL_PROXY_HEADERS', '{}'))
TERMINAL_PROXY_HEADERS = JSONCodec.loads(os.getenv('TERMINAL_PROXY_HEADERS', '{}'))
except Exception:
TERMINAL_PROXY_HEADERS = {}
@ -394,6 +421,7 @@ CODE_EXECUTION_JUPYTER_TIMEOUT = int(os.getenv('CODE_EXECUTION_JUPYTER_TIMEOUT',
ENABLE_CODE_INTERPRETER = os.getenv('ENABLE_CODE_INTERPRETER', 'True').lower() == 'true'
ENABLE_MEMORIES = os.getenv('ENABLE_MEMORIES', 'True').lower() == 'true'
ENABLE_MEMORY_SYSTEM_CONTEXT = os.getenv('ENABLE_MEMORY_SYSTEM_CONTEXT', 'True').lower() == 'true'
ENABLE_MEMORY_BACKGROUND_REVIEW = os.getenv('ENABLE_MEMORY_BACKGROUND_REVIEW', 'False').lower() == 'true'
MEMORIES_REVIEW_INTERVAL_TURNS = int(os.getenv('MEMORIES_REVIEW_INTERVAL_TURNS', '10'))
MEMORIES_USER_CHAR_LIMIT = int(os.getenv('MEMORIES_USER_CHAR_LIMIT', '2000'))
@ -784,7 +812,7 @@ if VECTOR_DB == 'oracle23ai':
'Oracle23ai requires setting ORACLE_WALLET_DIR and ORACLE_WALLET_PASSWORD when using wallet authentication.'
)
log.info(f'VECTOR_DB: {VECTOR_DB}')
log.info('VECTOR_DB: %s', VECTOR_DB)
# S3 Vector
S3_VECTOR_BUCKET_NAME = os.getenv('S3_VECTOR_BUCKET_NAME', None)
@ -832,6 +860,13 @@ ONEDRIVE_SHAREPOINT_TENANT_ID = os.getenv('ONEDRIVE_SHAREPOINT_TENANT_ID', '')
# RAG Content Extraction
CONTENT_EXTRACTION_ENGINE = os.getenv('CONTENT_EXTRACTION_ENGINE', '').lower()
content_extraction_supported_media_mime_types = os.getenv('CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES')
CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES = (
[mime_type.strip() for mime_type in content_extraction_supported_media_mime_types.split(',') if mime_type.strip()]
if content_extraction_supported_media_mime_types is not None
else None
)
DATALAB_MARKER_API_KEY = os.getenv('DATALAB_MARKER_API_KEY', '')
DATALAB_MARKER_API_BASE_URL = os.getenv('DATALAB_MARKER_API_BASE_URL', '')
@ -866,8 +901,8 @@ MINERU_API_KEY = os.getenv('MINERU_API_KEY', '')
mineru_params = os.getenv('MINERU_PARAMS', '')
try:
mineru_params = json.loads(mineru_params)
except json.JSONDecodeError:
mineru_params = JSONCodec.loads(mineru_params)
except JSONCodec.JSONDecodeError:
mineru_params = {}
MINERU_PARAMS = mineru_params
@ -880,8 +915,8 @@ EXTERNAL_DOCUMENT_LOADER_API_KEY = os.getenv('EXTERNAL_DOCUMENT_LOADER_API_KEY',
external_document_loader_headers = os.getenv('EXTERNAL_DOCUMENT_LOADER_HEADERS', '')
try:
external_document_loader_headers = json.loads(external_document_loader_headers)
except json.JSONDecodeError:
external_document_loader_headers = JSONCodec.loads(external_document_loader_headers)
except JSONCodec.JSONDecodeError:
external_document_loader_headers = {}
if not isinstance(external_document_loader_headers, dict):
external_document_loader_headers = {}
@ -890,14 +925,16 @@ EXTERNAL_DOCUMENT_LOADER_HEADERS = external_document_loader_headers
TIKA_SERVER_URL = os.getenv('TIKA_SERVER_URL', 'http://tika:9998')
TIKA_SERVER_VERSION = os.getenv('TIKA_SERVER_VERSION', '3')
DOCLING_SERVER_URL = os.getenv('DOCLING_SERVER_URL', 'http://docling:5001')
DOCLING_API_KEY = os.getenv('DOCLING_API_KEY', '')
docling_params = os.getenv('DOCLING_PARAMS', '')
try:
docling_params = json.loads(docling_params)
except json.JSONDecodeError:
docling_params = JSONCodec.loads(docling_params)
except JSONCodec.JSONDecodeError:
docling_params = {}
DOCLING_PARAMS = docling_params
@ -938,6 +975,8 @@ RAG_FILE_MAX_COUNT = int(os.getenv('RAG_FILE_MAX_COUNT')) if os.getenv('RAG_FILE
RAG_FILE_MAX_SIZE = int(os.getenv('RAG_FILE_MAX_SIZE')) if os.getenv('RAG_FILE_MAX_SIZE') else None
ENABLE_KNOWLEDGE_FILE_RETENTION = os.getenv('ENABLE_KNOWLEDGE_FILE_RETENTION', 'False').lower() == 'true'
RAG_FILE_CONTENT_SEARCH_MAX_CHARS = int(os.getenv('RAG_FILE_CONTENT_SEARCH_MAX_CHARS', str(64 * 1024 * 1024)))
FILE_IMAGE_COMPRESSION_WIDTH = (
@ -960,7 +999,7 @@ PDF_EXTRACT_IMAGES = os.getenv('PDF_EXTRACT_IMAGES', 'False').lower() == 'true'
PDF_LOADER_MODE = os.getenv('PDF_LOADER_MODE', 'page')
RAG_EMBEDDING_MODEL = os.getenv('RAG_EMBEDDING_MODEL', 'sentence-transformers/all-MiniLM-L6-v2')
log.info(f'Embedding model set: {RAG_EMBEDDING_MODEL}')
log.info('Embedding model set: %s', RAG_EMBEDDING_MODEL)
RAG_TOKENIZER_MODEL = os.getenv('RAG_TOKENIZER_MODEL', '')
@ -988,7 +1027,7 @@ RAG_RERANKING_ENGINE = os.getenv('RAG_RERANKING_ENGINE', '')
RAG_RERANKING_MODEL = os.getenv('RAG_RERANKING_MODEL', '')
if RAG_RERANKING_MODEL != '':
log.info(f'Reranking model set: {RAG_RERANKING_MODEL}')
log.info('Reranking model set: %s', RAG_RERANKING_MODEL)
RAG_RERANKING_MODEL_AUTO_UPDATE = (
@ -1072,12 +1111,26 @@ ENABLE_LOCAL_WEB_FETCH = (
ENABLE_RAG_LOCAL_WEB_FETCH = ENABLE_LOCAL_WEB_FETCH
# Operators extend this through WEB_FETCH_FILTER_LIST.
DEFAULT_WEB_FETCH_FILTER_LIST = [
'!169.254.169.254',
'!fd00:ec2::254',
'!metadata.google.internal',
'!metadata.azure.com',
'!100.100.100.200',
'!168.63.129.16', # Azure platform channel, reachable from every Azure VM
'!192.88.99.0/24', # 6to4 relay anycast, deprecated by RFC 7526
'!224.0.0.0/4', # IPv4 multicast
'!::ffff:0:0:0/96', # IPv4-translated (SIIT, RFC 2765), never routed
'!64:ff9b:1::/48', # NAT64 local-use prefix, RFC 8215, not a public destination
'!100:0:0:1::/64', # dummy prefix, RFC 9780
'!2001:1::1', # PCP anycast, RFC 7723, answered by the local network's own edge device
'!2001:1::2', # TURN anycast, RFC 8155, likewise
'!2001:20::/28', # ORCHIDv2, RFC 7343, never routed
'!2001:30::/28', # DRIP, RFC 9374, never routed
'!5f00::/16', # SRv6 SIDs, RFC 9602, internal to one segment routing domain
'!fec0::/10', # IPv6 site-local, deprecated by RFC 3879
'!ff00::/8', # IPv6 multicast
]
web_fetch_filter_list = os.getenv('WEB_FETCH_FILTER_LIST', '')
@ -1120,7 +1173,7 @@ WEB_SEARCH_RESULT_COUNT = int(os.getenv('WEB_SEARCH_RESULT_COUNT', '3'))
try:
web_search_domain_filter_list = json.loads(os.getenv('WEB_SEARCH_DOMAIN_FILTER_LIST', '[]'))
web_search_domain_filter_list = JSONCodec.loads(os.getenv('WEB_SEARCH_DOMAIN_FILTER_LIST', '[]'))
except Exception as e:
web_search_domain_filter_list = [
# "wikipedia.com",
@ -1155,6 +1208,7 @@ WEB_SEARCH_TRUST_ENV = os.getenv('WEB_SEARCH_TRUST_ENV', 'True').lower() == 'tru
OLLAMA_CLOUD_WEB_SEARCH_API_KEY = os.getenv('OLLAMA_CLOUD_API_KEY', '')
SEARXNG_QUERY_URL = os.getenv('SEARXNG_QUERY_URL', '')
OPENSERP_BASE_URL = os.getenv('OPENSERP_BASE_URL', 'http://localhost:7000')
SEARXNG_LANGUAGE = os.getenv('SEARXNG_LANGUAGE', 'all')
@ -1268,8 +1322,8 @@ LINKUP_API_KEY = os.getenv('LINKUP_API_KEY', '')
linkup_search_params = os.getenv('LINKUP_SEARCH_PARAMS', '')
try:
linkup_search_params = json.loads(linkup_search_params)
except json.JSONDecodeError:
linkup_search_params = JSONCodec.loads(linkup_search_params)
except JSONCodec.JSONDecodeError:
linkup_search_params = {}
LINKUP_SEARCH_PARAMS = linkup_search_params
@ -1301,8 +1355,8 @@ AUTOMATIC1111_API_AUTH = os.getenv('AUTOMATIC1111_API_AUTH', '')
automatic1111_params = os.getenv('AUTOMATIC1111_PARAMS', '')
try:
automatic1111_params = json.loads(automatic1111_params)
except json.JSONDecodeError:
automatic1111_params = JSONCodec.loads(automatic1111_params)
except JSONCodec.JSONDecodeError:
automatic1111_params = {}
AUTOMATIC1111_PARAMS = automatic1111_params
@ -1426,8 +1480,8 @@ COMFYUI_WORKFLOW = os.getenv('COMFYUI_WORKFLOW', COMFYUI_DEFAULT_WORKFLOW)
comfyui_workflow_nodes = os.getenv('COMFYUI_WORKFLOW_NODES', '')
try:
comfyui_workflow_nodes = json.loads(comfyui_workflow_nodes)
except json.JSONDecodeError:
comfyui_workflow_nodes = JSONCodec.loads(comfyui_workflow_nodes)
except JSONCodec.JSONDecodeError:
comfyui_workflow_nodes = []
COMFYUI_WORKFLOW_NODES = comfyui_workflow_nodes
@ -1439,8 +1493,8 @@ IMAGES_OPENAI_API_KEY = os.getenv('IMAGES_OPENAI_API_KEY', OPENAI_API_KEY)
images_openai_params = os.getenv('IMAGES_OPENAI_PARAMS', '')
try:
images_openai_params = json.loads(images_openai_params)
except json.JSONDecodeError:
images_openai_params = JSONCodec.loads(images_openai_params)
except JSONCodec.JSONDecodeError:
images_openai_params = {}
@ -1478,8 +1532,8 @@ IMAGES_EDIT_COMFYUI_WORKFLOW = os.getenv('IMAGES_EDIT_COMFYUI_WORKFLOW', '')
images_edit_comfyui_workflow_nodes = os.getenv('IMAGES_EDIT_COMFYUI_WORKFLOW_NODES', '')
try:
images_edit_comfyui_workflow_nodes = json.loads(images_edit_comfyui_workflow_nodes)
except json.JSONDecodeError:
images_edit_comfyui_workflow_nodes = JSONCodec.loads(images_edit_comfyui_workflow_nodes)
except JSONCodec.JSONDecodeError:
images_edit_comfyui_workflow_nodes = []
IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = images_edit_comfyui_workflow_nodes
@ -1511,6 +1565,8 @@ AUDIO_STT_OPENAI_API_BASE_URL = os.getenv('AUDIO_STT_OPENAI_API_BASE_URL', OPENA
AUDIO_STT_OPENAI_API_KEY = os.getenv('AUDIO_STT_OPENAI_API_KEY', OPENAI_API_KEY)
AUDIO_STT_OPENAI_API_REQUEST_FORMAT = os.getenv('AUDIO_STT_OPENAI_API_REQUEST_FORMAT', 'multipart')
AUDIO_STT_ENGINE = os.getenv('AUDIO_STT_ENGINE', '')
AUDIO_STT_MODEL = os.getenv('AUDIO_STT_MODEL', '')
@ -1551,8 +1607,8 @@ AUDIO_TTS_OPENAI_API_KEY = os.getenv('AUDIO_TTS_OPENAI_API_KEY', OPENAI_API_KEY)
audio_tts_openai_params = os.getenv('AUDIO_TTS_OPENAI_PARAMS', '')
try:
audio_tts_openai_params = json.loads(audio_tts_openai_params)
except json.JSONDecodeError:
audio_tts_openai_params = JSONCodec.loads(audio_tts_openai_params)
except JSONCodec.JSONDecodeError:
audio_tts_openai_params = {}
AUDIO_TTS_OPENAI_PARAMS = audio_tts_openai_params
@ -1604,7 +1660,7 @@ DEFAULT_MODELS = os.getenv('DEFAULT_MODELS', None)
DEFAULT_PINNED_MODELS = os.getenv('DEFAULT_PINNED_MODELS', None)
try:
default_prompt_suggestions = json.loads(os.getenv('DEFAULT_PROMPT_SUGGESTIONS', '[]'))
default_prompt_suggestions = JSONCodec.loads(os.getenv('DEFAULT_PROMPT_SUGGESTIONS', '[]'))
except Exception as e:
log.exception(f'Error loading DEFAULT_PROMPT_SUGGESTIONS: {e}')
default_prompt_suggestions = []
@ -1641,10 +1697,16 @@ if default_prompt_suggestions == []:
DEFAULT_PROMPT_SUGGESTIONS = default_prompt_suggestions
MODEL_ORDER_LIST = []
try:
model_order_list = JSONCodec.loads(os.getenv('MODEL_ORDER_LIST', '[]'))
except Exception as e:
log.exception(f'Error loading MODEL_ORDER_LIST: {e}')
model_order_list = []
MODEL_ORDER_LIST = model_order_list
try:
default_model_metadata = json.loads(os.getenv('DEFAULT_MODEL_METADATA', '{}'))
default_model_metadata = JSONCodec.loads(os.getenv('DEFAULT_MODEL_METADATA', '{}'))
except Exception as e:
log.exception(f'Error loading DEFAULT_MODEL_METADATA: {e}')
default_model_metadata = {}
@ -1652,13 +1714,22 @@ except Exception as e:
DEFAULT_MODEL_METADATA = default_model_metadata
try:
default_model_params = json.loads(os.getenv('DEFAULT_MODEL_PARAMS', '{}'))
default_model_params = JSONCodec.loads(os.getenv('DEFAULT_MODEL_PARAMS', '{}'))
except Exception as e:
log.exception(f'Error loading DEFAULT_MODEL_PARAMS: {e}')
default_model_params = {}
DEFAULT_MODEL_PARAMS = default_model_params
try:
default_interface_settings = JSONCodec.loads(os.getenv('DEFAULT_INTERFACE_SETTINGS', '{}'))
except Exception as e:
log.exception(f'Error loading DEFAULT_INTERFACE_SETTINGS: {e}')
default_interface_settings = {}
DEFAULT_INTERFACE_SETTINGS = default_interface_settings if isinstance(default_interface_settings, dict) else {}
DEFAULT_USER_ROLE = os.getenv('DEFAULT_USER_ROLE', 'pending')
DEFAULT_GROUP_ID = os.getenv('DEFAULT_GROUP_ID', '')
@ -1783,6 +1854,9 @@ USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = (
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true'
)
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS = (
os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS', 'True').lower() == 'true'
)
USER_PERMISSIONS_CHAT_CONTROLS = os.getenv('USER_PERMISSIONS_CHAT_CONTROLS', 'True').lower() == 'true'
@ -1817,6 +1891,10 @@ USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING = (
os.getenv('USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
)
USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING = (
os.getenv('USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING', 'False').lower() == 'true'
)
USER_PERMISSIONS_CHAT_EXPORT = os.getenv('USER_PERMISSIONS_CHAT_EXPORT', 'True').lower() == 'true'
USER_PERMISSIONS_CHAT_IMPORT = os.getenv('USER_PERMISSIONS_CHAT_IMPORT', 'True').lower() == 'true'
@ -1903,10 +1981,12 @@ DEFAULT_USER_PERMISSIONS = {
'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
'folders': USER_PERMISSIONS_FOLDERS_ALLOW_SHARING,
'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING,
'open_chats': USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING,
'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING,
},
'access_grants': {
'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
'allow_groups': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS,
},
'chat': {
'controls': USER_PERMISSIONS_CHAT_CONTROLS,
@ -1960,10 +2040,20 @@ FOLDER_MAX_FILE_COUNT = os.getenv('FOLDER_MAX_FILE_COUNT', '')
ENABLE_CHANNELS = os.getenv('ENABLE_CHANNELS', 'False').lower() == 'true'
CHANNEL_MODEL_RESPONSE_MODE = os.getenv('CHANNEL_MODEL_RESPONSE_MODE', 'thread')
ENABLE_CALENDAR = os.getenv('ENABLE_CALENDAR', 'True').lower() == 'true'
ENABLE_AUTOMATIONS = os.getenv('ENABLE_AUTOMATIONS', 'True').lower() == 'true'
ENABLE_SUBAGENTS = os.getenv('ENABLE_SUBAGENTS', 'False').lower() == 'true'
SUBAGENTS_BACKGROUND_ENABLED = os.getenv('SUBAGENTS_BACKGROUND_ENABLED', 'False').lower() == 'true'
SUBAGENTS_MAX_CONCURRENT = int(os.getenv('SUBAGENTS_MAX_CONCURRENT', '20'))
SUBAGENTS_MAX_ASYNC = int(os.getenv('SUBAGENTS_MAX_ASYNC', '20'))
SUBAGENTS_MAX_ITERATIONS = int(os.getenv('SUBAGENTS_MAX_ITERATIONS', '30'))
SUBAGENTS_MAX_OUTPUT = int(os.getenv('SUBAGENTS_MAX_OUTPUT', '30000'))
SUBAGENTS_SYSTEM_PROMPT = os.getenv('SUBAGENTS_SYSTEM_PROMPT', '')
AUTOMATION_MAX_COUNT = os.getenv('AUTOMATION_MAX_COUNT', '')
AUTOMATION_MIN_INTERVAL = os.getenv('AUTOMATION_MIN_INTERVAL', '')
@ -1976,7 +2066,7 @@ ENABLE_USER_STATUS = os.getenv('ENABLE_USER_STATUS', 'True').lower() == 'true'
ENABLE_EVALUATION_ARENA_MODELS = os.getenv('ENABLE_EVALUATION_ARENA_MODELS', 'True').lower() == 'true'
try:
evaluation_arena_models = json.loads(os.getenv('EVALUATION_ARENA_MODELS', '[]'))
evaluation_arena_models = JSONCodec.loads(os.getenv('EVALUATION_ARENA_MODELS', '[]'))
if not isinstance(evaluation_arena_models, list) or not all(
isinstance(model, dict) for model in evaluation_arena_models
):
@ -1991,6 +2081,9 @@ DEFAULT_ARENA_MODEL = {
'id': 'arena-model',
'name': 'Arena Model',
'meta': {
# LICENSE covers this Open WebUI fallback logo.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
'profile_image_url': '/favicon.png',
'description': 'Submit your questions to anonymous AI chatbots and vote on the best response.',
'model_ids': None,
@ -2011,8 +2104,6 @@ BYPASS_ADMIN_ACCESS_CONTROL = (
== 'true'
)
ENABLE_ADMIN_CHAT_ACCESS = os.getenv('ENABLE_ADMIN_CHAT_ACCESS', 'True').lower() == 'true'
ENABLE_ADMIN_ANALYTICS = os.getenv('ENABLE_ADMIN_ANALYTICS', 'True').lower() == 'true'
ENABLE_COMMUNITY_SHARING = os.getenv('ENABLE_COMMUNITY_SHARING', 'True').lower() == 'true'
@ -2023,6 +2114,7 @@ ENABLE_USER_WEBHOOKS = os.getenv('ENABLE_USER_WEBHOOKS', 'False').lower() == 'tr
# FastAPI / AnyIO settings
THREAD_POOL_SIZE = os.getenv('THREAD_POOL_SIZE', None)
THREAD_POOL_THREAD_NAME_PREFIX = os.getenv('THREAD_POOL_THREAD_NAME_PREFIX', '')
if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str):
try:
@ -2078,7 +2170,7 @@ class BannerModel(BaseModel):
try:
banners = json.loads(os.getenv('WEBUI_BANNERS', '[]'))
banners = JSONCodec.loads(os.getenv('WEBUI_BANNERS', '[]'))
banners = [BannerModel(**banner) for banner in banners]
except Exception as e:
log.exception(f'Error loading WEBUI_BANNERS: {e}')
@ -2101,33 +2193,51 @@ TASK_MODEL = os.getenv('TASK_MODEL', '')
TASK_MODEL_EXTERNAL = os.getenv('TASK_MODEL_EXTERNAL', '')
try:
task_model_params = JSONCodec.loads(os.getenv('TASK_MODEL_PARAMS', '{}'))
except Exception as e:
log.exception(f'Error loading TASK_MODEL_PARAMS: {e}')
task_model_params = {}
TASK_MODEL_PARAMS = task_model_params
CONTEXT_COMPACTION_MODEL = os.getenv('CONTEXT_COMPACTION_MODEL', '')
ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lower() == 'true'
ENABLE_TOOL_PERMISSIONS = os.getenv('ENABLE_TOOL_PERMISSIONS', 'False').lower() == 'true'
CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000'))
_CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP')
CONTEXT_COMPACTION_TOKEN_CAP = int(_CONTEXT_COMPACTION_TOKEN_CAP) if _CONTEXT_COMPACTION_TOKEN_CAP else None
CONTEXT_COMPACTION_RETENTION_PERCENTAGE = min(
50, max(10, int(os.getenv('CONTEXT_COMPACTION_RETENTION_PERCENTAGE', '40')))
)
CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '')
TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '')
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task:
Generate a concise, 3-5 word title with an emoji summarizing the chat history.
Generate a concise title summarizing the chat history.
### Guidelines:
- The title should clearly represent the main theme or subject of the conversation.
- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting.
- Keep it short: 2-4 words is best.
- Do not use emojis, quotation marks, or special formatting.
- Write the title in the chat's primary language; default to English if multilingual.
- Prioritize accuracy over excessive creativity; keep it clear and simple.
- Prioritize accuracy over creativity.
- Your entire response must consist solely of the JSON object, without any introductory or concluding text.
- The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text.
- Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure.
### Output:
JSON format: { "title": "your concise title here" }
### Examples:
- { "title": "📉 Stock Market Trends" },
- { "title": "🍪 Perfect Chocolate Chip Recipe" },
- { "title": "Evolution of Music Streaming" },
- { "title": "Remote Work Productivity Tips" },
- { "title": "Artificial Intelligence in Healthcare" },
- { "title": "🎮 Video Game Development Insights" }
- { "title": "Stock Trends" },
- { "title": "Chocolate Chip Cookies" },
- { "title": "Music Streaming" },
- { "title": "Remote Work" }
### Chat History:
<chat_history>
{{MESSAGES:END:2}}
@ -2377,6 +2487,11 @@ if JWT_EXPIRES_IN == '-1':
# OAuth config
####################################
# Master switch for OAuth/OIDC sign-in. Defaults to enabled so existing
# deployments that already have a provider configured keep working; admins can
# turn it off to disable OAuth login without clearing their provider settings.
ENABLE_OAUTH = os.getenv('ENABLE_OAUTH', 'True').lower() == 'true'
ENABLE_OAUTH_SIGNUP = os.getenv('ENABLE_OAUTH_SIGNUP', 'False').lower() == 'true'
OAUTH_AUTO_REDIRECT = os.getenv('OAUTH_AUTO_REDIRECT', 'False').lower() == 'true'
@ -2401,12 +2516,12 @@ GOOGLE_OAUTH_AUTHORIZE_PARAMS = {}
_google_oauth_authorize_params = os.getenv('GOOGLE_OAUTH_AUTHORIZE_PARAMS', '')
if _google_oauth_authorize_params:
try:
_parsed = json.loads(_google_oauth_authorize_params)
_parsed = JSONCodec.loads(_google_oauth_authorize_params)
if isinstance(_parsed, dict):
GOOGLE_OAUTH_AUTHORIZE_PARAMS = _parsed
else:
log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring')
except (json.JSONDecodeError, TypeError):
except (JSONCodec.JSONDecodeError, TypeError):
log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
MICROSOFT_CLIENT_ID = os.getenv('MICROSOFT_CLIENT_ID', '')
@ -2521,15 +2636,32 @@ OAUTH_AUTHORIZE_PARAMS = {}
_oauth_authorize_params = os.getenv('OAUTH_AUTHORIZE_PARAMS', '')
if _oauth_authorize_params:
try:
_parsed = json.loads(_oauth_authorize_params)
_parsed = JSONCodec.loads(_oauth_authorize_params)
if isinstance(_parsed, dict):
OAUTH_AUTHORIZE_PARAMS = _parsed
else:
log.warning('OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring')
except (json.JSONDecodeError, TypeError):
except (JSONCodec.JSONDecodeError, TypeError):
log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
def oauth_client_kwargs(scope: str, **kwargs):
client_kwargs = {
'scope': scope,
**kwargs,
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
}
if OAUTH_CODE_CHALLENGE_METHOD == 'S256':
client_kwargs['code_challenge_method'] = 'S256'
elif OAUTH_CODE_CHALLENGE_METHOD:
raise Exception(
'Code challenge methods other than "%s" not supported. Given: "%s"' % ('S256', OAUTH_CODE_CHALLENGE_METHOD)
)
return client_kwargs
def load_oauth_providers():
OAUTH_PROVIDERS.clear()
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET:
@ -2540,10 +2672,7 @@ def load_oauth_providers():
client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET,
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={
'scope': GOOGLE_OAUTH_SCOPE,
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
},
client_kwargs=oauth_client_kwargs(GOOGLE_OAUTH_SCOPE),
redirect_uri=GOOGLE_REDIRECT_URI,
**({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}),
)
@ -2561,10 +2690,7 @@ def load_oauth_providers():
client_id=MICROSOFT_CLIENT_ID,
client_secret=MICROSOFT_CLIENT_SECRET,
server_metadata_url=f'{MICROSOFT_CLIENT_LOGIN_BASE_URL}/{MICROSOFT_CLIENT_TENANT_ID}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID}',
client_kwargs={
'scope': MICROSOFT_OAUTH_SCOPE,
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
},
client_kwargs=oauth_client_kwargs(MICROSOFT_OAUTH_SCOPE),
redirect_uri=MICROSOFT_REDIRECT_URI,
)
return client
@ -2585,10 +2711,7 @@ def load_oauth_providers():
authorize_url='https://github.com/login/oauth/authorize',
api_base_url='https://api.github.com',
userinfo_endpoint='https://api.github.com/user',
client_kwargs={
'scope': GITHUB_CLIENT_SCOPE,
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
},
client_kwargs=oauth_client_kwargs(GITHUB_CLIENT_SCOPE),
redirect_uri=GITHUB_CLIENT_REDIRECT_URI,
)
return client
@ -2601,30 +2724,19 @@ def load_oauth_providers():
if OAUTH_CLIENT_ID and (OAUTH_CLIENT_SECRET or OAUTH_CODE_CHALLENGE_METHOD) and OPENID_PROVIDER_URL:
def oidc_oauth_register(oauth: OAuth):
client_kwargs = {
'scope': OAUTH_SCOPES,
**(
{'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD}
if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD
else {}
),
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
}
if OAUTH_CODE_CHALLENGE_METHOD and OAUTH_CODE_CHALLENGE_METHOD == 'S256':
client_kwargs['code_challenge_method'] = 'S256'
elif OAUTH_CODE_CHALLENGE_METHOD:
raise Exception(
'Code challenge methods other than "%s" not supported. Given: "%s"'
% ('S256', OAUTH_CODE_CHALLENGE_METHOD)
)
client = oauth.register(
name='oidc',
client_id=OAUTH_CLIENT_ID,
client_secret=OAUTH_CLIENT_SECRET,
server_metadata_url=OPENID_PROVIDER_URL,
client_kwargs=client_kwargs,
client_kwargs=oauth_client_kwargs(
OAUTH_SCOPES,
**(
{'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD}
if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD
else {}
),
),
redirect_uri=OPENID_REDIRECT_URI,
)
return client
@ -2741,6 +2853,7 @@ DEFAULT_CONFIG = {
'code_execution.jupyter.timeout': CODE_EXECUTION_JUPYTER_TIMEOUT,
'code_interpreter.enable': ENABLE_CODE_INTERPRETER,
'memories.enable': ENABLE_MEMORIES,
'memories.system_context.enable': ENABLE_MEMORY_SYSTEM_CONTEXT,
'memories.background_review.enable': ENABLE_MEMORY_BACKGROUND_REVIEW,
'memories.review_interval_turns': MEMORIES_REVIEW_INTERVAL_TURNS,
'memories.user_char_limit': MEMORIES_USER_CHAR_LIMIT,
@ -2759,6 +2872,7 @@ DEFAULT_CONFIG = {
'onedrive.sharepoint_url': ONEDRIVE_SHAREPOINT_URL,
'onedrive.sharepoint_tenant_id': ONEDRIVE_SHAREPOINT_TENANT_ID,
'rag.content_extraction_engine': CONTENT_EXTRACTION_ENGINE,
'rag.content_extraction.supported_media_mime_types': CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES,
'rag.datalab_marker_api_key': DATALAB_MARKER_API_KEY,
'rag.datalab_marker_api_base_url': DATALAB_MARKER_API_BASE_URL,
'rag.datalab_marker_additional_config': DATALAB_MARKER_ADDITIONAL_CONFIG,
@ -2780,6 +2894,7 @@ DEFAULT_CONFIG = {
'rag.external_document_loader_api_key': EXTERNAL_DOCUMENT_LOADER_API_KEY,
'rag.external_document_loader_headers': EXTERNAL_DOCUMENT_LOADER_HEADERS,
'rag.tika_server_url': TIKA_SERVER_URL,
'rag.tika_server_version': TIKA_SERVER_VERSION,
'rag.docling_server_url': DOCLING_SERVER_URL,
'rag.docling_api_key': DOCLING_API_KEY,
'rag.docling_params': DOCLING_PARAMS,
@ -2851,6 +2966,7 @@ DEFAULT_CONFIG = {
'web.search.trust_env': WEB_SEARCH_TRUST_ENV,
'web.search.ollama_cloud_api_key': OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
'web.search.searxng_query_url': SEARXNG_QUERY_URL,
'web.search.openserp_base_url': OPENSERP_BASE_URL,
'web.search.searxng_language': SEARXNG_LANGUAGE,
'web.search.yacy_query_url': YACY_QUERY_URL,
'web.search.yacy_username': YACY_USERNAME,
@ -2944,6 +3060,7 @@ DEFAULT_CONFIG = {
'audio.stt.deepgram.api_key': DEEPGRAM_API_KEY,
'audio.stt.openai.api_base_url': AUDIO_STT_OPENAI_API_BASE_URL,
'audio.stt.openai.api_key': AUDIO_STT_OPENAI_API_KEY,
'audio.stt.openai.api_request_format': AUDIO_STT_OPENAI_API_REQUEST_FORMAT,
'audio.stt.engine': AUDIO_STT_ENGINE,
'audio.stt.model': AUDIO_STT_MODEL,
'audio.stt.supported_content_types': AUDIO_STT_SUPPORTED_CONTENT_TYPES,
@ -2976,6 +3093,7 @@ DEFAULT_CONFIG = {
'ui.default_locale': DEFAULT_LOCALE,
'ui.default_models': DEFAULT_MODELS,
'ui.default_pinned_models': DEFAULT_PINNED_MODELS,
'ui.default_interface_settings': DEFAULT_INTERFACE_SETTINGS,
'ui.prompt_suggestions': DEFAULT_PROMPT_SUGGESTIONS,
'ui.model_order_list': MODEL_ORDER_LIST,
'models.default_metadata': DEFAULT_MODEL_METADATA,
@ -2989,8 +3107,16 @@ DEFAULT_CONFIG = {
'folders.enable': ENABLE_FOLDERS,
'folders.max_file_count': FOLDER_MAX_FILE_COUNT,
'channels.enable': ENABLE_CHANNELS,
'channels.model_response_mode': CHANNEL_MODEL_RESPONSE_MODE,
'calendar.enable': ENABLE_CALENDAR,
'automations.enable': ENABLE_AUTOMATIONS,
'subagents.enable': ENABLE_SUBAGENTS,
'subagents.background_enabled': SUBAGENTS_BACKGROUND_ENABLED,
'subagents.max_concurrent': SUBAGENTS_MAX_CONCURRENT,
'subagents.max_async': SUBAGENTS_MAX_ASYNC,
'subagents.max_iterations': SUBAGENTS_MAX_ITERATIONS,
'subagents.max_output': SUBAGENTS_MAX_OUTPUT,
'subagents.system_prompt': SUBAGENTS_SYSTEM_PROMPT,
'automations.max_count': AUTOMATION_MAX_COUNT,
'automations.min_interval': AUTOMATION_MIN_INTERVAL,
'automations.auth_token_expires_in': AUTOMATION_AUTH_TOKEN_EXPIRES_IN,
@ -3007,9 +3133,14 @@ DEFAULT_CONFIG = {
'auth.admin.email': ADMIN_EMAIL,
'task.model.default': TASK_MODEL,
'task.model.external': TASK_MODEL_EXTERNAL,
'task.model.params': TASK_MODEL_PARAMS,
'chat.context_compaction.model': CONTEXT_COMPACTION_MODEL,
'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION,
'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD,
'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP,
'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE,
'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE,
'chat.tool_permissions.enable': ENABLE_TOOL_PERMISSIONS,
'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE,
'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE,
'task.image.prompt_template': IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
@ -3030,6 +3161,7 @@ DEFAULT_CONFIG = {
'auth.api_key.endpoint_restrictions': ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
'auth.api_key.allowed_endpoints': API_KEYS_ALLOWED_ENDPOINTS,
'auth.jwt_expiry': JWT_EXPIRES_IN,
'oauth.enable': ENABLE_OAUTH,
'oauth.enable_signup': ENABLE_OAUTH_SIGNUP,
'oauth.auto_redirect': OAUTH_AUTO_REDIRECT,
'oauth.refresh_token.include_scope': OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE,

View file

@ -99,7 +99,7 @@ class ERROR_MESSAGES(str, Enum):
INVALID_URL = 'The URL you provided is invalid. Please double-check and try again.'
WEB_SEARCH_ERROR = lambda err='': err if err else 'Something went wrong while searching the web.'
WEB_SEARCH_ERROR = 'Something went wrong while searching the web.'
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
@ -118,9 +118,15 @@ class ERROR_MESSAGES(str, Enum):
AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.'
AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}'
AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences'
AUTOMATION_COUNT_REQUIRES_DTSTART = (
'RRULE with COUNT requires an explicit DTSTART line to anchor the occurrence window'
)
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
# LICENSE covers this Open WebUI error identifier.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
SERVER_CONNECTION_ERROR = 'Open WebUI: Server Connection Error'
REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty'
OAUTH_NOT_CONFIGURED = lambda name='': f"Provider '{name}' is not configured"

View file

@ -102,6 +102,7 @@ class JSONFormatter(logging.Formatter):
LOG_FORMAT = os.getenv('LOG_FORMAT', '').lower()
LOGURU_DIAGNOSE = os.getenv('LOGURU_DIAGNOSE', 'False').lower() == 'true'
GLOBAL_LOG_LEVEL = os.getenv('GLOBAL_LOG_LEVEL', '').upper()
if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping():
@ -149,6 +150,11 @@ INSTANCE_ID = os.getenv('INSTANCE_ID', str(uuid4()))
ENABLE_DB_MIGRATIONS = os.getenv('ENABLE_DB_MIGRATIONS', 'True').lower() == 'true'
# Swap the JSON encoder/decoder used across the app (HTTP request bodies, JSONResponse
# bodies, upstream provider responses, socket.io payloads) from the stdlib `json` module
# to orjson. Faster, but stricter: see open_webui/utils/json_codec.py for the differences.
ENABLE_ORJSON = os.getenv('ENABLE_ORJSON', 'False').lower() == 'true'
# Function to parse each section
def parse_section(section):
@ -221,7 +227,7 @@ if FROM_INIT_PY:
# Check if the data directory exists in the package directory
if DATA_DIR.exists() and DATA_DIR != NEW_DATA_DIR:
log.info(f'Moving {DATA_DIR} to {NEW_DATA_DIR}')
log.info('Moving %s to %s', DATA_DIR, NEW_DATA_DIR)
for item in DATA_DIR.iterdir():
dest = NEW_DATA_DIR / item.name
if item.is_dir():
@ -347,18 +353,18 @@ DATABASE_SQLITE_PRAGMA_MMAP_SIZE = os.getenv('DATABASE_SQLITE_PRAGMA_MMAP_SIZE',
# truncated. 67108864 ≈ 64 MB. Set to -1 for no limit (SQLite default).
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT = os.getenv('DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT', '67108864')
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.getenv('DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL', None)
if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
try:
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
except Exception:
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0
# Seconds between presence writes per user per worker; keep under the 180s active-user window. 0 disables.
try:
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float(os.getenv('DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL', '60'))
except ValueError:
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 60.0
DATABASE_ENABLE_SESSION_SHARING = os.getenv('DATABASE_ENABLE_SESSION_SHARING', 'False').lower() == 'true'
ENABLE_PUBLIC_ACTIVE_USERS_COUNT = os.getenv('ENABLE_PUBLIC_ACTIVE_USERS_COUNT', 'True').lower() == 'true'
RESET_CONFIG_ON_START = os.getenv('RESET_CONFIG_ON_START', 'False').lower() == 'true'
ENABLE_REALTIME_CHAT_SAVE = os.getenv('ENABLE_REALTIME_CHAT_SAVE', 'False').lower() == 'true'
ENABLE_QUERIES_CACHE = os.getenv('ENABLE_QUERIES_CACHE', 'False').lower() == 'true'
ENABLE_ADMIN_CHAT_ACCESS = os.getenv('ENABLE_ADMIN_CHAT_ACCESS', 'True').lower() == 'true'
RAG_SYSTEM_CONTEXT = os.getenv('RAG_SYSTEM_CONTEXT', 'False').lower() == 'true'
####################################
@ -370,6 +376,11 @@ REDIS_CLUSTER = os.getenv('REDIS_CLUSTER', 'False').lower() == 'true'
REDIS_KEY_PREFIX = os.getenv('REDIS_KEY_PREFIX', 'open-webui')
try:
REDIS_RESPONSE_STREAM_TTL = int(os.getenv('REDIS_RESPONSE_STREAM_TTL', '3600'))
except ValueError:
REDIS_RESPONSE_STREAM_TTL = 3600
REDIS_SENTINEL_HOSTS = os.getenv('REDIS_SENTINEL_HOSTS', '')
REDIS_SENTINEL_PORT = os.getenv('REDIS_SENTINEL_PORT', '26379')
@ -389,6 +400,12 @@ try:
except ValueError:
REDIS_SOCKET_CONNECT_TIMEOUT = None
REDIS_SOCKET_TIMEOUT = os.getenv('REDIS_SOCKET_TIMEOUT', '')
try:
REDIS_SOCKET_TIMEOUT = float(REDIS_SOCKET_TIMEOUT)
except ValueError:
REDIS_SOCKET_TIMEOUT = None
# Whether to enable TCP SO_KEEPALIVE on Redis client sockets. Opt-in:
# defaults to off so behavior is unchanged for existing deployments. When
# enabled, the kernel sends TCP keepalive probes on idle connections so
@ -431,6 +448,9 @@ try:
except (ValueError, TypeError):
UVICORN_WORKERS = 1
# tiny delta-stream frames make per-frame websocket compression CPU-bound, allow opting out (true/false)
UVICORN_WS_PER_MESSAGE_DEFLATE = os.getenv('UVICORN_WS_PER_MESSAGE_DEFLATE', 'True').lower() == 'true'
####################################
# WEBSOCKET SUPPORT
####################################
@ -444,17 +464,16 @@ WEBSOCKET_REDIS_OPTIONS = os.getenv('WEBSOCKET_REDIS_OPTIONS', '')
if WEBSOCKET_REDIS_OPTIONS == '':
WEBSOCKET_REDIS_OPTIONS = {'socket_timeout': None}
if REDIS_SOCKET_CONNECT_TIMEOUT:
WEBSOCKET_REDIS_OPTIONS = {'socket_connect_timeout': REDIS_SOCKET_CONNECT_TIMEOUT}
else:
log.debug('No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None')
WEBSOCKET_REDIS_OPTIONS = None
WEBSOCKET_REDIS_OPTIONS['socket_connect_timeout'] = REDIS_SOCKET_CONNECT_TIMEOUT
else:
try:
WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS)
WEBSOCKET_REDIS_OPTIONS.setdefault('socket_timeout', None)
except Exception:
log.warning('Invalid WEBSOCKET_REDIS_OPTIONS, defaulting to None')
WEBSOCKET_REDIS_OPTIONS = None
log.warning('Invalid WEBSOCKET_REDIS_OPTIONS, defaulting to socket_timeout=None')
WEBSOCKET_REDIS_OPTIONS = {'socket_timeout': None}
WEBSOCKET_REDIS_URL = os.getenv('WEBSOCKET_REDIS_URL', REDIS_URL)
WEBSOCKET_REDIS_CLUSTER = os.getenv('WEBSOCKET_REDIS_CLUSTER', str(REDIS_CLUSTER)).lower() == 'true'
@ -488,6 +507,15 @@ try:
except ValueError:
WEBSOCKET_SERVER_PING_INTERVAL = 25
WEBSOCKET_HEARTBEAT_INTERVAL = os.getenv('WEBSOCKET_HEARTBEAT_INTERVAL', '')
if WEBSOCKET_HEARTBEAT_INTERVAL == '':
WEBSOCKET_HEARTBEAT_INTERVAL = None
else:
try:
WEBSOCKET_HEARTBEAT_INTERVAL = min(max(int(WEBSOCKET_HEARTBEAT_INTERVAL), 5), 90)
except ValueError:
WEBSOCKET_HEARTBEAT_INTERVAL = 30
WEBSOCKET_EVENT_CALLER_TIMEOUT = os.getenv('WEBSOCKET_EVENT_CALLER_TIMEOUT', '')
if WEBSOCKET_EVENT_CALLER_TIMEOUT == '':
@ -560,21 +588,51 @@ def _parse_ssl_env(value: str) -> 'bool | _ssl.SSLContext':
REQUESTS_VERIFY = os.getenv('REQUESTS_VERIFY', 'True').lower() == 'true'
TAVILY_API_BASE_URL = os.getenv('TAVILY_API_BASE_URL', 'https://api.tavily.com').rstrip('/')
_aiohttp_timeout_raw = os.getenv('AIOHTTP_CLIENT_TIMEOUT', '')
try:
AIOHTTP_CLIENT_TIMEOUT = int(_aiohttp_timeout_raw) if _aiohttp_timeout_raw else None
except (ValueError, TypeError):
AIOHTTP_CLIENT_TIMEOUT = 300
# Optional between-chunks idle cap for streaming aiohttp requests.
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = os.getenv('AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT', '')
if AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT == '':
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
else:
try:
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = int(AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT)
except (ValueError, TypeError):
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
if AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT is not None and AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT <= 0:
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
# SSL verification for general outbound requests (OpenAI, OAuth, etc.).
# Accepts "True", "False", or a path to a CA bundle file.
# When "True", falls back to AIOHTTP_CLIENT_SSL_CERT_FILE if set.
AIOHTTP_CLIENT_SESSION_SSL = _parse_ssl_env(os.getenv('AIOHTTP_CLIENT_SESSION_SSL', 'True'))
SEARXNG_CLIENT_CERT_FILE = os.getenv('SEARXNG_CLIENT_CERT_FILE', '').strip()
SEARXNG_CLIENT_KEY_FILE = os.getenv('SEARXNG_CLIENT_KEY_FILE', '').strip()
# When False (default), outbound HTTP requests do not follow 3xx redirects.
AIOHTTP_CLIENT_ALLOW_REDIRECTS = os.getenv('AIOHTTP_CLIENT_ALLOW_REDIRECTS', 'False').lower() == 'true'
# Opt-in c-ares DNS resolution (aiodns). Off by default: c-ares breaks name
# resolution in some environments (#28013, #28215). Must run before any
# TCPConnector is constructed.
AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER = os.getenv('AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER', 'False').lower() == 'true'
if not AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER:
import aiohttp
aiohttp.DefaultResolver = aiohttp.resolver.ThreadedResolver # for plugin code
aiohttp.resolver.DefaultResolver = aiohttp.resolver.ThreadedResolver
aiohttp.connector.DefaultResolver = aiohttp.resolver.ThreadedResolver
# Optional User-Agent override for outbound web-loader fetches. When set,
# SafeWebBaseLoader sends this value instead of the default python-requests UA
# which is aggressively blocked by Cloudflare, Wikipedia, and similar services.
@ -595,6 +653,15 @@ try:
except (ValueError, TypeError):
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10
AIOHTTP_FILE_STREAM_CHUNK_SIZE = os.getenv('AIOHTTP_FILE_STREAM_CHUNK_SIZE', str(1024 * 1024))
try:
AIOHTTP_FILE_STREAM_CHUNK_SIZE = int(AIOHTTP_FILE_STREAM_CHUNK_SIZE)
except Exception:
AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024
if AIOHTTP_FILE_STREAM_CHUNK_SIZE <= 0:
AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024
# SSL verification for tool server connections specifically.
# Accepts "True", "False", or a path to a CA bundle file.
@ -755,6 +822,16 @@ BYPASS_RETRIEVAL_ACCESS_CONTROL = os.getenv('BYPASS_RETRIEVAL_ACCESS_CONTROL', '
# for non-admin users. When False (default), unknown collection names are
# denied — closing the legacy unscoped namespace.
ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS = os.getenv('ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS', 'False').lower() == 'true'
# Falls back to the upload size limit, because a document cannot legitimately carry more metadata
# than the file itself is allowed to be. Left unbounded, a small archive that expands enormously
# during extraction can exhaust memory. RAG_FILE_MAX_SIZE is in MB.
RAG_METADATA_MAX_VALUE_CHARS = (
int(os.getenv('RAG_METADATA_MAX_VALUE_CHARS'))
if os.getenv('RAG_METADATA_MAX_VALUE_CHARS')
else ((int(os.getenv('RAG_FILE_MAX_SIZE', '0')) or 0) * 1024 * 1024 or None)
)
MINERU_MAX_MARKDOWN_BYTES = (
int(os.getenv('MINERU_MAX_MARKDOWN_BYTES')) if os.getenv('MINERU_MAX_MARKDOWN_BYTES') else None
)
@ -789,6 +866,18 @@ OAUTH_MAX_SESSIONS_PER_USER = int(os.getenv('OAUTH_MAX_SESSIONS_PER_USER', '10')
# Token Exchange Configuration
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
ENABLE_OAUTH_TOKEN_EXCHANGE = os.getenv('ENABLE_OAUTH_TOKEN_EXCHANGE', 'False').lower() == 'true'
_oauth_token_exchange_rate_limit = (os.getenv('OAUTH_TOKEN_EXCHANGE_RATE_LIMIT') or '').strip()
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT = (
int(_oauth_token_exchange_rate_limit)
if _oauth_token_exchange_rate_limit and _oauth_token_exchange_rate_limit.lower() != 'none'
else None
)
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW = int(os.getenv('OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW', str(60 * 3)))
OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS = [
client_id.strip()
for client_id in os.getenv('OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS', '').split(',')
if client_id.strip()
]
# Back-Channel Logout Configuration
# When enabled, exposes POST /oauth/backchannel-logout for IdP-initiated logout
@ -840,10 +929,18 @@ if LICENSE_PUBLIC_KEY:
# WEBUI Identity
####################################
# LICENSE covers this Open WebUI branding surface, including name, logo,
# visual, textual, symbolic identifiers, metadata, and surrounding UI.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
WEBUI_NAME = os.getenv('WEBUI_NAME', 'Open WebUI')
if WEBUI_NAME != 'Open WebUI':
WEBUI_NAME += ' (Open WebUI)'
# LICENSE covers this Open WebUI branding surface, including this favicon
# and any visual, textual, or symbolic identifiers it preserves.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
WEBUI_FAVICON_URL = 'https://openwebui.com/favicon.png'
WEBUI_BUILD_HASH = os.getenv('WEBUI_BUILD_HASH', 'dev-build')
TRUSTED_SIGNATURE_KEY = os.getenv('TRUSTED_SIGNATURE_KEY', '')
@ -900,6 +997,10 @@ except ValueError:
# Progressive Web App
####################################
# LICENSE covers this install-time Open WebUI branding surface, including
# names, logos, manifests, metadata, and surrounding UI.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
EXTERNAL_PWA_MANIFEST_URL = os.getenv('EXTERNAL_PWA_MANIFEST_URL', None)
####################################
@ -1035,10 +1136,34 @@ SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = (
os.getenv('SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION', 'True').lower() == 'true'
)
####################################
# KNOWLEDGE TOOLS
####################################
def _int_env(name: str, default: int) -> int:
try:
return max(int(os.getenv(name) or default), 1)
except (ValueError, TypeError):
return default
# Total output of a single kb_exec call, whatever the command.
KB_EXEC_MAX_OUTPUT_CHARS = _int_env('KB_EXEC_MAX_OUTPUT_CHARS', 30_000)
# Files a single kb_exec grep may scan before it asks for a narrower scope.
KB_EXEC_MAX_GREP_FILES = _int_env('KB_EXEC_MAX_GREP_FILES', 200)
# Matching lines returned by kb_exec grep and grep_knowledge_files.
KNOWLEDGE_GREP_MAX_MATCHES = _int_env('KNOWLEDGE_GREP_MAX_MATCHES', 50)
# Characters returned by view_file / view_knowledge_file.
VIEW_FILE_MAX_CHARS = _int_env('VIEW_FILE_MAX_CHARS', 100_000)
VIEW_FILE_DEFAULT_MAX_CHARS = _int_env('VIEW_FILE_DEFAULT_MAX_CHARS', 10_000)
####################################
# TOOLS/FUNCTIONS PIP OPTIONS
####################################
ENABLE_PLUGINS = os.getenv('ENABLE_PLUGINS', 'True').lower() == 'true'
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = (
os.getenv('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS', 'True').lower() == 'true'
)
@ -1092,15 +1217,19 @@ except ValueError:
MAX_BODY_LOG_SIZE = 2048
# Comma separated list for urls to exclude from audit
AUDIT_EXCLUDED_PATHS = os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',')
AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
AUDIT_EXCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_EXCLUDED_PATHS]
AUDIT_EXCLUDED_PATHS = [
path
for path in (
path.strip().lstrip('/') for path in os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',')
)
if path
]
# Comma separated list of urls to include in audit (whitelist mode)
# When set, only these paths are audited and AUDIT_EXCLUDED_PATHS is ignored
AUDIT_INCLUDED_PATHS = os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')
AUDIT_INCLUDED_PATHS = [path.strip() for path in AUDIT_INCLUDED_PATHS]
AUDIT_INCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_INCLUDED_PATHS if path]
AUDIT_INCLUDED_PATHS = [
path for path in (path.strip().lstrip('/') for path in os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')) if path
]
# When enabled, GET requests are also audited (disabled by default to avoid log noise)
ENABLE_AUDIT_GET_REQUESTS = os.getenv('ENABLE_AUDIT_GET_REQUESTS', 'False').lower() == 'true'

View file

@ -8,7 +8,7 @@ import uuid
from types import SimpleNamespace
from typing import Any
from open_webui.env import VERSION
from open_webui.env import ENABLE_PLUGINS, VERSION
from open_webui.models.config import Config
from pydantic import BaseModel, ConfigDict, Field, model_validator
from open_webui.retrieval.web.utils import validate_url
@ -161,6 +161,12 @@ class EventDefinitions(BaseModel):
CHAT_CREATED: EventDefinition = EventDefinition(
name='chat.created', description='A chat was created.', message='Chat created'
)
CHAT_FINISHED: EventDefinition = EventDefinition(
name='chat.finished', description='A chat response finished.', message='Chat finished'
)
CHAT_FAILED: EventDefinition = EventDefinition(
name='chat.failed', description='A chat response failed.', message='Chat failed'
)
CHAT_IMPORTED: EventDefinition = EventDefinition(
name='chat.imported', description='A chat was imported.', message='Chat imported'
)
@ -258,6 +264,11 @@ class EventDefinitions(BaseModel):
description='A channel member active state was updated.',
message='Channel member active updated',
)
CHANNEL_MESSAGE: EventDefinition = EventDefinition(
name='channel.message',
description='A channel message was posted.',
message='Channel message',
)
CHANNEL_WEBHOOK_CREATED: EventDefinition = EventDefinition(
name='channel.webhook.created',
description='A channel incoming webhook was created.',
@ -400,6 +411,11 @@ class EventDefinitions(BaseModel):
description='Retrieval content was processed.',
message='Retrieval Content processed',
)
RETRIEVAL_CONTENT_PROCESS_FAILED: EventDefinition = EventDefinition(
name='retrieval.content.process_failed',
description='Retrieval content processing failed.',
message='Retrieval Content process failed',
)
RETRIEVAL_COLLECTION_DELETED: EventDefinition = EventDefinition(
name='retrieval.collection.deleted',
description='A retrieval collection was deleted.',
@ -442,6 +458,11 @@ class EventDefinitions(BaseModel):
description='Model provider configuration was updated.',
message='Model Provider Config updated',
)
MODEL_PROVIDER_REQUEST_FAILED: EventDefinition = EventDefinition(
name='model.provider_request.failed',
description='A model provider request failed.',
message='Model provider request failed',
)
MODEL_PROVIDER_MODEL_CREATED: EventDefinition = EventDefinition(
name='model.provider_model.created',
description='A provider model was created.',
@ -467,6 +488,16 @@ class EventDefinitions(BaseModel):
FUNCTION_DISABLED: EventDefinition = EventDefinition(
name='function.disabled', description='A function was disabled.', message='Function disabled'
)
FUNCTION_ENABLE_STARTED: EventDefinition = EventDefinition(
name='function.enable_started',
description='A function is about to be enabled.',
message='Function enable started',
)
FUNCTION_DISABLE_STARTED: EventDefinition = EventDefinition(
name='function.disable_started',
description='A function is about to be disabled.',
message='Function disable started',
)
FUNCTION_VALVES_UPDATED: EventDefinition = EventDefinition(
name='function.valves_updated', description='Function valves were updated.', message='Function valves updated'
)
@ -561,6 +592,11 @@ class EventDefinitions(BaseModel):
description='A calendar event RSVP was updated.',
message='Calendar Event rsvp updated',
)
CALENDAR_ALERT: EventDefinition = EventDefinition(
name='calendar.alert',
description='A calendar event alert was triggered.',
message='Calendar alert',
)
AUTOMATION_CREATED: EventDefinition = EventDefinition(
name='automation.created', description='An automation was created.', message='Automation created'
)
@ -617,6 +653,12 @@ class EventDefinitions(BaseModel):
TERMINAL_SESSION_CLOSED: EventDefinition = EventDefinition(
name='terminal.session.closed', description='A terminal session was closed.', message='Terminal Session closed'
)
NOTIFICATION_TEST: EventDefinition = EventDefinition(
name='notification.test', description='A notification target test was sent.', message='Notification test'
)
NOTIFICATION_MANUAL: EventDefinition = EventDefinition(
name='notification.manual', description='A manual notification was sent.', message='Notification sent'
)
EVENTS = EventDefinitions()
@ -624,6 +666,13 @@ EVENT_DEFINITIONS = tuple(getattr(EVENTS, field_name) for field_name in EventDef
EVENT_DEFINITIONS_BY_NAME = {definition.name: definition for definition in EVENT_DEFINITIONS}
EVENT_CATALOG = tuple(definition.name for definition in EVENT_DEFINITIONS)
EVENT_CATALOG_SET = set(EVENT_CATALOG)
NOTIFICATION_EVENTS = (
EVENTS.CHAT_FINISHED.name,
EVENTS.CHAT_FAILED.name,
EVENTS.CHANNEL_MESSAGE.name,
EVENTS.CALENDAR_ALERT.name,
EVENTS.RETRIEVAL_CONTENT_PROCESS_FAILED.name,
)
def get_event_catalog() -> list[dict[str, str]]:
@ -983,6 +1032,9 @@ def build_event(
async def dispatch_webhook_event(app: Any, event: Event) -> None:
# LICENSE covers this Open WebUI webhook identifier.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI')
subject = event.subject or {}
subject_id = subject.get('id')
@ -1019,7 +1071,41 @@ class WebhookEventSink:
schedule_webhook_dispatch(app, event)
async def dispatch_event_functions(app: Any, event: Event, request: Any | None = None) -> None:
def schedule_notification_dispatch(app: Any, event: Event) -> None:
try:
from open_webui.utils.notifications import dispatch_notification_event
asyncio.create_task(dispatch_notification_event(app, event))
except RuntimeError:
log.exception('Notification delivery could not be scheduled for %s', event.event)
class NotificationEventSink:
async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None:
if event.event in NOTIFICATION_EVENTS:
schedule_notification_dispatch(app, event)
class SocketSessionEventSink:
async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None:
if event.event not in {EVENTS.USER_DELETED.name, EVENTS.USER_ROLE_UPDATED.name}:
return
subject = event.subject or {}
if subject.get('type') != 'user' or not subject.get('id'):
return
from open_webui.socket.main import disconnect_user_sessions
await disconnect_user_sessions(str(subject['id']))
async def dispatch_event_functions(
app: Any, event: Event, request: Any | None = None, extra_function_ids: list[str] | None = None
) -> None:
if not ENABLE_PLUGINS:
return
from open_webui.models.functions import Functions
from open_webui.utils.plugin import get_function_module_from_cache
@ -1028,6 +1114,12 @@ async def dispatch_event_functions(app: Any, event: Event, request: Any | None =
try:
event_functions = await Functions.get_functions_by_type('event', active_only=True)
if extra_function_ids:
extra_functions = await Functions.get_functions_by_ids(extra_function_ids)
existing_ids = {function.id for function in event_functions}
event_functions.extend(
function for function in extra_functions if function.type == 'event' and function.id not in existing_ids
)
except Exception:
log.exception('Event functions could not be loaded for %s', event.event)
return
@ -1076,7 +1168,7 @@ class EventFunctionSink:
schedule_event_function_dispatch(app, event, request)
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink()]
EVENT_SINKS = [SocketSessionEventSink(), EventFunctionSink(), WebhookEventSink(), NotificationEventSink()]
async def publish_event(
@ -1108,3 +1200,74 @@ async def publish_event(
await sink.handle_event(app, event_payload, request=request)
except Exception:
log.exception('Event sink failed for %s', event_payload.event)
async def publish_model_provider_request_failed(
request_or_app: Any,
*,
actor: Any | None,
provider: str,
base_url: str,
status: int,
requested_model: str | None = None,
api_key: str | None = None,
upstream_error: Any = None,
) -> None:
error = upstream_error.get('error') if isinstance(upstream_error, dict) else upstream_error
error_code = None
if isinstance(error, dict):
error_code = error.get('code') or error.get('type') or error.get('error_code')
error = error.get('message') or error.get('detail') or error
error_text = str(error or '')
marker = f'{error_code or ""} {error_text}'.lower()
error_type = (
'model_not_found'
if status == 404
and any(value in marker for value in ('model_not_found', 'model not found', 'does not exist', 'no such model'))
else 'authentication_failed'
if status in (401, 403)
else 'rate_limited'
if status == 429
else 'server_failed'
if status >= 500
else 'upstream_error'
)
# Server-log only; the upstream error body is otherwise invisible to admins
# (event sinks require an event function or webhook to be configured).
log.log(
logging.ERROR if status >= 500 else logging.WARNING,
'Upstream %s request failed: HTTP %d (%s) url=%s model=%s code=%s message=%s',
provider,
status,
error_type,
base_url,
requested_model or '-',
error_code or '-',
error_text[:MAX_STRING_LENGTH] or '-',
)
data = {
'error_type': error_type,
'status': status,
'provider': provider,
'base_url': base_url,
}
if requested_model:
data['requested_model'] = requested_model
if api_key:
data['api_key_suffix'] = f'...{api_key[-4:]}'
if error_code:
data['upstream_error_code'] = error_code
if error:
data['upstream_message'] = error
await publish_event(
request_or_app,
EVENTS.MODEL_PROVIDER_REQUEST_FAILED,
actor=actor,
subject_id=requested_model,
subject_type='model',
data=data,
)

View file

@ -1,6 +1,5 @@
import asyncio
import inspect
import json
import logging
import sys
from typing import AsyncGenerator, Generator, Iterator
@ -20,7 +19,7 @@ from starlette.responses import Response, StreamingResponse
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.users import UserModel
@ -29,6 +28,7 @@ from open_webui.socket.main import (
get_event_emitter,
)
from open_webui.utils.access_control import check_model_access
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.misc import (
add_or_update_system_message,
get_last_user_message,
@ -69,6 +69,9 @@ async def get_function_module_by_id(request: Request, pipe_id: str):
async def get_function_models(request):
if not ENABLE_PLUGINS:
return []
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
pipe_models = []
@ -97,7 +100,7 @@ async def get_function_models(request):
log.exception(e)
sub_pipes = []
log.debug(f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}")
log.debug("get_function_models: function '%s' is a manifold of %s", pipe.id, sub_pipes)
for p in sub_pipes:
sub_pipe_id = f'{pipe.id}.{p["id"]}'
@ -123,7 +126,10 @@ async def get_function_models(request):
pipe_flag = {'type': 'pipe'}
log.debug(
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
"get_function_models: function '%s' is a single pipe { 'id': %s, 'name': %s }",
pipe.id,
pipe.id,
pipe.name,
)
pipe_models.append(
@ -144,7 +150,10 @@ async def get_function_models(request):
return pipe_models
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
async def generate_function_chat_completion(request, form_data, user, models: dict | None = None):
if models is None:
models = {}
async def execute_pipe(pipe, params):
if inspect.iscoroutinefunction(pipe):
return await pipe(**params)
@ -164,7 +173,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
line = line.model_dump_json()
line = f'data: {line}'
if isinstance(line, dict):
line = f'data: {json.dumps(line)}'
line = f'data: {JSONCodec.dumps(line)}'
try:
line = line.decode('utf-8')
@ -175,7 +184,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
return f'{line}\n\n'
else:
line = openai_chat_chunk_message_template(form_data['model'], line)
return f'data: {json.dumps(line)}\n\n'
return f'data: {JSONCodec.dumps(line)}\n\n'
def get_pipe_id(form_data: dict) -> str:
pipe_id = form_data['model']
@ -203,6 +212,13 @@ async def generate_function_chat_completion(request, form_data, user, models: di
return params
# Set server-side by utils/chat.py, never by client input. Mirrors the routers.
bypass_system_prompt = getattr(request.state, 'bypass_system_prompt', False)
# Copy so the base-model substitution below doesn't leak into the caller's
# payload, which the tool-call continuation re-submits. Mirrors the routers.
form_data = {**form_data}
model_id = form_data.get('model')
model_info = await Models.get_model_by_id(model_id)
@ -278,7 +294,8 @@ async def generate_function_chat_completion(request, form_data, user, models: di
if params:
system = params.pop('system', None)
form_data = apply_model_params_to_body_openai(params, form_data)
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
if not bypass_system_prompt:
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
pipe_id = get_pipe_id(form_data)
function_module = await get_function_module_by_id(request, pipe_id)
@ -298,17 +315,17 @@ async def generate_function_chat_completion(request, form_data, user, models: di
yield data
return
if isinstance(res, dict):
yield f'data: {json.dumps(res)}\n\n'
yield f'data: {JSONCodec.dumps(res)}\n\n'
return
except Exception as e:
log.error(f'Error: {e}')
yield f'data: {json.dumps({"error": {"detail": str(e)}})}\n\n'
yield f'data: {JSONCodec.dumps({"error": {"detail": str(e)}})}\n\n'
return
if isinstance(res, str):
message = openai_chat_chunk_message_template(form_data['model'], res)
yield f'data: {json.dumps(message)}\n\n'
yield f'data: {JSONCodec.dumps(message)}\n\n'
if isinstance(res, Iterator):
for line in res:
@ -320,7 +337,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
finish_message['choices'][0]['finish_reason'] = 'stop'
yield f'data: {json.dumps(finish_message)}\n\n'
yield f'data: {JSONCodec.dumps(finish_message)}\n\n'
yield 'data: [DONE]'
return StreamingResponse(stream_content(), media_type='text/event-stream')

View file

@ -1,6 +1,5 @@
from __future__ import annotations
import json
import logging
import os
import sys
@ -28,6 +27,7 @@ from open_webui.env import (
ENABLE_DB_MIGRATIONS,
OPEN_WEBUI_DIR,
)
from open_webui.utils.json_codec import JSONCodec
from sqlalchemy import Dialect, MetaData, create_engine, event, types
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
@ -124,18 +124,18 @@ class JSONField(types.TypeDecorator): # TEXT-backed JSON storage
"""Store arbitrary Python objects as JSON-encoded TEXT.
Used instead of native JSON columns for portability across SQLite and
PostgreSQL. Values are serialized with ``json.dumps`` on write and
deserialized with ``json.loads`` on read.
PostgreSQL. Values are serialized with ``JSONCodec.dumps`` on write and
deserialized with ``JSONCodec.loads`` on read.
"""
impl = types.UnicodeText
cache_ok = True
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
return json.dumps(value) if value is not None else None
return JSONCodec.dumps(value) if value is not None else None
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
return json.loads(value) if value is not None else None
return JSONCodec.loads(value) if value is not None else None
def copy(self, **kwargs: Any) -> Self:
return JSONField(length=self.impl.length)
@ -202,6 +202,20 @@ def enable_iam_token_auth(connectable) -> None:
return
engine = getattr(connectable, 'sync_engine', connectable)
url = engine.url
auth = _rds_iam_token_auth
# The token is bound to one host/port/user pair; leave other databases on their own credentials.
if (url.host, url.port or 5432, url.username) != (auth.host, auth.port, auth.username):
log.warning(
'AWS RDS IAM token auth not applied to %s: the token is issued for %s@%s:%s, '
'so this connection uses the password from its own URL',
url.render_as_string(hide_password=True),
auth.username,
auth.host,
auth.port,
)
return
if not event.contains(engine, 'do_connect', _set_iam_token_password):
event.listen(engine, 'do_connect', _set_iam_token_password)
@ -232,6 +246,27 @@ def _make_async_url(url: str) -> str:
return url
def _json_codec_kwargs(kwargs: dict) -> dict:
"""Default an engine to JSONCodec for native ``JSON`` columns.
Unlike ``JSONField``, those serialize through the engine, which otherwise uses
stdlib ``json``. With ``ENABLE_ORJSON`` off JSONCodec is stdlib ``json`` anyway.
"""
kwargs.setdefault('json_serializer', JSONCodec.dumps)
kwargs.setdefault('json_deserializer', JSONCodec.loads)
return kwargs
def _create_engine(*args, **kwargs):
"""``create_engine`` with the app JSON codec wired in."""
return create_engine(*args, **_json_codec_kwargs(kwargs))
def _create_async_engine(*args, **kwargs):
"""``create_async_engine`` with the app JSON codec wired in."""
return create_async_engine(*args, **_json_codec_kwargs(kwargs))
# ============================================================
# SYNC ENGINE (used only for: startup migrations, config loading,
# Alembic, peewee migration, health checks)
@ -260,7 +295,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
# in the native sqlcipher3 C library. Use NullPool by default for safety,
# or QueuePool if DATABASE_POOL_SIZE is explicitly configured.
if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0:
engine = create_engine(
engine = _create_engine(
'sqlite://',
creator=create_sqlcipher_connection,
pool_size=DATABASE_POOL_SIZE,
@ -272,7 +307,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
echo=False,
)
else:
engine = create_engine(
engine = _create_engine(
'sqlite://',
creator=create_sqlcipher_connection,
poolclass=NullPool,
@ -282,7 +317,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
log.info('Connected to encrypted SQLite database using SQLCipher')
elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False})
engine = _create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False})
def _apply_sqlite_pragmas(dbapi_connection):
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
@ -314,7 +349,7 @@ elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
else:
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
engine = create_engine(
engine = _create_engine(
SQLALCHEMY_DATABASE_URL,
pool_size=DATABASE_POOL_SIZE,
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
@ -324,9 +359,9 @@ else:
poolclass=QueuePool,
)
else:
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool)
engine = _create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool)
else:
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
engine = _create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
enable_iam_token_auth(engine)
@ -370,14 +405,15 @@ if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
# Generous default — async coroutines + no session sharing = high connection demand.
# No pool_pre_ping: a local SQLite file cannot drop connections, and the
# ping costs a worker-thread hop plus a SELECT 1 on every checkout.
_sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512
async_engine = create_async_engine(
async_engine = _create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
connect_args={'check_same_thread': False},
pool_size=_sqlite_pool_size,
pool_timeout=DATABASE_POOL_TIMEOUT,
pool_recycle=DATABASE_POOL_RECYCLE,
pool_pre_ping=True,
)
@event.listens_for(async_engine.sync_engine, 'connect')
@ -386,7 +422,7 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
else:
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
async_engine = create_async_engine(
async_engine = _create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_size=DATABASE_POOL_SIZE,
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
@ -395,13 +431,13 @@ else:
pool_pre_ping=True,
)
else:
async_engine = create_async_engine(
async_engine = _create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
poolclass=NullPool,
)
else:
async_engine = create_async_engine(
async_engine = _create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
)

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,8 @@ from open_webui.env import DATABASE_PASSWORD, DATABASE_URL, LOG_FORMAT
from open_webui.internal.db import enable_iam_token_auth, extract_ssl_params_from_url, reattach_ssl_params_to_url
from open_webui.models.auths import Auth
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
from open_webui.models.chat_messages import ChatMessage # noqa: F401
from open_webui.models.chats import Chat # noqa: F401
from sqlalchemy import create_engine, engine_from_config, pool
alembic_config = alembic.context.config

View file

@ -0,0 +1,28 @@
"""Add group_member user_id index
Revision ID: 1ce6ade7d93b
Revises: f0bd01a18a3d
Create Date: 2026-07-31 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = '1ce6ade7d93b'
down_revision = 'f0bd01a18a3d'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_indexes = {idx['name'] for idx in inspector.get_indexes('group_member')}
if 'ix_group_member_user_id_group_id' not in existing_indexes:
op.create_index('ix_group_member_user_id_group_id', 'group_member', ['user_id', 'group_id'])
def downgrade():
op.drop_index('ix_group_member_user_id_group_id', table_name='group_member')

View file

@ -0,0 +1,36 @@
"""Add memory (id, user_id) covering index
Revision ID: 55f1302ac17c
Revises: b0018471bbbe
Create Date: 2026-07-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '55f1302ac17c'
down_revision: Union[str, None] = 'b0018471bbbe'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'ix_memory_id_user_id' not in indexes:
op.create_index('ix_memory_id_user_id', 'memory', ['id', 'user_id'])
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'ix_memory_id_user_id' in indexes:
op.drop_index('ix_memory_id_user_id', table_name='memory')

View file

@ -0,0 +1,64 @@
"""repair double encoded user oauth
Revision ID: 6d09d1bf1f23
Revises: 1ce6ade7d93b
Create Date: 2026-08-10 23:20:20.374826
"""
import json
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = '6d09d1bf1f23'
down_revision: Union[str, None] = '1ce6ade7d93b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_user = sa.table(
'user',
sa.column('id', sa.Text),
sa.column('oauth', sa.JSON),
)
def _decode_json_object(value: str) -> dict | None:
try:
decoded = json.loads(value)
except Exception:
return None
return decoded if isinstance(decoded, dict) else None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'user' not in inspector.get_table_names():
return
user_columns = {c['name'] for c in inspector.get_columns('user')}
if 'oauth' not in user_columns:
return
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth).where(_user.c.oauth.is_not(None))).fetchall()
for uid, oauth in rows:
if not isinstance(oauth, str):
continue
decoded = _decode_json_object(oauth)
if decoded is None:
continue
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth=decoded))
def downgrade() -> None:
pass

View file

@ -0,0 +1,25 @@
"""add chat message meta
Revision ID: 856c5b02fb54
Revises: 42e2978c7933
Create Date: 2026-07-16 01:39:39.291935
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '856c5b02fb54'
down_revision: Union[str, None] = '42e2978c7933'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('chat_message', sa.Column('meta', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('chat_message', 'meta')

View file

@ -0,0 +1,54 @@
"""add automation folder id
Revision ID: 959eaac8f909
Revises: 55f1302ac17c
Create Date: 2026-07-26 19:19:31.345756
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = '959eaac8f909'
down_revision: str | None = '55f1302ac17c'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
if context.is_offline_mode():
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
return
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {col['name'] for col in inspector.get_columns('automation')}
indexes = {index['name'] for index in inspector.get_indexes('automation')}
if 'folder_id' not in columns:
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
if 'ix_automation_user_folder' not in indexes:
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
def downgrade() -> None:
if context.is_offline_mode():
op.drop_index('ix_automation_user_folder', table_name='automation')
op.drop_column('automation', 'folder_id')
return
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {col['name'] for col in inspector.get_columns('automation')}
indexes = {index['name'] for index in inspector.get_indexes('automation')}
if 'ix_automation_user_folder' in indexes:
op.drop_index('ix_automation_user_folder', table_name='automation')
if 'folder_id' in columns:
op.drop_column('automation', 'folder_id')

View file

@ -0,0 +1,219 @@
"""add current_message_id to chat
Revision ID: 9a1b2c3d4e5f
Revises: 856c5b02fb54
Create Date: 2026-07-23 00:00:00.000000
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '9a1b2c3d4e5f'
down_revision: Union[str, None] = '856c5b02fb54'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
BATCH_SIZE = 150
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'current_message_id' not in columns:
op.add_column('chat', sa.Column('current_message_id', sa.Text(), nullable=True))
chat = sa.table(
'chat',
sa.column('id', sa.String()),
sa.column('chat', sa.Text()),
sa.column('current_message_id', sa.Text()),
)
chat_message = sa.table(
'chat_message',
sa.column('id', sa.Text()),
sa.column('chat_id', sa.Text()),
sa.column('parent_id', sa.Text()),
sa.column('created_at', sa.BigInteger()),
)
has_chat_message = 'chat_message' in inspector.get_table_names()
result = conn.execute(
sa.select(chat.c.id, chat.c.chat, chat.c.current_message_id).execution_options(
yield_per=BATCH_SIZE,
stream_results=True,
)
)
while True:
rows = result.fetchmany(BATCH_SIZE)
if not rows:
break
batch_chat_ids: list[str] = []
candidates_by_chat: dict[str, list[str]] = {}
current_by_chat: dict[str, str | None] = {}
json_messages_by_chat: dict[str, dict[str, dict]] = {}
for row in rows:
values = row._mapping
chat_id = values['id']
prefix = f'{chat_id}-'
batch_chat_ids.append(chat_id)
current_by_chat[chat_id] = values['current_message_id']
chat_data = {}
if isinstance(values['chat'], dict):
chat_data = values['chat']
elif isinstance(values['chat'], str):
try:
parsed = json.loads(values['chat'])
chat_data = parsed if isinstance(parsed, dict) else {}
except (TypeError, ValueError, json.JSONDecodeError):
pass
history = chat_data.get('history') if isinstance(chat_data.get('history'), dict) else {}
candidates_by_chat[chat_id] = []
for candidate in (
values['current_message_id'],
history.get('currentId'),
chat_data.get('currentId'),
chat_data.get('branchPointMessageId'),
):
if not isinstance(candidate, str) or not candidate:
continue
candidate = candidate[len(prefix) :] if candidate.startswith(prefix) else candidate
if candidate not in candidates_by_chat[chat_id]:
candidates_by_chat[chat_id].append(candidate)
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
if not messages and isinstance(chat_data.get('messages'), list):
messages = {
message['id']: message
for message in chat_data['messages']
if isinstance(message, dict) and message.get('id')
}
if messages:
json_messages_by_chat[chat_id] = {
message_id: {
'parent_id': message.get('parentId') if isinstance(message, dict) else None,
'created_at': message.get('timestamp', 0) if isinstance(message, dict) else 0,
}
for message_id, message in messages.items()
}
resolved: dict[str, str] = {}
if has_chat_message:
candidate_ids = {
f'{chat_id}-{candidate}'
for chat_id, candidates in candidates_by_chat.items()
for candidate in candidates
}
if candidate_ids:
valid_by_chat: dict[str, set[str]] = {}
for row in conn.execute(
sa.select(chat_message.c.chat_id, chat_message.c.id).where(
chat_message.c.chat_id.in_(batch_chat_ids),
chat_message.c.id.in_(candidate_ids),
)
):
values = row._mapping
chat_id = values['chat_id']
prefix = f'{chat_id}-'
message_id = values['id']
if message_id and message_id.startswith(prefix):
message_id = message_id[len(prefix) :]
if message_id:
valid_by_chat.setdefault(chat_id, set()).add(message_id)
for chat_id, candidates in candidates_by_chat.items():
valid_ids = valid_by_chat.get(chat_id, set())
for candidate in candidates:
if candidate in valid_ids:
resolved[chat_id] = candidate
break
unresolved_chat_ids = [chat_id for chat_id in batch_chat_ids if chat_id not in resolved]
messages_by_chat: dict[str, dict[str, dict]] = {}
if unresolved_chat_ids:
for row in conn.execute(
sa.select(
chat_message.c.chat_id,
chat_message.c.id,
chat_message.c.parent_id,
chat_message.c.created_at,
).where(chat_message.c.chat_id.in_(unresolved_chat_ids))
):
values = row._mapping
chat_id = values['chat_id']
prefix = f'{chat_id}-'
message_id = values['id']
if message_id and message_id.startswith(prefix):
message_id = message_id[len(prefix) :]
if not message_id:
continue
parent_id = values['parent_id']
if parent_id and parent_id.startswith(prefix):
parent_id = parent_id[len(prefix) :]
messages_by_chat.setdefault(chat_id, {})[message_id] = {
'parent_id': parent_id,
'created_at': values['created_at'] or 0,
}
for chat_id, messages in messages_by_chat.items():
parent_ids = {
message['parent_id'] for message in messages.values() if message.get('parent_id') in messages
}
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
resolved[chat_id] = max(
leaf_ids or list(messages),
key=lambda message_id: messages[message_id].get('created_at') or 0,
)
for chat_id in batch_chat_ids:
if chat_id in resolved:
continue
messages = json_messages_by_chat.get(chat_id, {})
valid_candidate = next(
(candidate for candidate in candidates_by_chat[chat_id] if candidate in messages),
None,
)
if valid_candidate:
resolved[chat_id] = valid_candidate
elif messages:
parent_ids = {
message['parent_id'] for message in messages.values() if message.get('parent_id') in messages
}
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
resolved[chat_id] = max(
leaf_ids or list(messages),
key=lambda message_id: messages[message_id].get('created_at') or 0,
)
updates = [
{'chat_id': chat_id, 'current_message_id': message_id}
for chat_id, message_id in resolved.items()
if message_id and message_id != current_by_chat.get(chat_id)
]
if updates:
conn.execute(
sa.update(chat)
.where(chat.c.id == sa.bindparam('update_chat_id'))
.values(current_message_id=sa.bindparam('update_current_message_id')),
[
{
'update_chat_id': row['chat_id'],
'update_current_message_id': row['current_message_id'],
}
for row in updates
],
)
def downgrade() -> None:
op.drop_column('chat', 'current_message_id')

View file

@ -0,0 +1,32 @@
"""add user variables
Revision ID: b0018471bbbe
Revises: c49178636c78
Create Date: 2026-07-24 01:21:46.457057
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b0018471bbbe'
down_revision: Union[str, None] = 'c49178636c78'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('user')]
if 'variables' not in columns:
op.add_column('user', sa.Column('variables', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('user', 'variables')

View file

@ -72,7 +72,6 @@ def _convert_column_to_json(table: str, column: str):
dialect = conn.dialect.name
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.Text))
t_json = sa.column(f'{column}_json', sa.JSON)
# SQLite cannot ALTER COLUMN → must recreate column
if dialect == 'sqlite':
@ -90,9 +89,9 @@ def _convert_column_to_json(table: str, column: str):
parsed = None
conn.execute(
sa.update(sa.table(table, sa.column('id'), t_json))
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_json', sa.JSON)))
.where(sa.column('id') == uid)
.values({f'{column}_json': json.dumps(parsed) if parsed else None})
.values({f'{column}_json': parsed})
)
op.drop_column(table, column)
@ -112,8 +111,7 @@ def _convert_column_to_text(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
t = sa.table(table, sa.column('id', sa.Text), sa.column(column))
t_text = sa.column(f'{column}_text', sa.Text)
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.JSON))
if dialect == 'sqlite':
op.add_column(table, sa.Column(f'{column}_text', sa.Text(), nullable=True))
@ -122,9 +120,9 @@ def _convert_column_to_text(table: str, column: str):
for uid, raw in rows:
conn.execute(
sa.update(sa.table(table, sa.column('id'), t_text))
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_text', sa.Text)))
.where(sa.column('id') == uid)
.values({f'{column}_text': json.dumps(raw) if raw else None})
.values({f'{column}_text': json.dumps(raw) if raw is not None else None})
)
op.drop_column(table, column)
@ -187,9 +185,7 @@ def upgrade() -> None:
for uid, oauth_sub in rows:
if oauth_sub:
provider, sub = oauth_sub.split('@', 1) if '@' in oauth_sub else ('oidc', oauth_sub)
conn.execute(
sa.update(_user).where(_user.c.id == uid).values(oauth=json.dumps({provider: {'sub': sub}}))
)
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth={provider: {'sub': sub}}))
# ── Migrate api_key column → api_key table (only if old column still exists)
if 'api_key' in user_columns:
@ -228,7 +224,7 @@ def downgrade() -> None:
for uid, oauth in rows:
try:
data = json.loads(oauth)
data = oauth if isinstance(oauth, dict) else json.loads(oauth)
provider = list(data.keys())[0]
sub = data[provider].get('sub')
oauth_sub = f'{provider}@{sub}'

View file

@ -0,0 +1,32 @@
"""add chat variables
Revision ID: c49178636c78
Revises: 9a1b2c3d4e5f
Create Date: 2026-07-23 23:33:45.497453
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c49178636c78'
down_revision: Union[str, None] = '9a1b2c3d4e5f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'variables' not in columns:
op.add_column('chat', sa.Column('variables', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('chat', 'variables')

View file

@ -0,0 +1,70 @@
"""add chat timer_at and chat list, unread and timer indexes
Revision ID: d4c1a8e37b62
Revises: 6d09d1bf1f23
Create Date: 2026-08-23 18:05:12.441907
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'd4c1a8e37b62'
down_revision: str | None = '6d09d1bf1f23'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column('chat', sa.Column('timer_at', sa.BigInteger(), nullable=True))
op.create_index(
'timer_at_idx',
'chat',
['timer_at'],
sqlite_where=sa.text('timer_at IS NOT NULL'),
postgresql_where=sa.text('timer_at IS NOT NULL'),
)
# Timers created before this migration carry their due time in meta only, and would never fire.
chat = sa.table(
'chat', sa.column('id', sa.String), sa.column('meta', sa.JSON), sa.column('timer_at', sa.BigInteger)
)
conn = op.get_bind()
pending = conn.execute(
sa.select(chat.c.id, chat.c.meta)
.where(chat.c.meta['type'].as_string() == 'timer')
.where(chat.c.meta['status'].as_string() == 'pending')
).all()
for chat_id, meta in pending:
try: # imported chats can carry any meta, and a non-numeric due time must not abort the migration
due_at = int(meta.get('timer_at'))
except (TypeError, ValueError):
continue
conn.execute(chat.update().where(chat.c.id == chat_id).values(timer_at=due_at))
op.create_index('user_id_updated_at_id_idx', 'chat', ['user_id', sa.text('updated_at DESC'), 'id'])
op.create_index(
'user_id_timer_at_idx',
'chat',
['user_id', 'timer_at'],
sqlite_where=sa.text('timer_at IS NOT NULL'),
postgresql_where=sa.text('timer_at IS NOT NULL'),
)
op.create_index(
'user_id_folder_unread_idx',
'chat',
['user_id', 'folder_id', 'archived', 'updated_at', 'last_read_at', 'id'],
)
op.create_index('chat_message_chat_role_done_idx', 'chat_message', ['chat_id', 'role', 'done'])
def downgrade() -> None:
op.drop_index('chat_message_chat_role_done_idx', table_name='chat_message')
op.drop_index('user_id_folder_unread_idx', table_name='chat')
op.drop_index('user_id_timer_at_idx', table_name='chat')
op.drop_index('user_id_updated_at_id_idx', table_name='chat')
op.drop_index('timer_at_idx', table_name='chat')
op.drop_column('chat', 'timer_at')

View file

@ -0,0 +1,84 @@
"""add unique normalized user email index
Revision ID: f0bd01a18a3d
Revises: 959eaac8f909
Create Date: 2026-07-27 04:41:12.708743
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = 'f0bd01a18a3d'
down_revision: str | None = '959eaac8f909'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
INDEX_NAME = 'uq_user_email_lower'
EMAIL_IS_NOT_NULL = sa.text('email IS NOT NULL')
LOWER_EMAIL = sa.text('lower(email)')
def _index_exists() -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return INDEX_NAME in {index['name'] for index in inspector.get_indexes('user')}
def _duplicate_emails() -> list:
conn = op.get_bind()
return conn.execute(
sa.text(
"""
SELECT lower(email) AS email, count(*) AS duplicate_count
FROM "user"
WHERE email IS NOT NULL
GROUP BY lower(email)
HAVING count(*) > 1
ORDER BY lower(email)
"""
)
).fetchall()
def _create_index() -> None:
op.create_index(
INDEX_NAME,
'user',
[LOWER_EMAIL],
unique=True,
postgresql_where=EMAIL_IS_NOT_NULL,
sqlite_where=EMAIL_IS_NOT_NULL,
)
def upgrade() -> None:
if context.is_offline_mode():
_create_index()
return
if _index_exists():
return
duplicates = _duplicate_emails()
if duplicates:
details = ', '.join(f'{row.email} (x{row.duplicate_count})' for row in duplicates)
raise RuntimeError(
'Cannot add unique normalized user email index because duplicate emails exist: '
f'{details}. Merge or remove the duplicate users and rerun migrations.'
)
_create_index()
def downgrade() -> None:
if context.is_offline_mode():
op.drop_index(INDEX_NAME, table_name='user')
return
if _index_exists():
op.drop_index(INDEX_NAME, table_name='user')

View file

@ -11,6 +11,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
PRINCIPAL_TYPE_ANYONE = 'anyone'
PRINCIPAL_TYPE_GROUP = 'group'
PRINCIPAL_TYPE_USER = 'user'
WILDCARD_PRINCIPAL_ID = '*'
####################
# AccessGrant DB Schema
@ -23,7 +28,7 @@ class AccessGrant(Base):
id = Column(Text, primary_key=True)
resource_type = Column(Text, nullable=False) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
resource_id = Column(Text, nullable=False)
principal_type = Column(Text, nullable=False) # "user" or "group"
principal_type = Column(Text, nullable=False) # "user", "group", or "anyone"
principal_id = Column(Text, nullable=False) # user_id, group_id, or "*" (wildcard for public)
permission = Column(Text, nullable=False) # "read" or "write"
created_at = Column(BigInteger, nullable=False)
@ -163,12 +168,14 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
principal_id = grant.get('principal_id')
permission = grant.get('permission')
if principal_type not in ('user', 'group'):
if principal_type not in (PRINCIPAL_TYPE_USER, PRINCIPAL_TYPE_GROUP, PRINCIPAL_TYPE_ANYONE):
continue
if permission not in ('read', 'write'):
continue
if not isinstance(principal_id, str) or not principal_id:
continue
if principal_type == PRINCIPAL_TYPE_ANYONE and (principal_id != WILDCARD_PRINCIPAL_ID or permission != 'read'):
continue
key = (principal_type, principal_id, permission)
deduped[key] = {
@ -186,7 +193,11 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
Returns True when a direct grant list includes wildcard public-read.
"""
for grant in normalize_access_grants(access_grants):
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'read':
if (
grant['principal_type'] == PRINCIPAL_TYPE_USER
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
and grant['permission'] == 'read'
):
return True
return False
@ -196,7 +207,25 @@ def has_public_write_access_grant(access_grants: Optional[list]) -> bool:
Returns True when a direct grant list includes wildcard public-write.
"""
for grant in normalize_access_grants(access_grants):
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'write':
if (
grant['principal_type'] == PRINCIPAL_TYPE_USER
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
and grant['permission'] == 'write'
):
return True
return False
def has_anyone_read_access_grant(access_grants: Optional[list]) -> bool:
"""
Returns True when a direct grant list includes no-auth anyone-read.
"""
for grant in normalize_access_grants(access_grants):
if (
grant['principal_type'] == PRINCIPAL_TYPE_ANYONE
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
and grant['permission'] == 'read'
):
return True
return False
@ -206,7 +235,7 @@ def has_user_access_grant(access_grants: Optional[list]) -> bool:
Returns True when a direct grant list includes any non-wildcard user grant.
"""
for grant in normalize_access_grants(access_grants):
if grant['principal_type'] == 'user' and grant['principal_id'] != '*':
if grant['principal_type'] == PRINCIPAL_TYPE_USER and grant['principal_id'] != WILDCARD_PRINCIPAL_ID:
return True
return False
@ -223,12 +252,27 @@ def strip_user_access_grants(access_grants: Optional[list]) -> list:
for grant in access_grants
if not (
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
== 'user'
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None)) != '*'
== PRINCIPAL_TYPE_USER
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None))
!= WILDCARD_PRINCIPAL_ID
)
]
def strip_anyone_access_grants(access_grants: Optional[list]) -> list:
"""
Remove no-auth anyone grants from the list.
"""
if not access_grants:
return []
return [
grant
for grant in access_grants
if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
!= PRINCIPAL_TYPE_ANYONE
]
def grants_to_access_control(grants: list) -> Optional[dict]:
"""
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
@ -316,7 +360,6 @@ class AccessGrantsTable:
)
db.add(grant)
await db.commit()
await db.refresh(grant)
return AccessGrantModel.model_validate(grant)
async def revoke_access(
@ -494,6 +537,28 @@ class AccessGrantsTable:
result_dict[g.resource_id].append(AccessGrantModel.model_validate(g))
return result_dict
async def has_anyone_access(
self,
resource_type: str,
resource_id: str,
permission: str = 'read',
db: Optional[AsyncSession] = None,
) -> bool:
"""Check for a no-auth anyone:* grant. Callers must opt in explicitly."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(AccessGrant)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == resource_id,
AccessGrant.principal_type == PRINCIPAL_TYPE_ANYONE,
AccessGrant.principal_id == WILDCARD_PRINCIPAL_ID,
AccessGrant.permission == permission,
)
.limit(1)
)
return result.scalars().first() is not None
async def has_access(
self,
user_id: str,
@ -774,7 +839,8 @@ class AccessGrantsTable:
):
"""
Filter for items where user has read BUT NOT write access.
Public items are NOT considered read_only.
A public (user:*) read grant counts as read access, so publicly shared
read-only items are listed rather than being reachable only by direct link.
Note: This method builds SQLAlchemy expressions and does NOT perform I/O itself,
so it remains synchronous. The caller is responsible for executing the query
@ -785,7 +851,6 @@ class AccessGrantsTable:
from sqlalchemy import exists as sa_exists
# Has read grant (not public)
read_grant_exists = (
select(AccessGrant.id)
.where(
@ -793,6 +858,10 @@ class AccessGrantsTable:
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == 'read',
or_(
and_(
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
),
*(
[
and_(
@ -819,7 +888,6 @@ class AccessGrantsTable:
.exists()
)
# Does NOT have write grant
write_grant_exists = (
select(AccessGrant.id)
.where(
@ -827,6 +895,10 @@ class AccessGrantsTable:
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == 'write',
or_(
and_(
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
),
*(
[
and_(
@ -853,21 +925,7 @@ class AccessGrantsTable:
.exists()
)
# Is NOT public
public_grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == 'read',
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
)
.correlate(DocumentModel)
.exists()
)
conditions = [read_grant_exists, ~write_grant_exists, ~public_grant_exists]
conditions = [read_grant_exists, ~write_grant_exists]
# Not owner
if user_id:

View file

@ -12,6 +12,7 @@ from open_webui.models.users import User, UserModel, UserProfileImageResponse, U
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, field_validator
from sqlalchemy import Boolean, Column, String, Text, delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@ -124,18 +125,20 @@ class AuthsTable:
)
session.add(credential)
created_user = await Users.insert_new_user(
new_id,
name,
email,
profile_image_url,
role,
oauth=oauth,
db=session,
)
# persist both records and reload generated defaults
await session.commit()
await session.refresh(credential)
try:
created_user = await Users.insert_new_user(
new_id,
name,
email,
profile_image_url,
role,
oauth=oauth,
db=session,
)
await session.commit()
except IntegrityError:
await session.rollback()
raise
return created_user if credential and created_user else None
async def authenticate_user(

View file

@ -1,9 +1,10 @@
import logging
import time
from typing import Optional
from typing import Literal, Optional
from uuid import uuid4
from open_webui.internal.db import Base, get_async_db_context
from open_webui.utils.misc import json_text_variants
from pydantic import BaseModel, ConfigDict
from sqlalchemy import JSON, BigInteger, Boolean, Column, Index, String, Text, cast, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -21,6 +22,7 @@ class Automation(Base):
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
folder_id = Column(Text, nullable=True)
name = Column(Text, nullable=False)
data = Column(JSON, nullable=False) # {prompt, model_id, rrule}
meta = Column(JSON, nullable=True)
@ -31,7 +33,10 @@ class Automation(Base):
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (Index('ix_automation_next_run', 'next_run_at'),)
__table_args__ = (
Index('ix_automation_next_run', 'next_run_at'),
Index('ix_automation_user_folder', 'user_id', 'folder_id'),
)
class AutomationRun(Base):
@ -60,11 +65,17 @@ class AutomationTerminalConfig(BaseModel):
cwd: Optional[str] = None
class AutomationTarget(BaseModel):
type: Literal['chat', 'channel'] = 'chat'
channel_id: Optional[str] = None
class AutomationData(BaseModel):
prompt: str
model_id: str
rrule: str
terminal: Optional[AutomationTerminalConfig] = None
target: Optional[AutomationTarget] = None
class AutomationModel(BaseModel):
@ -72,6 +83,7 @@ class AutomationModel(BaseModel):
id: str
user_id: str
folder_id: Optional[str] = None
name: str
data: dict
meta: Optional[dict] = None
@ -96,6 +108,7 @@ class AutomationRunModel(BaseModel):
class AutomationForm(BaseModel):
name: str
folder_id: Optional[str] = None
data: AutomationData
meta: Optional[dict] = None
is_active: Optional[bool] = True
@ -129,6 +142,7 @@ class AutomationTable:
row = Automation(
id=str(uuid4()),
user_id=user_id,
folder_id=form.folder_id,
name=form.name,
data=form.data.model_dump(),
meta=form.meta,
@ -139,7 +153,6 @@ class AutomationTable:
)
db.add(row)
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def count_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> int:
@ -165,6 +178,7 @@ class AutomationTable:
user_id: str,
query: Optional[str] = None,
status: Optional[str] = None,
folder_id: Optional[str] = None,
skip: int = 0,
limit: int = 30,
db: Optional[AsyncSession] = None,
@ -172,13 +186,16 @@ class AutomationTable:
async with get_async_db_context(db) as db:
stmt = select(Automation).filter_by(user_id=user_id)
if folder_id:
stmt = stmt.filter(Automation.folder_id == folder_id)
if query:
search = f'%{query}%'
# Search in name and prompt inside JSON data
# Search the name column and the prompt inside the JSON data.
data_text = cast(Automation.data, String)
stmt = stmt.filter(
or_(
Automation.name.ilike(search),
cast(Automation.data, String).ilike(search),
Automation.name.ilike(f'%{query}%'),
*(data_text.ilike(f'%{variant}%') for variant in json_text_variants(query)),
)
)
@ -217,6 +234,7 @@ class AutomationTable:
if not row:
return None
row.name = form.name
row.folder_id = form.folder_id
row.data = form.data.model_dump()
row.meta = form.meta
if form.is_active is not None:
@ -224,9 +242,25 @@ class AutomationTable:
row.next_run_at = next_run_at
row.updated_at = int(time.time_ns())
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def clear_folder_ids(
self,
user_id: str,
folder_ids: list[str],
db: Optional[AsyncSession] = None,
) -> int:
if not folder_ids:
return 0
async with get_async_db_context(db) as db:
result = await db.execute(
update(Automation)
.where(Automation.user_id == user_id, Automation.folder_id.in_(folder_ids))
.values(folder_id=None, updated_at=int(time.time_ns()))
)
await db.commit()
return result.rowcount or 0
async def toggle(
self,
id: str,
@ -241,7 +275,6 @@ class AutomationTable:
row.next_run_at = next_run_at if row.is_active else None
row.updated_at = int(time.time_ns())
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def delete(self, id: str, db: Optional[AsyncSession] = None) -> bool:
@ -324,7 +357,6 @@ class AutomationRunTable:
)
db.add(row)
await db.commit()
await db.refresh(row)
return AutomationRunModel.model_validate(row)
async def get_latest(self, automation_id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationRunModel]:

View file

@ -241,11 +241,11 @@ class CalendarTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> CalendarModel:
cal_data = CalendarModel.model_validate(cal).model_dump(exclude={'access_grants'})
cal_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(cal_data['id'], db=db)
calendar_model = CalendarModel.model_validate(cal)
calendar_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(calendar_model.id, db=db)
)
return CalendarModel.model_validate(cal_data)
return calendar_model
async def get_or_create_defaults(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
"""Return user's calendars, creating 'Personal' default if none exist."""
@ -500,9 +500,12 @@ class CalendarEventTable:
# Filter to requested calendars only
accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids]
# Also get event IDs where user is an attendee
# Also get event IDs where the user is an attendee, excluding invites they declined
attendee_event_ids_result = await db.execute(
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
select(CalendarEventAttendee.event_id).filter(
CalendarEventAttendee.user_id == user_id,
CalendarEventAttendee.status != 'declined',
)
)
attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()]
@ -731,10 +734,10 @@ class CalendarEventTable:
events = []
for event, tz in rows:
model = CalendarEventModel.model_validate(event)
# Determine per-event alert window
alert_minutes = None
if model.meta and 'alert_minutes' in model.meta:
alert_minutes = model.meta['alert_minutes']
# meta is user-writable and this poll is shared by every user.
alert_minutes = (model.meta or {}).get('alert_minutes')
if not isinstance(alert_minutes, (int, float)):
alert_minutes = None
if alert_minutes is not None:
if alert_minutes < 0:
@ -764,22 +767,32 @@ class CalendarEventAttendeeTable:
async def set_attendees(
self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None
) -> list[CalendarEventAttendeeModel]:
"""Replace all attendees for an event.
"""Replace all attendees for an event ({user_id, meta?} per dict).
Each dict in attendees: {user_id: str, status?: str, meta?: dict}
RSVP status is the attendee's alone to set (via update_rsvp): an existing
attendee keeps their status, a newly added one starts 'pending'. A
caller-supplied status is ignored so an organiser cannot set it for others.
"""
async with get_async_db_context(db) as db:
existing_status = {
row.user_id: row.status
for row in (
await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
).scalars()
}
# Remove existing
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
now = int(time.time_ns())
models = []
for att in attendees:
user_id = att['user_id']
row = CalendarEventAttendee(
id=str(uuid4()),
event_id=event_id,
user_id=att['user_id'],
status=att.get('status', 'pending'),
user_id=user_id,
status=existing_status.get(user_id, 'pending'),
meta=att.get('meta'),
created_at=now,
updated_at=now,

View file

@ -1,4 +1,3 @@
import json
import secrets
import time
import uuid
@ -10,6 +9,7 @@ from open_webui.models.access_grants import (
AccessGrants,
)
from open_webui.models.groups import Groups
from open_webui.models.users import User
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import (
@ -266,11 +266,11 @@ class ChannelTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> ChannelModel:
channel_data = ChannelModel.model_validate(channel).model_dump(exclude={'access_grants'})
channel_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(channel_data['id'], db=db)
channel_model = ChannelModel.model_validate(channel)
channel_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(channel_model.id, db=db)
)
return ChannelModel.model_validate(channel_data)
return channel_model
async def _collect_unique_user_ids(
self,
@ -438,17 +438,17 @@ class ChannelTable:
match_count = func.sum(
case(
(ChannelMember.user_id.in_(unique_user_ids), 1),
(User.id.in_(unique_user_ids), 1),
else_=0,
)
)
subquery = (
select(ChannelMember.channel_id)
.join(User, User.id == ChannelMember.user_id)
.group_by(ChannelMember.channel_id)
# 1. Channel must have exactly len(user_ids) members
.having(func.count(ChannelMember.user_id) == len(unique_user_ids))
# 2. All those members must be in unique_user_ids
# Match the exact set of accounts that still exist.
.having(func.count(User.id) == len(unique_user_ids))
.having(match_count == len(unique_user_ids))
.subquery()
)
@ -869,7 +869,6 @@ class ChannelTable:
result = ChannelFile(**channel_file.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return ChannelFileModel.model_validate(result)
else:

View file

@ -1,7 +1,9 @@
import json
import time
import uuid
from collections import Counter
from datetime import datetime, timedelta
from typing import Any, Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy import select, delete, func, cast, Integer, distinct
from sqlalchemy.ext.asyncio import AsyncSession
@ -47,6 +49,17 @@ def _normalize_timestamp(timestamp: int) -> float:
return timestamp
def _timezone(tz: Optional[str]) -> ZoneInfo:
try:
return ZoneInfo(tz or 'UTC')
except ZoneInfoNotFoundError:
return ZoneInfo('UTC')
def _date_key(timestamp: int, tz: ZoneInfo) -> str:
return datetime.fromtimestamp(_normalize_timestamp(timestamp), tz=tz).strftime('%Y-%m-%d')
def get_usage(data: dict) -> Optional[dict]:
"""Extract and normalize usage from message data."""
usage = data.get('usage') or (data.get('info') or {}).get('usage')
@ -72,6 +85,40 @@ def _token_columns(dialect: str):
)
def _extract_tool_names(value: Any) -> list[str]:
names: list[str] = []
def add(name: Any):
if isinstance(name, str):
cleaned = name.strip()
if cleaned and len(cleaned) <= 128:
names.append(cleaned)
def walk(item: Any):
if isinstance(item, list):
for child in item:
walk(child)
return
if not isinstance(item, dict):
return
item_type = str(item.get('type') or '')
looks_like_tool = 'tool' in item_type or item_type in {'function_call', 'function_call_output'}
if looks_like_tool:
add(item.get('name') or item.get('tool_name'))
function = item.get('function')
if isinstance(function, dict):
add(function.get('name'))
for key in ('tool_calls', 'tools', 'output', 'meta'):
if key in item:
walk(item.get(key))
walk(value)
return names
####################
# ChatMessage DB Schema
####################
@ -100,6 +147,7 @@ class ChatMessage(Base):
files = Column(JSON, nullable=True)
sources = Column(JSON, nullable=True)
embeds = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
# Status
done = Column(Boolean, default=True)
@ -120,6 +168,7 @@ class ChatMessage(Base):
Index('chat_message_chat_parent_idx', 'chat_id', 'parent_id'),
Index('chat_message_model_created_idx', 'model_id', 'created_at'),
Index('chat_message_user_created_idx', 'user_id', 'created_at'),
Index('chat_message_chat_role_done_idx', 'chat_id', 'role', 'done'), # unfinished-assistant probe
)
@ -142,6 +191,7 @@ class ChatMessageModel(BaseModel):
files: Optional[list] = None
sources: Optional[list] = None
embeds: Optional[list] = None
meta: Optional[dict] = None
done: bool = True
status_history: Optional[list] = None
error: Optional[dict | str] = None
@ -157,6 +207,66 @@ class ChatMessageModel(BaseModel):
class ChatMessageTable:
@staticmethod
def _apply_message_data(message: ChatMessage, data: dict, now: int) -> None:
"""Overwrite only the fields the payload carries."""
if 'role' in data:
message.role = data['role']
if 'parent_id' in data or 'parentId' in data:
message.parent_id = data.get('parent_id') or data.get('parentId')
if 'content' in data:
message.content = data.get('content')
if 'output' in data:
message.output = data.get('output')
if 'model_id' in data or 'model' in data:
message.model_id = data.get('model_id') or data.get('model')
if 'files' in data:
message.files = data.get('files')
if 'sources' in data:
message.sources = data.get('sources')
if 'embeds' in data:
message.embeds = data.get('embeds')
if 'meta' in data:
message.meta = data.get('meta')
if 'done' in data:
message.done = data['done']
if 'status_history' in data or 'statusHistory' in data:
message.status_history = data.get('status_history') or data.get('statusHistory')
if 'error' in data:
message.error = data.get('error')
if 'context_summary' in data or 'contextSummary' in data:
message.context_summary = data.get('context_summary') or data.get('contextSummary')
usage = get_usage(data)
if usage:
existing_usage = normalize_usage(message.usage)
message.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage)
message.updated_at = now
@staticmethod
def _build_message(composite_id: str, chat_id: str, user_id: str, data: dict, now: int) -> ChatMessage:
return ChatMessage(
id=composite_id,
chat_id=chat_id,
user_id=user_id,
role=data.get('role', 'user'),
parent_id=data.get('parent_id') or data.get('parentId'),
content=data.get('content'),
output=data.get('output'),
model_id=data.get('model_id') or data.get('model'),
files=data.get('files'),
sources=data.get('sources'),
embeds=data.get('embeds'),
meta=data.get('meta'),
done=data.get('done', True),
status_history=data.get('status_history') or data.get('statusHistory'),
error=data.get('error'),
usage=get_usage(data),
context_summary=data.get('context_summary') or data.get('contextSummary'),
created_at=data.get('timestamp', now),
updated_at=now,
)
async def upsert_message(
self,
message_id: str,
@ -168,81 +278,67 @@ class ChatMessageTable:
"""Insert or update a chat message."""
async with get_async_db_context(db) as db:
now = int(time.time())
timestamp = data.get('timestamp', now)
# Use composite ID: {chat_id}-{message_id}
composite_id = f'{chat_id}-{message_id}'
existing = await db.get(ChatMessage, composite_id)
if existing:
# Update existing
if 'role' in data:
existing.role = data['role']
if 'parent_id' in data or 'parentId' in data:
existing.parent_id = data.get('parent_id') or data.get('parentId')
if 'content' in data:
existing.content = data.get('content')
if 'output' in data:
existing.output = data.get('output')
if 'model_id' in data or 'model' in data:
existing.model_id = data.get('model_id') or data.get('model')
if 'files' in data:
existing.files = data.get('files')
if 'sources' in data:
existing.sources = data.get('sources')
if 'embeds' in data:
existing.embeds = data.get('embeds')
if 'done' in data:
existing.done = data.get('done', True)
if 'status_history' in data or 'statusHistory' in data:
existing.status_history = data.get('status_history') or data.get('statusHistory')
if 'error' in data:
existing.error = data.get('error')
if 'context_summary' in data or 'contextSummary' in data:
existing.context_summary = data.get('context_summary') or data.get('contextSummary')
# Extract and normalize usage
usage = get_usage(data)
if usage:
existing_usage = normalize_usage(existing.usage or {}) if existing.usage else {}
existing.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage)
existing.updated_at = now
await db.commit()
await db.refresh(existing)
return ChatMessageModel.model_validate(existing)
message = await db.get(ChatMessage, composite_id)
if message:
self._apply_message_data(message, data, now)
else:
# Insert new
# Extract and normalize usage
usage = get_usage(data)
message = ChatMessage(
id=composite_id,
chat_id=chat_id,
user_id=user_id,
role=data.get('role', 'user'),
parent_id=data.get('parent_id') or data.get('parentId'),
content=data.get('content'),
output=data.get('output'),
model_id=data.get('model_id') or data.get('model'),
files=data.get('files'),
sources=data.get('sources'),
embeds=data.get('embeds'),
done=data.get('done', True),
status_history=data.get('status_history') or data.get('statusHistory'),
error=data.get('error'),
usage=usage,
context_summary=data.get('context_summary') or data.get('contextSummary'),
created_at=timestamp,
updated_at=now,
)
message = self._build_message(composite_id, chat_id, user_id, data, now)
db.add(message)
await db.commit()
await db.refresh(message)
return ChatMessageModel.model_validate(message)
await db.commit()
return ChatMessageModel.model_validate(message)
async def upsert_messages(
self,
chat_id: str,
user_id: str,
messages: dict[str, dict],
db: AsyncSession | None = None,
) -> None:
"""Insert or update the given messages of one chat."""
if not messages:
return
async with get_async_db_context(db) as db:
now = int(time.time())
result = await db.execute(
select(ChatMessage).filter(ChatMessage.id.in_([f'{chat_id}-{message_id}' for message_id in messages]))
)
existing_by_id = {row.id: row for row in result.scalars().all()}
for message_id, data in messages.items():
composite_id = f'{chat_id}-{message_id}'
message = existing_by_id.get(composite_id)
if message:
self._apply_message_data(message, data, now)
else:
db.add(self._build_message(composite_id, chat_id, user_id, data, now))
await db.commit()
async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]:
async with get_async_db_context(db) as db:
message = await db.get(ChatMessage, id)
return ChatMessageModel.model_validate(message) if message else None
async def has_unfinished_assistant_by_chat_id(
self,
chat_id: str,
db: Optional[AsyncSession] = None,
) -> bool:
async with get_async_db_context(db) as db:
result = await db.execute(
select(ChatMessage.id)
.where(ChatMessage.chat_id == chat_id)
.where(ChatMessage.role == 'assistant')
.where(ChatMessage.done.is_(False))
.limit(1)
)
return result.scalar_one_or_none() is not None
async def get_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[ChatMessageModel]:
async with get_async_db_context(db) as db:
result = await db.execute(
@ -584,6 +680,233 @@ class ChatMessageTable:
for row in result.all()
}
async def get_user_usage_summary(
self,
user_id: str,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
include_active_days: bool = True,
timezone: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict:
async with get_async_db_context(db) as db:
bind = await db.connection()
dialect = bind.dialect.name
input_tokens, output_tokens = _token_columns(dialect)
messages_stmt = select(ChatMessage.role, func.count(ChatMessage.id).label('count')).filter(
ChatMessage.user_id == user_id,
)
token_stmt = select(
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
).filter(
ChatMessage.user_id == user_id,
ChatMessage.role == 'assistant',
ChatMessage.usage.isnot(None),
)
models_stmt = select(func.count(distinct(ChatMessage.model_id)).label('models_used')).filter(
ChatMessage.user_id == user_id,
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
)
if start_date:
messages_stmt = messages_stmt.filter(ChatMessage.created_at >= start_date)
token_stmt = token_stmt.filter(ChatMessage.created_at >= start_date)
models_stmt = models_stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
messages_stmt = messages_stmt.filter(ChatMessage.created_at <= end_date)
token_stmt = token_stmt.filter(ChatMessage.created_at <= end_date)
models_stmt = models_stmt.filter(ChatMessage.created_at <= end_date)
messages_result = await db.execute(messages_stmt.group_by(ChatMessage.role))
message_counts = {row.role: row.count for row in messages_result.all()}
token_result = (await db.execute(token_stmt)).one()
models_used = (await db.execute(models_stmt)).scalar() or 0
active_days = set()
if include_active_days:
tz = _timezone(timezone)
day_stmt = select(ChatMessage.created_at).filter(ChatMessage.user_id == user_id)
if start_date:
day_stmt = day_stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
day_stmt = day_stmt.filter(ChatMessage.created_at <= end_date)
day_result = await db.execute(day_stmt)
active_days = {_date_key(row.created_at, tz) for row in day_result.all()}
input_total = int(token_result.input_tokens or 0)
output_total = int(token_result.output_tokens or 0)
return {
'messages': sum(message_counts.values()),
'user_messages': message_counts.get('user', 0),
'assistant_messages': message_counts.get('assistant', 0),
'input_tokens': input_total,
'output_tokens': output_total,
'total_tokens': input_total + output_total,
'models_used': int(models_used),
'active_days': len(active_days),
}
async def get_user_first_message_created_at(
self,
user_id: str,
db: Optional[AsyncSession] = None,
) -> Optional[int]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(func.min(ChatMessage.created_at)).filter(
ChatMessage.user_id == user_id,
ChatMessage.created_at.isnot(None),
)
)
value = result.scalar()
return int(value) if value else None
async def get_user_daily_usage(
self,
user_id: str,
start_date: int,
end_date: int,
timezone: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> list[dict]:
async with get_async_db_context(db) as db:
tz = _timezone(timezone)
bind = await db.connection()
dialect = bind.dialect.name
input_tokens, output_tokens = _token_columns(dialect)
stmt = select(
ChatMessage.created_at,
ChatMessage.chat_id,
ChatMessage.role,
ChatMessage.model_id,
ChatMessage.usage,
input_tokens.label('input_tokens'),
output_tokens.label('output_tokens'),
).filter(
ChatMessage.user_id == user_id,
ChatMessage.created_at >= start_date,
ChatMessage.created_at <= end_date,
)
result = await db.execute(stmt)
daily: dict[str, dict] = {}
for row in result.all():
date = _date_key(row.created_at, tz)
entry = daily.setdefault(
date,
{
'date': date,
'messages': 0,
'chat_ids': set(),
'tokens': 0,
'models': Counter(),
},
)
entry['messages'] += 1
entry['chat_ids'].add(row.chat_id)
if row.role == 'assistant' and row.model_id:
entry['models'][row.model_id] += 1
if row.usage:
entry['tokens'] += int(row.input_tokens or 0) + int(row.output_tokens or 0)
current = datetime.fromtimestamp(_normalize_timestamp(start_date), tz=tz).replace(
hour=0, minute=0, second=0, microsecond=0
)
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date), tz=tz).replace(
hour=0, minute=0, second=0, microsecond=0
)
while current <= end_dt:
date = current.strftime('%Y-%m-%d')
daily.setdefault(
date,
{'date': date, 'messages': 0, 'chat_ids': set(), 'tokens': 0, 'models': Counter()},
)
current += timedelta(days=1)
return [
{
'date': item['date'],
'messages': item['messages'],
'chats': len(item['chat_ids']),
'tokens': item['tokens'],
'models': dict(item['models']),
}
for item in sorted(daily.values(), key=lambda x: x['date'])
]
async def get_user_top_models(
self,
user_id: str,
start_date: int,
end_date: int,
limit: int = 5,
db: Optional[AsyncSession] = None,
) -> list[dict]:
async with get_async_db_context(db) as db:
bind = await db.connection()
dialect = bind.dialect.name
input_tokens, output_tokens = _token_columns(dialect)
stmt = (
select(
ChatMessage.model_id,
func.count(ChatMessage.id).label('messages'),
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
)
.filter(
ChatMessage.user_id == user_id,
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
ChatMessage.created_at >= start_date,
ChatMessage.created_at <= end_date,
)
.group_by(ChatMessage.model_id)
.order_by(func.count(ChatMessage.id).desc())
.limit(limit)
)
result = await db.execute(stmt)
return [
{
'model_id': row.model_id,
'messages': row.messages,
'input_tokens': int(row.input_tokens or 0),
'output_tokens': int(row.output_tokens or 0),
'total_tokens': int(row.input_tokens or 0) + int(row.output_tokens or 0),
}
for row in result.all()
]
async def get_user_top_tools(
self,
user_id: str,
start_date: int,
end_date: int,
limit: int = 5,
db: Optional[AsyncSession] = None,
) -> list[dict]:
async with get_async_db_context(db) as db:
stmt = select(ChatMessage.output, ChatMessage.meta).filter(
ChatMessage.user_id == user_id,
ChatMessage.created_at >= start_date,
ChatMessage.created_at <= end_date,
)
result = await db.execute(stmt)
counts: Counter[str] = Counter()
for output, meta in result.all():
for name in _extract_tool_names(output):
counts[name] += 1
for name in _extract_tool_names(meta):
counts[name] += 1
return [{'name': name, 'count': count} for name, count in counts.most_common(limit)]
async def get_message_count_by_user(
self,
start_date: Optional[int] = None,

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@ import logging
import time
from typing import Any, ClassVar
from fastapi.encoders import jsonable_encoder
from open_webui.internal.db import Base, get_async_db
from sqlalchemy import JSON, BigInteger, Column, Text, delete, select
@ -31,6 +32,8 @@ DICT_CONFIG_KEY_ALIASES = {
'audio.tts.openai.params': ('AUDIO_TTS_OPENAI_PARAMS',),
'models.default_metadata': ('DEFAULT_MODEL_METADATA',),
'models.default_params': ('DEFAULT_MODEL_PARAMS',),
'task.model.params': ('TASK_MODEL_PARAMS',),
'ui.default_interface_settings': ('DEFAULT_INTERFACE_SETTINGS',),
'user.permissions': ('USER_PERMISSIONS',),
}
DICT_CONFIG_KEYS = tuple(DICT_CONFIG_KEY_ALIASES)
@ -45,8 +48,10 @@ API_CONFIG_FIELDS = (
'auth_type',
'headers',
'azure',
'api_type',
'api_version',
'extra_params',
'passthrough_params',
)
@ -86,6 +91,10 @@ def _assign_path(target: dict, path: list[str], value: Any) -> None:
current[path[-1]] = value
def _json_value(value: Any) -> Any:
return jsonable_encoder(value)
# ── Model ────────────────────────────────────────────────────────────────────
@ -112,7 +121,7 @@ class Config(Base):
enable_persistent: bool = True,
enable_oauth_persistent: bool = False,
) -> None:
cls.DEFAULTS = defaults or {}
cls.DEFAULTS = dict(defaults or {})
cls.PERSISTENT_ENABLED = enable_persistent
cls.OAUTH_PERSISTENT_ENABLED = enable_oauth_persistent
@ -188,9 +197,20 @@ class Config(Base):
@staticmethod
async def upsert(updates: dict) -> None:
"""Upsert multiple config key-value pairs. Raises on failure."""
persistent_updates = {}
for key, value in updates.items():
value = _json_value(value)
if Config.persistent_enabled_for(key):
persistent_updates[key] = value
else:
Config.DEFAULTS[key] = value
if not persistent_updates:
return
async with get_async_db() as db:
now = int(time.time())
for key, value in updates.items():
for key, value in persistent_updates.items():
existing = await db.get(Config, key)
if existing:
existing.value = value
@ -231,7 +251,12 @@ class Config(Base):
now = int(time.time())
new_count = 0
for key, value in defaults.items():
# Skip keys the DB is not authoritative for (e.g. oauth.* while
# ENABLE_OAUTH_PERSISTENT_CONFIG is off), matching the read paths.
if not Config.persistent_enabled_for(key):
continue
if key not in existing_keys:
value = _json_value(value)
db.add(Config(key=key, value=value, updated_at=now))
existing_keys.add(key)
new_count += 1
@ -275,14 +300,16 @@ class Config(Base):
)
@staticmethod
async def repair_flattened_dict_configs() -> None:
"""Reassemble dict config values flattened by the per-key migration."""
async def repair_config_rows() -> None:
"""Repair known legacy config row shapes."""
if not Config.PERSISTENT_ENABLED:
return
async with get_async_db() as db:
repaired_keys: list[str] = []
orphan_keys: list[str] = []
default_model_keys: list[str] = []
now = int(time.time())
for config_key, aliases in DICT_CONFIG_KEY_ALIASES.items():
prefixes = (config_key, *aliases)
@ -330,14 +357,26 @@ class Config(Base):
if existing:
existing.value = repaired
existing.updated_at = int(time.time())
existing.updated_at = now
else:
db.add(Config(key=config_key, value=repaired, updated_at=int(time.time())))
db.add(Config(key=config_key, value=repaired, updated_at=now))
repaired_keys.append(config_key)
if orphan_keys:
await db.execute(delete(Config).where(Config.key.in_(orphan_keys)))
if repaired_keys or orphan_keys:
for key in ('ui.default_models', 'ui.default_pinned_models'):
row = await db.get(Config, key)
if not row or not isinstance(row.value, list):
continue
row.value = ','.join(model_id for model_id in (str(item).strip() for item in row.value) if model_id)
row.updated_at = now
default_model_keys.append(key)
if repaired_keys or orphan_keys or default_model_keys:
await db.commit()
log.info('Repaired flattened dict config rows for %s', ', '.join(repaired_keys))
if repaired_keys or orphan_keys:
log.info('Repaired flattened dict config rows for %s', ', '.join(repaired_keys))
if default_model_keys:
log.info('Repaired default model config rows for %s', ', '.join(default_model_keys))

View file

@ -165,7 +165,6 @@ class FeedbackTable:
result = Feedback(**feedback.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return FeedbackModel.model_validate(result)
else:

View file

@ -142,7 +142,6 @@ class FilesTable:
result = File(**file.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return FileModel.model_validate(result)
else:

View file

@ -58,6 +58,7 @@ class FolderNameIdResponse(BaseModel):
meta: Optional[FolderMetadataResponse] = None
parent_id: Optional[str] = None
is_expanded: bool = False
unread_count: int = 0
created_at: int
updated_at: int
@ -156,6 +157,11 @@ class FolderTable:
except Exception:
return None
async def get_folders_by_ids(self, ids: list[str], db: AsyncSession | None = None) -> list[FolderModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter(Folder.id.in_(ids)).order_by(Folder.updated_at.desc()))
return [FolderModel.model_validate(folder) for folder in result.scalars().all()]
async def get_shared_folder_ids_for_user(
self, user_id: str, user_group_ids: set[str], db: Optional[AsyncSession] = None
) -> dict[str, str]:
@ -196,10 +202,14 @@ class FolderTable:
try:
async with get_async_db_context(db) as db:
folders = []
seen_ids = {id}
async def get_children(folder):
children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db)
for child in children:
if child.id in seen_ids:
continue
seen_ids.add(child.id)
await get_children(child)
folders.append(child)
@ -229,7 +239,9 @@ class FolderTable:
async with get_async_db_context(db) as db:
# Check if folder exists
result = await db.execute(
select(Folder).filter_by(parent_id=parent_id, user_id=user_id).filter(Folder.name.ilike(name))
select(Folder)
.filter_by(parent_id=parent_id, user_id=user_id)
.filter(func.lower(Folder.name) == func.lower(name))
)
folder = result.scalars().first()
@ -245,7 +257,9 @@ class FolderTable:
self, parent_id: Optional[str], user_id: str, db: Optional[AsyncSession] = None
) -> list[FolderModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(parent_id=parent_id, user_id=user_id))
result = await db.execute(
select(Folder).filter_by(parent_id=parent_id, user_id=user_id).order_by(Folder.updated_at.desc())
)
return [FolderModel.model_validate(folder) for folder in result.scalars().all()]
async def get_folder_ids_by_id_and_user_id_in_subtree(
@ -257,15 +271,17 @@ class FolderTable:
if not folder:
return []
folder_ids = [folder.id]
folder_ids = {folder.id}
folders = [FolderModel.model_validate(folder)]
while folders:
current_folder = folders.pop()
children = await self.get_folders_by_parent_id_and_user_id(current_folder.id, user_id, db=db)
folder_ids.extend(child.id for child in children)
folders.extend(children)
for child in children:
if child.id not in folder_ids:
folder_ids.add(child.id)
folders.append(child)
return folder_ids
return list(folder_ids)
async def update_folder_parent_id_by_id_and_user_id(
self,
@ -375,11 +391,15 @@ class FolderTable:
return folder_ids
folder_ids.append(folder.id)
seen_ids = {folder.id}
# Delete all children folders
async def delete_children(folder):
folder_children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db)
for folder_child in folder_children:
if folder_child.id in seen_ids:
continue
seen_ids.add(folder_child.id)
await delete_children(folder_child)
folder_ids.append(folder_child.id)

View file

@ -7,7 +7,7 @@ import time
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import UserResponse, Users
from open_webui.models.users import User, UserResponse, Users, UserSettings
from open_webui.utils.valves import decrypt_valves, encrypt_valves
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, Index, String, Text, delete, select, update
@ -42,7 +42,7 @@ class FunctionMeta(BaseModel):
class FunctionModel(BaseModel):
id: str
user_id: str
user_id: str | None = None # may be null for legacy/malformed records
name: str
type: str
content: str
@ -58,7 +58,7 @@ class FunctionModel(BaseModel):
# --- form / schema definitions ---
class FunctionWithValvesModel(BaseModel):
id: str
user_id: str
user_id: str | None = None # may be null for legacy/malformed records
name: str
type: str
content: str
@ -79,7 +79,7 @@ class FunctionWithValvesModel(BaseModel):
class FunctionResponse(BaseModel):
id: str
user_id: str
user_id: str | None = None # may be null for legacy/malformed records
type: str
name: str
meta: FunctionMeta
@ -129,7 +129,6 @@ class FunctionsTable:
result = Function(**function.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return FunctionModel.model_validate(result)
else:
@ -275,6 +274,18 @@ class FunctionsTable:
result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True))
return [FunctionModel.model_validate(function) for function in result.scalars().all()]
async def get_active_function_ids_by_type(
self, type: str, db: AsyncSession | None = None
) -> list[tuple[str, bool]]:
"""Return (id, is_global) for active functions without fetching plugin source."""
async with get_async_db_context(db) as db:
result = await db.execute(select(Function.id, Function.is_global).filter_by(type=type, is_active=True))
return [(id, bool(is_global)) for id, is_global in result.all()]
async def get_active_filter_ids(self, db: AsyncSession | None = None) -> list[tuple[str, bool]]:
"""Return (id, is_global) for active filters without fetching plugin source."""
return await self.get_active_function_ids_by_type('filter', db=db)
async def get_global_action_functions(self, db: AsyncSession | None = None) -> list[FunctionModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Function).filter_by(type='action', is_active=True, is_global=True))
@ -283,8 +294,8 @@ class FunctionsTable:
async def get_function_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
async with get_async_db_context(db) as db:
try:
function = await db.get(Function, id)
return decrypt_valves(function.valves if function else None)
result = await db.execute(select(Function.valves).filter_by(id=id))
return decrypt_valves(result.scalar_one_or_none())
except Exception as e:
log.exception(f'Error getting function valves by id {id}: {e}')
return None
@ -300,8 +311,7 @@ class FunctionsTable:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Function.id, Function.valves).filter(Function.id.in_(ids)))
functions = result.all()
return {f.id: decrypt_valves(f.valves) for f in functions}
return {id: decrypt_valves(valves) for id, valves in result.all()}
except Exception as e:
log.exception(f'Error batch-fetching function valves: {e}')
return {}
@ -315,7 +325,6 @@ class FunctionsTable:
function.valves = encrypt_valves(valves)
function.updated_at = int(time.time())
await db.commit()
await db.refresh(function)
return FunctionModel.model_validate(function)
except Exception:
return None
@ -335,7 +344,6 @@ class FunctionsTable:
function.updated_at = int(time.time())
await db.commit()
await db.refresh(function)
return FunctionModel.model_validate(function)
else:
return None
@ -347,8 +355,11 @@ class FunctionsTable:
self, id: str, user_id: str, db: AsyncSession | None = None
) -> dict | None:
try:
user = await Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {}
async with get_async_db_context(db) as db:
result = await db.execute(select(User.settings).filter_by(id=user_id))
settings = result.scalar_one_or_none()
user_settings = UserSettings(**settings).model_dump() if settings else {}
# Check if user has "functions" and "valves" settings
if 'functions' not in user_settings:

View file

@ -1,4 +1,3 @@
import json
import logging
import time
import uuid
@ -13,6 +12,7 @@ from sqlalchemy import (
BigInteger,
Column,
ForeignKey,
Index,
String,
Text,
and_,
@ -72,6 +72,8 @@ class GroupModel(BaseModel):
class GroupMember(Base):
__tablename__ = 'group_member'
# The table's (group_id, user_id) unique constraint cannot serve user_id lookups.
__table_args__ = (Index('ix_group_member_user_id_group_id', 'user_id', 'group_id'),)
id = Column(Text, unique=True, primary_key=True)
group_id = Column(

View file

@ -1,4 +1,3 @@
import json
import logging
import time
import uuid
@ -36,6 +35,9 @@ from sqlalchemy.orm import defer
log = logging.getLogger(__name__)
# Columns the knowledge base list may be ordered by; anything else falls back to the default.
KNOWLEDGE_SORTABLE_FIELDS = {'name', 'created_at', 'updated_at'}
####################
# Knowledge DB Schema
# Let what was gathered here outlast the one who gathered it,
@ -149,6 +151,7 @@ class KnowledgeDirectoryForm(BaseModel):
####################
class KnowledgeUserModel(KnowledgeModel):
user: Optional[UserResponse] = None
file_count: int | None = None
class KnowledgeResponse(KnowledgeModel):
@ -191,11 +194,11 @@ class KnowledgeTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> KnowledgeModel:
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(exclude={'access_grants'})
knowledge_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(knowledge_data['id'], db=db)
knowledge_model = KnowledgeModel.model_validate(knowledge)
knowledge_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(knowledge_model.id, db=db)
)
return KnowledgeModel.model_validate(knowledge_data)
return knowledge_model
async def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm, db: Optional[AsyncSession] = None
@ -308,7 +311,17 @@ class KnowledgeTable:
permission='read',
)
stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
order_by = (filter or {}).get('order_by')
direction = (filter or {}).get('direction')
if order_by in KNOWLEDGE_SORTABLE_FIELDS:
column = getattr(Knowledge, order_by)
if (direction or 'desc').lower() == 'asc':
stmt = stmt.order_by(column.asc(), Knowledge.id.asc())
else:
stmt = stmt.order_by(column.desc(), Knowledge.id.asc())
else:
stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
@ -322,6 +335,14 @@ class KnowledgeTable:
knowledge_ids = [kb.id for kb, _ in items]
grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db)
file_counts = {}
if knowledge_ids:
file_count_result = await db.execute(
select(KnowledgeFile.knowledge_id, func.count(KnowledgeFile.id))
.where(KnowledgeFile.knowledge_id.in_(knowledge_ids))
.group_by(KnowledgeFile.knowledge_id)
)
file_counts = dict(file_count_result.all())
knowledge_bases = []
for knowledge_base, user in items:
@ -336,6 +357,7 @@ class KnowledgeTable:
)
).model_dump(),
'user': (UserModel.model_validate(user).model_dump() if user else None),
'file_count': file_counts.get(knowledge_base.id, 0),
}
)
)
@ -445,14 +467,22 @@ class KnowledgeTable:
print('search_knowledge_files error:', e)
return KnowledgeFileListResponse(items=[], total=0)
async def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[AsyncSession] = None) -> bool:
async def check_access_by_user_id(
self,
id,
user_id,
permission='write',
db: Optional[AsyncSession] = None,
user_group_ids: set[str] | None = None,
) -> bool:
knowledge = await self.get_knowledge_by_id(id, db=db)
if not knowledge:
return False
if knowledge.user_id == user_id:
return True
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
if user_group_ids is None:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
return await AccessGrants.has_access(
user_id=user_id,
resource_type='knowledge',
@ -462,28 +492,6 @@ class KnowledgeTable:
db=db,
)
async def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None
) -> list[KnowledgeUserModel]:
knowledge_bases = await self.get_knowledge_bases(db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
result = []
for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user_id:
result.append(knowledge_base)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type='knowledge',
resource_id=knowledge_base.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
):
result.append(knowledge_base)
return result
async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]:
try:
async with get_async_db_context(db) as db:
@ -493,29 +501,6 @@ class KnowledgeTable:
except Exception:
return None
async def get_knowledge_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[KnowledgeModel]:
knowledge = await self.get_knowledge_by_id(id, db=db)
if not knowledge:
return None
if knowledge.user_id == user_id:
return knowledge
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
if await AccessGrants.has_access(
user_id=user_id,
resource_type='knowledge',
resource_id=knowledge.id,
permission='write',
user_group_ids=user_group_ids,
db=db,
):
return knowledge
return None
async def get_knowledges_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[KnowledgeModel]:
try:
async with get_async_db_context(db) as db:
@ -665,9 +650,25 @@ class KnowledgeTable:
async def get_file_metadatas_by_id(
self, knowledge_id: str, db: Optional[AsyncSession] = None
) -> list[FileMetadataResponse]:
"""Column-only listing: File.data holds each file's full extracted
text, which metadata views must never load."""
try:
files = await self.get_files_by_id(knowledge_id, db=db)
return [FileMetadataResponse(**file.model_dump()) for file in files]
async with get_async_db_context(db) as db:
result = await db.execute(
select(File.id, File.hash, File.meta, File.created_at, File.updated_at)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
)
return [
FileMetadataResponse(
id=row.id,
hash=row.hash,
meta=row.meta,
created_at=row.created_at,
updated_at=row.updated_at,
)
for row in result.all()
]
except Exception:
return []
@ -774,25 +775,6 @@ class KnowledgeTable:
log.exception(e)
return None
async def update_knowledge_data_by_id(
self, id: str, data: dict, db: Optional[AsyncSession] = None
) -> Optional[KnowledgeModel]:
try:
async with get_async_db_context(db) as db:
await db.execute(
update(Knowledge)
.filter_by(id=id)
.values(
data=data,
updated_at=int(time.time()),
)
)
await db.commit()
return await self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
return None
async def update_knowledge_meta_by_id(
self, id: str, meta: dict, db: Optional[AsyncSession] = None
) -> Optional[KnowledgeModel]:

View file

@ -8,7 +8,7 @@ from typing import Literal
from open_webui.internal.db import Base, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import JSON, BigInteger, Column, String, Text, delete, select
from sqlalchemy import JSON, BigInteger, Column, Index, String, Text, delete, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -16,6 +16,7 @@ class Memory(Base): # user memory store
"""Stores user-created memory entries linked to a vector collection."""
__tablename__ = 'memory'
__table_args__ = (Index('ix_memory_id_user_id', 'id', 'user_id'),)
id = Column(String, primary_key=True, unique=True)
user_id = Column(String, index=True)
@ -70,7 +71,6 @@ class MemoriesTable:
)
db.add(record)
await db.commit()
await db.refresh(record)
return MemoryModel.model_validate(record) if record else None
async def update_memory_by_id_and_user_id(
@ -101,7 +101,6 @@ class MemoriesTable:
memory.updated_at = int(time.time())
await db.commit()
await db.refresh(memory)
return MemoryModel.model_validate(memory)
except Exception:
return None

View file

@ -1,4 +1,3 @@
import json
import time
import uuid
from typing import Optional

View file

@ -1,18 +1,18 @@
from __future__ import annotations
import json
import logging
import time
from typing import Optional
from copy import deepcopy
from typing import Any
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse, Users
from open_webui.utils.misc import json_text_variants
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from sqlalchemy import BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@ -22,6 +22,50 @@ log = logging.getLogger(__name__)
_warned_profile_urls: set[str] = set()
def normalize_model_tags(tags: Any) -> list[dict[str, str]]:
if not isinstance(tags, list):
return []
normalized = []
for tag in tags:
name = tag.get('name') if isinstance(tag, dict) else tag
if isinstance(name, str) and name.strip():
normalized.append({'name': name.strip()})
return normalized
def strip_extracted_content_from_model_knowledge(knowledge: Any) -> Any:
"""Drop duplicated extracted text from ModelMeta.knowledge."""
if not isinstance(knowledge, list):
return knowledge
sanitized = []
for item in knowledge:
if not isinstance(item, dict):
sanitized.append(item)
continue
next_item = item
data = item.get('data')
if isinstance(data, dict) and 'content' in data:
next_item = deepcopy(item)
next_item.get('data', {}).pop('content', None)
file = next_item.get('file')
file_data = file.get('data') if isinstance(file, dict) else None
if isinstance(file_data, dict) and 'content' in file_data:
if next_item is item:
next_item = deepcopy(item)
file = next_item.get('file')
file_data = file.get('data') if isinstance(file, dict) else None
file_data.pop('content', None)
sanitized.append(next_item)
return sanitized
# --- Models DB Schema ---
@ -37,6 +81,7 @@ class ModelMeta(BaseModel):
profile_image_url: str | None = None
description: str | None = Field(default=None, description='User-facing description of the model.')
capabilities: dict | None = None
knowledge: list[Any] | None = None
model_config = ConfigDict(extra='allow')
@ -56,19 +101,16 @@ class ModelMeta(BaseModel):
)
return None
@field_validator('knowledge', mode='before')
@classmethod
def strip_knowledge_content(cls, v):
return strip_extracted_content_from_model_knowledge(v)
@model_validator(mode='before')
@classmethod
def normalize_tags(cls, data):
if isinstance(data, dict) and 'tags' in data:
raw_tags = data['tags']
if isinstance(raw_tags, list):
normalized = []
for tag in raw_tags:
if isinstance(tag, str):
normalized.append({'name': tag})
elif isinstance(tag, dict) and 'name' in tag:
normalized.append(tag)
data['tags'] = normalized
data['tags'] = normalize_model_tags(data['tags'])
return data
@ -152,11 +194,19 @@ class ModelsTable:
access_grants: list[AccessGrantModel | None] = None,
db: AsyncSession | None = None,
) -> ModelModel:
model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'})
model_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(model_data['id'], db=db)
if isinstance(model.meta, dict):
knowledge = model.meta.get('knowledge')
stripped_knowledge = strip_extracted_content_from_model_knowledge(knowledge)
if stripped_knowledge != knowledge:
model.meta = {**model.meta, 'knowledge': stripped_knowledge}
if db is not None:
await db.commit()
model_model = ModelModel.model_validate(model)
model_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(model_model.id, db=db)
)
return ModelModel.model_validate(model_data)
return model_model
async def insert_new_model(
self, form_data: ModelForm, user_id: str, db: AsyncSession | None = None
@ -173,7 +223,6 @@ class ModelsTable:
)
db.add(result)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db)
if result:
@ -198,9 +247,21 @@ class ModelsTable:
log.error('Skipping model %r during get_all_models due to error: %s', model.id, exc)
return models
async def get_models(self, db: AsyncSession | None = None) -> list[ModelUserResponse]:
async def get_models(
self, writable_by_user_id: str | None = None, db: AsyncSession | None = None
) -> list[ModelUserResponse]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Model).filter(Model.base_model_id != None))
stmt = select(Model).filter(Model.base_model_id != None)
if writable_by_user_id:
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(writable_by_user_id, db=db)
}
stmt = self._has_permission(
db, stmt, {'user_id': writable_by_user_id, 'group_ids': user_group_ids}, permission='write'
)
result = await db.execute(stmt)
all_models = result.scalars().all()
user_ids = list(set(model.user_id for model in all_models))
@ -229,6 +290,24 @@ class ModelsTable:
)
return models
async def get_model_owners_attaching_file(self, file_id: str, db: AsyncSession | None = None) -> dict[str, str]:
"""Map of model id to owner id for workspace models whose knowledge attaches this file."""
async with get_async_db_context(db) as db:
# File ids are server-generated uuids, so the text match can only over-match.
result = await db.execute(
select(Model.id, Model.user_id, Model.meta).filter(
Model.base_model_id.is_not(None), cast(Model.meta, String).like(f'%"{file_id}"%')
)
)
return {
model_id: user_id
for model_id, user_id, meta in result.all()
if any(
isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file_id
for item in meta.get('knowledge') or []
)
}
@staticmethod
def _meta_has_tag(meta: dict | None, tag: str) -> bool:
if not meta:
@ -255,28 +334,6 @@ class ModelsTable:
for model in all_models
]
async def get_models_by_user_id(
self, user_id: str, permission: str = 'write', db: AsyncSession | None = None
) -> list[ModelUserResponse]:
models = await self.get_models(db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
result = []
for model in models:
if model.user_id == user_id:
result.append(model)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type='model',
resource_id=model.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
):
result.append(model)
return result
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
return AccessGrants.has_permission_filter(
db=db,
@ -328,20 +385,14 @@ class ModelsTable:
tag = filter.get('tag')
if tag:
# SQLite stores JSON text via json.dumps(ensure_ascii=True),
# so non-ASCII chars are \uXXXX-escaped. PostgreSQL native JSONB
# stores literal Unicode. Use the right pattern for each.
if db.bind.dialect.name == 'sqlite':
if tag.isascii():
meta_text = func.lower(cast(Model.meta, String))
pattern = f'%{json.dumps(tag.lower())}%'
else:
meta_text = cast(Model.meta, String)
pattern = f'%{json.dumps(tag)}%'
if db.bind.dialect.name == 'sqlite' and not tag.isascii():
# SQLite's LOWER() is ASCII-only, so match non-ASCII tags exact-case.
meta_text = cast(Model.meta, String)
variants = json_text_variants(tag)
else:
meta_text = func.lower(cast(Model.meta, String))
pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%'
stmt = stmt.filter(meta_text.like(pattern))
variants = json_text_variants(tag.lower())
stmt = stmt.filter(or_(*(meta_text.like(f'%"{variant}"%') for variant in variants)))
order_by = filter.get('order_by')
direction = filter.get('direction')
@ -483,7 +534,6 @@ class ModelsTable:
model.is_active = not model.is_active
model.updated_at = int(time.time())
await db.commit()
await db.refresh(model)
return await self._to_model_model(model, db=db)
except Exception:
@ -510,13 +560,12 @@ class ModelsTable:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Model).filter_by(id=id))
model_obj = result.scalars().first()
if not model_obj:
model = result.scalars().first()
if not model:
return None
model_obj.updated_at = int(time.time())
model.updated_at = int(time.time())
await db.commit()
await db.refresh(model_obj)
return await self._to_model_model(model_obj, db=db)
return await self._to_model_model(model, db=db)
except Exception as e:
log.exception(f'Failed to update the model updated_at by id {id}: {e}')
return None
@ -561,25 +610,16 @@ class ModelsTable:
# Update or insert models
for model in models:
model_data = {
**model.model_dump(exclude={'access_grants'}),
'user_id': user_id,
'updated_at': int(time.time()),
}
if model.id in existing_ids:
await db.execute(
update(Model)
.filter_by(id=model.id)
.values(
**model.model_dump(exclude={'access_grants'}),
user_id=user_id,
updated_at=int(time.time()),
)
)
await db.execute(update(Model).filter_by(id=model.id).values(**model_data))
else:
new_model = Model(
**{
**model.model_dump(exclude={'access_grants'}),
'user_id': user_id,
'updated_at': int(time.time()),
}
)
db.add(new_model)
db.add(Model(**model_data))
await AccessGrants.set_access_grants('model', model.id, model.access_grants, db=db)
# Remove models that are no longer present

View file

@ -1,4 +1,3 @@
import json
import time
import uuid
from functools import lru_cache
@ -8,7 +7,8 @@ from open_webui.internal.db import Base, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse, Users
from pydantic import BaseModel, ConfigDict, Field
from open_webui.utils.json_codec import JSONCodec
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import JSON, BigInteger, Boolean, Column, ForeignKey, Text, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -31,6 +31,32 @@ class Note(Base):
updated_at = Column(BigInteger)
def sanitize_note_data(data: Optional[dict]) -> Optional[dict]:
"""Sanitize malformed note.data so content.md is always markdown text."""
if data is None:
return None
if not isinstance(data, dict):
return {'content': {'md': str(data)}}
content = data.get('content')
if not isinstance(content, dict) or 'md' not in content or isinstance(content.get('md'), str):
return data
md = content.get('md') if content.get('md') is not None else ''
if isinstance(md, (dict, list)):
md = f'```json\n{JSONCodec.dumps(md, indent=2, ensure_ascii=False)}\n```'
else:
md = str(md)
return {
**data,
'content': {
**content,
'md': md,
},
}
class NoteModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
@ -47,6 +73,11 @@ class NoteModel(BaseModel):
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@field_validator('data', mode='before')
@classmethod
def sanitize_data(cls, data):
return sanitize_note_data(data)
class PinnedNote(Base):
__tablename__ = 'pinned_note'
@ -68,6 +99,11 @@ class NoteForm(BaseModel):
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
@field_validator('data', mode='before')
@classmethod
def sanitize_data(cls, data):
return sanitize_note_data(data)
class NoteUpdateForm(BaseModel):
title: Optional[str] = None
@ -75,6 +111,11 @@ class NoteUpdateForm(BaseModel):
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
@field_validator('data', mode='before')
@classmethod
def sanitize_data(cls, data):
return sanitize_note_data(data)
class NoteUserResponse(NoteModel):
user: Optional[UserResponse] = None
@ -106,11 +147,12 @@ class NoteTable:
db: Optional[AsyncSession] = None,
) -> NoteModel:
# We exclude access_grants to inject them
note_data = NoteModel.model_validate(note).model_dump(exclude={'access_grants'})
note_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(note_data['id'], db=db)
note_model = NoteModel.model_validate(note)
note_model.data = note_model.data or {}
note_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(note_model.id, db=db)
)
return NoteModel.model_validate(note_data)
return note_model
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
return AccessGrants.has_permission_filter(
@ -304,13 +346,17 @@ class NoteTable:
return None
form_data = form_data.model_dump(exclude_unset=True)
note.data = sanitize_note_data(note.data) or {}
if 'title' in form_data:
note.title = form_data['title']
if 'data' in form_data:
note.data = {**note.data, **form_data['data']}
note.data = {**(note.data or {}), **(form_data['data'] or {})}
if 'meta' in form_data:
note.meta = {**note.meta, **form_data['meta']}
note.meta = {**(note.meta or {}), **(form_data['meta'] or {})}
if not db.is_modified(note) and 'access_grants' not in form_data:
return await self._to_note_model(note, db=db)
if 'access_grants' in form_data:
await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db)

View file

@ -1,6 +1,5 @@
import base64
import hashlib
import json
import logging
import time
import uuid
@ -9,6 +8,7 @@ from typing import List, Optional
from cryptography.fernet import Fernet
from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
from open_webui.internal.db import Base, get_async_db_context
from open_webui.utils.json_codec import JSONCodec
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Index, String, Text, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -85,7 +85,7 @@ class OAuthSessionTable:
def _encrypt_token(self, token) -> str:
"""Encrypt OAuth tokens for storage"""
try:
token_json = json.dumps(token)
token_json = JSONCodec.dumps(token)
encrypted = self.fernet.encrypt(token_json.encode()).decode()
return encrypted
except Exception as e:
@ -96,7 +96,7 @@ class OAuthSessionTable:
"""Decrypt OAuth tokens from storage"""
try:
decrypted = self.fernet.decrypt(token.encode()).decode()
return json.loads(decrypted)
return JSONCodec.loads(decrypted)
except Exception as e:
log.error(f'Error decrypting tokens: {type(e).__name__}: {e}')
raise
@ -128,7 +128,6 @@ class OAuthSessionTable:
db.add(result)
await db.commit()
await db.refresh(result)
if result:
# Make a copy of the model data before closing session

View file

@ -1,7 +1,6 @@
"""Prompt history model for version tracking."""
import difflib
import json
import time
import uuid
from typing import Optional
@ -70,7 +69,6 @@ class PromptHistoryTable:
)
db.add(history)
await db.commit()
await db.refresh(history)
return PromptHistoryModel.model_validate(history)
async def get_history_by_prompt_id(

View file

@ -2,7 +2,6 @@
from __future__ import annotations
import json
import logging
import time
import uuid
@ -15,6 +14,7 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import PromptHistories
from open_webui.models.users import User, UserModel, UserResponse, Users
from open_webui.utils.misc import json_text_variants
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -103,11 +103,11 @@ class PromptsTable:
access_grants: list[AccessGrantModel | None] = None,
db: AsyncSession | None = None,
) -> PromptModel:
prompt_data = PromptModel.model_validate(prompt).model_dump(exclude={'access_grants'})
prompt_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(prompt_data['id'], db=db)
prompt_model = PromptModel.model_validate(prompt)
prompt_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(prompt_model.id, db=db)
)
return PromptModel.model_validate(prompt_data)
return prompt_model
async def insert_new_prompt(
self, user_id: str, form_data: PromptForm, db: AsyncSession | None = None
@ -132,7 +132,6 @@ class PromptsTable:
)
session.add(record)
await session.commit()
await session.refresh(record) # populate generated defaults
await AccessGrants.set_access_grants(
'prompt',
@ -169,7 +168,6 @@ class PromptsTable:
if history_entry:
record.version_id = history_entry.id
await session.commit()
await session.refresh(record) # re-read version_id
return await self._to_prompt_model(record, db=session)
except Exception as e:
@ -344,9 +342,10 @@ class PromptsTable:
'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)'
)
else:
# Fallback: LIKE on serialised JSON text (ASCII-safe only)
tag_clause = func.lower(cast(Prompt.tags, String)).like(
f'%{json.dumps(tag_lower, ensure_ascii=False)}%'
# Fallback for dialects with no JSON array function: LIKE on the text.
tags_text = func.lower(cast(Prompt.tags, String))
tag_clause = or_(
*(tags_text.like(f'%"{variant}"%') for variant in json_text_variants(tag_lower))
)
tag_lower = None
@ -637,7 +636,6 @@ class PromptsTable:
prompt.is_active = not prompt.is_active
prompt.updated_at = int(time.time())
await session.commit()
await session.refresh(prompt)
return await self._to_prompt_model(prompt, db=session)
return None
except Exception:

View file

@ -113,11 +113,11 @@ class SkillsTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> SkillModel:
skill_data = SkillModel.model_validate(skill).model_dump(exclude={'access_grants'})
skill_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(skill_data['id'], db=db)
skill_model = SkillModel.model_validate(skill)
skill_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(skill_model.id, db=db)
)
return SkillModel.model_validate(skill_data)
return skill_model
async def insert_new_skill(
self,
@ -137,7 +137,6 @@ class SkillsTable:
)
db.add(result)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db)
if result:
return await self._to_skill_model(result, db=db)
@ -164,9 +163,30 @@ class SkillsTable:
except Exception:
return None
async def get_skills(self, db: Optional[AsyncSession] = None) -> list[SkillUserModel]:
async def get_skills(
self,
user_id: str | None = None,
ids: list[str] | None = None,
db: AsyncSession | None = None,
) -> list[SkillUserModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Skill).order_by(Skill.updated_at.desc()))
stmt = select(Skill).order_by(Skill.updated_at.desc())
if ids is not None:
stmt = stmt.filter(Skill.id.in_(ids))
if user_id is not None:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
stmt = AccessGrants.has_permission_filter(
db=db,
query=stmt,
DocumentModel=Skill,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='skill',
permission='read',
)
result = await db.execute(stmt)
all_skills = result.scalars().all()
user_ids = list(set(skill.user_id for skill in all_skills))
@ -195,28 +215,6 @@ class SkillsTable:
)
return skills
async def get_skills_by_user_id(
self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None
) -> list[SkillUserModel]:
skills = await self.get_skills(db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
result = []
for skill in skills:
if skill.user_id == user_id:
result.append(skill)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type='skill',
resource_id=skill.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
):
result.append(skill)
return result
async def search_skills(
self,
user_id: str,
@ -259,7 +257,26 @@ class SkillsTable:
permission='read',
)
stmt = stmt.order_by(Skill.updated_at.desc())
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by == 'name':
if direction == 'asc':
stmt = stmt.order_by(Skill.name.asc())
else:
stmt = stmt.order_by(Skill.name.desc())
elif order_by == 'created_at':
if direction == 'asc':
stmt = stmt.order_by(Skill.created_at.asc())
else:
stmt = stmt.order_by(Skill.created_at.desc())
elif order_by == 'updated_at':
if direction == 'asc':
stmt = stmt.order_by(Skill.updated_at.asc())
else:
stmt = stmt.order_by(Skill.updated_at.desc())
else:
stmt = stmt.order_by(Skill.updated_at.desc())
# Count BEFORE pagination
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
@ -307,8 +324,8 @@ class SkillsTable:
if access_grants is not None:
await AccessGrants.set_access_grants('skill', id, access_grants, db=db)
skill = await db.get(Skill, id)
await db.refresh(skill)
# populate_existing: the Core update above bypasses any identity-map copy
skill = await db.get(Skill, id, populate_existing=True)
return await self._to_skill_model(skill, db=db)
except Exception:
return None
@ -324,7 +341,6 @@ class SkillsTable:
skill.is_active = not skill.is_active
skill.updated_at = int(time.time())
await db.commit()
await db.refresh(skill)
return await self._to_skill_model(skill, db=db)
except Exception:

View file

@ -63,7 +63,6 @@ class TagTable:
record = Tag(id=tag_id, user_id=user_id, name=name)
db.add(record)
await db.commit()
await db.refresh(record)
return TagModel.model_validate(record) if record else None
except Exception as e:
log.exception('Error inserting tag %r: %s', name, e)
@ -98,7 +97,7 @@ class TagTable:
async with get_async_db_context(db) as db:
id = name.replace(' ', '_').lower()
result = await db.execute(delete(Tag).filter_by(id=id, user_id=user_id))
log.debug(f'res: {result.rowcount}')
log.debug('res: %s', result.rowcount)
await db.commit()
return True
except Exception as e:

View file

@ -41,9 +41,10 @@ class ToolMeta(BaseModel):
class ToolModel(BaseModel):
id: str
user_id: str
user_id: str | None = None # may be null for legacy/malformed records
name: str
content: str
# None when listed with defer_content=True (source skipped for listings)
content: str | None = None
specs: list[dict]
meta: ToolMeta
access_grants: list[AccessGrantModel] = Field(default_factory=list)
@ -65,7 +66,7 @@ class ToolUserModel(ToolModel):
class ToolResponse(BaseModel):
id: str
user_id: str
user_id: str | None = None # may be null for legacy/malformed records
name: str
meta: ToolMeta
access_grants: list[AccessGrantModel] = Field(default_factory=list)
@ -105,11 +106,11 @@ class ToolsTable:
access_grants: list[AccessGrantModel | None] = None,
db: AsyncSession | None = None,
) -> ToolModel:
tool_data = ToolModel.model_validate(tool).model_dump(exclude={'access_grants'})
tool_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(tool_data['id'], db=db)
tool_model = ToolModel.model_validate(tool)
tool_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(tool_model.id, db=db)
)
return ToolModel.model_validate(tool_data)
return tool_model
async def insert_new_tool(
self,
@ -131,7 +132,6 @@ class ToolsTable:
)
db.add(result)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('tool', result.id, form_data.access_grants, db=db)
if result:
return await self._to_tool_model(result, db=db)
@ -169,13 +169,37 @@ class ToolsTable:
for tool in tools
}
async def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> list[ToolUserModel]:
async def get_tools(
self,
defer_content: bool = False,
db: AsyncSession | None = None,
user_id: str | None = None,
user_group_ids: set[str] | None = None,
permission: str = 'read',
) -> list[ToolUserModel]:
async with get_async_db_context(db) as db:
stmt = select(Tool).order_by(Tool.updated_at.desc())
if defer_content:
stmt = stmt
# Skip Tool.content (plugin source, potentially large) via a
# column select; Row attributes satisfy from_attributes.
stmt = (
select(Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at)
if defer_content
else select(Tool)
).order_by(Tool.updated_at.desc())
if user_id is not None:
if user_group_ids is None:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
stmt = AccessGrants.has_permission_filter(
db=db,
query=stmt,
DocumentModel=Tool,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='tool',
permission=permission,
)
result = await db.execute(stmt)
all_tools = result.scalars().all()
all_tools = result.all() if defer_content else result.scalars().all()
user_ids = list(set(tool.user_id for tool in all_tools))
tool_ids = [tool.id for tool in all_tools]
@ -210,24 +234,15 @@ class ToolsTable:
defer_content: bool = False,
db: AsyncSession | None = None,
) -> list[ToolUserModel]:
tools = await self.get_tools(defer_content=defer_content, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
result = []
for tool in tools:
if tool.user_id == user_id:
result.append(tool)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type='tool',
resource_id=tool.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
):
result.append(tool)
return result
return await self.get_tools(
defer_content=defer_content,
db=db,
user_id=user_id,
user_group_ids=user_group_ids,
permission=permission,
)
async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
try:
@ -301,8 +316,8 @@ class ToolsTable:
if access_grants is not None:
await AccessGrants.set_access_grants('tool', id, access_grants, db=db)
tool = await db.get(Tool, id)
await db.refresh(tool)
# populate_existing: the Core update above bypasses any identity-map copy
tool = await db.get(Tool, id, populate_existing=True)
return await self._to_tool_model(tool, db=db)
except Exception:
return None

View file

@ -9,7 +9,7 @@ from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.utils.misc import throttle
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from sqlalchemy import (
JSON,
BigInteger,
@ -27,7 +27,6 @@ from sqlalchemy import (
select,
update,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
####################
@ -69,6 +68,7 @@ class User(Base): # identity & profile
# Metadata
info = Column(JSON, nullable=True)
variables = Column(JSON, nullable=True)
settings = Column(JSON, nullable=True)
oauth = Column(JSON, nullable=True)
scim = Column(JSON, nullable=True)
@ -105,6 +105,7 @@ class UserModel(BaseModel):
status_expires_at: int | None = None
info: dict | None = None
variables: dict = Field(default_factory=dict, exclude=True)
settings: UserSettings | None = None
oauth: dict | None = None
@ -126,6 +127,11 @@ class UserModel(BaseModel):
self.profile_image_url = self.profile_image_url or _DEFAULT_PROFILE_IMAGE_URL.format(user_id=self.id)
return self
@field_validator('variables', mode='before')
@classmethod
def normalize_variables(cls, value):
return value if isinstance(value, dict) else {}
class UserStatusModel(UserModel):
is_active: bool = False
@ -302,7 +308,6 @@ class UsersTable:
result = User(**user.model_dump())
session.add(result)
await session.commit()
await session.refresh(result)
return user if result else None
# database read methods
@ -354,16 +359,15 @@ class UsersTable:
sub: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Look up a user by OAuth provider + subject claim (dialect-aware JSON filter)."""
"""Look up a user by OAuth provider + subject claim."""
sub = str(sub)
async with get_async_db_context(db) as session:
dialect = session.bind.dialect.name
query = select(User)
if dialect == 'sqlite':
oauth_match = User.oauth.contains({provider: {'sub': sub}})
query = query.where(oauth_match)
elif dialect == 'postgresql':
oauth_match = User.oauth[provider].cast(JSONB)['sub'].astext == sub
query = query.where(oauth_match)
# Subscript, never contains(): on a JSON column contains() degrades to a substring LIKE.
sub_expr = User.oauth[provider]['sub'].as_string()
query = select(User).where(sub_expr == sub)
# SQLite preserves JSON numeric type here; Postgres ->> already compares numeric JSON as text.
if session.get_bind().dialect.name == 'sqlite' and sub.isdecimal():
query = select(User).where(or_(sub_expr == sub, sub_expr == int(sub)))
row = (await session.execute(query)).scalars().first()
return UserModel.model_validate(row) if row else None
@ -373,27 +377,76 @@ class UsersTable:
external_id: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Look up a user by SCIM provider + external ID (dialect-aware JSON filter)."""
"""Look up a user by SCIM provider + external ID."""
async with get_async_db_context(db) as session:
dialect = session.bind.dialect.name
query = select(User)
if dialect == 'sqlite':
scim_match = User.scim.contains({provider: {'external_id': external_id}})
query = query.where(scim_match)
elif dialect == 'postgresql':
scim_match = User.scim[provider].cast(JSONB)['external_id'].astext == external_id
query = query.where(scim_match)
# Subscript, never contains(): on a JSON column contains() degrades to a substring LIKE.
query = select(User).where(User.scim[provider]['external_id'].as_string() == external_id)
row = (await session.execute(query)).scalars().first()
return UserModel.model_validate(row) if row else None
async def get_users(
async def get_scim_users(
self,
filter: dict | None = None,
sort: dict | None = None,
skip: int | None = None,
limit: int | None = None,
db: AsyncSession | None = None,
) -> dict:
"""Paginated user listing with optional filters for role, group, and channel."""
async with get_async_db_context(db) as session:
stmt = select(User).where(or_(User.oauth.cast(String) != 'null', User.scim.cast(String) != 'null'))
if filter:
user_id = filter.get('id')
if user_id:
stmt = stmt.where(User.id == user_id)
email = filter.get('email')
if email:
stmt = stmt.where(func.lower(User.email) == email.lower())
order_by = sort.get('order_by') if sort else None
direction = sort.get('direction') if sort else None
if order_by == 'created_at':
stmt = stmt.order_by(User.created_at.asc() if direction == 'asc' else User.created_at.desc())
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip is not None:
stmt = stmt.offset(skip)
if limit is not None:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
users = result.scalars().all()
return {
'users': [UserModel.model_validate(user) for user in users],
'total': total,
}
async def get_scim_user_by_id(
self,
id: str,
db: AsyncSession | None = None,
) -> UserModel | None:
async with get_async_db_context(db) as session:
stmt = select(User).where(
User.id == id,
or_(User.oauth.cast(String) != 'null', User.scim.cast(String) != 'null'),
)
user = (await session.execute(stmt)).scalars().first()
return UserModel.model_validate(user) if user else None
async def get_users(
self,
filter: dict | None = None,
sort: dict | None = None,
skip: int | None = None,
limit: int | None = None,
db: AsyncSession | None = None,
) -> dict:
"""Paginated user listing with optional filters and sort."""
async with get_async_db_context(db) as session:
# Deferred imports to avoid circular dependencies
from open_webui.models.channels import ChannelMember
@ -454,64 +507,63 @@ class UsersTable:
if exclude_roles:
stmt = stmt.filter(~User.role.in_(exclude_roles))
order_by = filter.get('order_by')
direction = filter.get('direction')
order_by = sort.get('order_by') if sort else None
direction = sort.get('direction') if sort else None
if order_by and order_by.startswith('group_id:'):
group_id = order_by.split(':', 1)[1]
if order_by and order_by.startswith('group_id:'):
group_id = order_by.split(':', 1)[1]
# Subquery that checks if the user belongs to the group
membership_exists = exists(
select(GroupMember.id).where(
GroupMember.user_id == User.id,
GroupMember.group_id == group_id,
)
# Subquery that checks if the user belongs to the group
membership_exists = exists(
select(GroupMember.id).where(
GroupMember.user_id == User.id,
GroupMember.group_id == group_id,
)
)
# CASE: user in group → 1, user not in group → 0
group_sort = case((membership_exists, 1), else_=0)
# CASE: user in group → 1, user not in group → 0
group_sort = case((membership_exists, 1), else_=0)
if direction == 'asc':
stmt = stmt.order_by(group_sort.asc(), User.name.asc())
else:
stmt = stmt.order_by(group_sort.desc(), User.name.asc())
if direction == 'asc':
stmt = stmt.order_by(group_sort.asc(), User.name.asc())
else:
stmt = stmt.order_by(group_sort.desc(), User.name.asc())
elif order_by == 'name':
if direction == 'asc':
stmt = stmt.order_by(User.name.asc())
else:
stmt = stmt.order_by(User.name.desc())
elif order_by == 'name':
if direction == 'asc':
stmt = stmt.order_by(User.name.asc())
else:
stmt = stmt.order_by(User.name.desc())
elif order_by == 'email':
if direction == 'asc':
stmt = stmt.order_by(User.email.asc())
else:
stmt = stmt.order_by(User.email.desc())
elif order_by == 'email':
if direction == 'asc':
stmt = stmt.order_by(User.email.asc())
else:
stmt = stmt.order_by(User.email.desc())
elif order_by == 'created_at':
if direction == 'asc':
stmt = stmt.order_by(User.created_at.asc())
else:
stmt = stmt.order_by(User.created_at.desc())
elif order_by == 'created_at':
if direction == 'asc':
stmt = stmt.order_by(User.created_at.asc())
else:
stmt = stmt.order_by(User.created_at.desc())
elif order_by == 'last_active_at':
if direction == 'asc':
stmt = stmt.order_by(User.last_active_at.asc())
else:
stmt = stmt.order_by(User.last_active_at.desc())
elif order_by == 'last_active_at':
if direction == 'asc':
stmt = stmt.order_by(User.last_active_at.asc())
else:
stmt = stmt.order_by(User.last_active_at.desc())
elif order_by == 'updated_at':
if direction == 'asc':
stmt = stmt.order_by(User.updated_at.asc())
else:
stmt = stmt.order_by(User.updated_at.desc())
elif order_by == 'role':
if direction == 'asc':
stmt = stmt.order_by(User.role.asc())
else:
stmt = stmt.order_by(User.role.desc())
else:
elif order_by == 'updated_at':
if direction == 'asc':
stmt = stmt.order_by(User.updated_at.asc())
else:
stmt = stmt.order_by(User.updated_at.desc())
elif order_by == 'role':
if direction == 'asc':
stmt = stmt.order_by(User.role.asc())
else:
stmt = stmt.order_by(User.role.desc())
elif not filter:
stmt = stmt.order_by(User.created_at.desc())
# Count BEFORE pagination
@ -566,13 +618,6 @@ class UsersTable:
row = (await session.execute(stmt)).scalars().first()
return UserModel.model_validate(row) if row else None
async def get_user_webhook_url_by_id(self, id: str, db: AsyncSession | None = None) -> str | None:
async with get_async_db_context(db) as session:
user = await session.get(User, id)
if user and user.settings:
return user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None)
return None
async def get_num_users_active_today(self, db: AsyncSession | None = None) -> int | None:
async with get_async_db_context(db) as session:
current_timestamp = int(time.time())
@ -589,7 +634,6 @@ class UsersTable:
return None
user.role = role
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_status_by_id(
@ -602,7 +646,6 @@ class UsersTable:
for key, value in form_data.model_dump(exclude_none=True).items():
setattr(user, key, value)
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_profile_image_url_by_id(
@ -622,7 +665,6 @@ class UsersTable:
return None
user.profile_image_url = profile_image_url
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
@ -640,17 +682,19 @@ class UsersTable:
if not user:
return None
oauth = dict(user.oauth or {})
oauth[provider] = {'sub': sub}
provider_oauth = oauth.get(provider)
provider_oauth = dict(provider_oauth) if isinstance(provider_oauth, dict) else {}
provider_oauth['sub'] = str(sub)
oauth[provider] = provider_oauth
user.oauth = oauth
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_scim_by_id(
self,
id: str,
provider: str,
external_id: str,
external_id: str | None,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Update or insert a SCIM provider/external_id pair into the user's scim JSON field."""
@ -662,7 +706,6 @@ class UsersTable:
scim[provider] = {'external_id': external_id}
user.scim = scim
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_by_id(self, id: str, updated: dict, db: AsyncSession | None = None) -> UserModel | None:
@ -673,7 +716,6 @@ class UsersTable:
for key, value in updated.items():
setattr(user, key, value)
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
# settings update helper
@ -688,7 +730,6 @@ class UsersTable:
user_settings.update(updated)
user.settings = user_settings
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def delete_user_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
@ -735,8 +776,8 @@ class UsersTable:
async def get_valid_user_ids(self, user_ids: list[str], db: AsyncSession | None = None) -> list[str]:
async with get_async_db_context(db) as session:
result = await session.execute(select(User).where(User.id.in_(user_ids)))
return [u.id for u in result.scalars().all()]
result = await session.execute(select(User.id).where(User.id.in_(user_ids)))
return list(result.scalars().all())
async def get_super_admin_user(self, db: AsyncSession | None = None) -> UserModel | None:
async with get_async_db_context(db) as session:
@ -762,11 +803,11 @@ class UsersTable:
async def is_user_active(self, user_id: str, db: AsyncSession | None = None) -> bool:
async with get_async_db_context(db) as session:
user = await session.get(User, user_id)
if user and user.last_active_at:
last_active_at = await session.scalar(select(User.last_active_at).where(User.id == user_id))
if last_active_at:
# Consider user active if last_active_at within the last 3 minutes
three_minutes_ago = int(time.time()) - 180
return user.last_active_at >= three_minutes_ago
return last_active_at >= three_minutes_ago
return False

View file

@ -4,6 +4,7 @@ import re
import time
from typing import Any, Optional
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
from open_webui.models.config import Config
from open_webui.models.knowledge import KnowledgeModel
@ -103,7 +104,7 @@ async def _retrieve_qdrant(connection, auth_config, knowledge, query, count, emb
source_config = _source_config(knowledge)
vector_field = source_config.get('vector_field') or None
vector = await embedding_function(query)
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
def _search():
client = QdrantClient(
@ -152,7 +153,7 @@ async def _retrieve_milvus(connection, auth_config, knowledge, query, count, emb
content_field = source_config.get('content_field') or 'data.text'
metadata_field = source_config.get('metadata_field') or 'metadata'
vector = await embedding_function(query)
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
def _search():
client_kwargs = {
@ -229,7 +230,7 @@ async def _retrieve_pgvector(connection, auth_config, knowledge, query, count, e
metadata_field = source_config.get('metadata_field') or 'vmetadata'
document_id_field = source_config.get('document_id_field') or 'id'
vector = await embedding_function(query)
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
def _search():
from psycopg import sql

View file

@ -7,6 +7,7 @@ from typing import List, Optional
import requests
from fastapi import HTTPException, status
from langchain_core.documents import Document
from open_webui.utils.json_codec import JSONCodec
log = logging.getLogger(__name__)
@ -64,25 +65,6 @@ class DatalabMarkerLoader:
}
return mime_map.get(ext, 'application/octet-stream')
def check_marker_request_status(self, request_id: str) -> dict:
url = f'{self.api_base_url}/{request_id}'
headers = {'X-Api-Key': self.api_key}
try:
response = requests.get(url, headers=headers)
response.raise_for_status()
result = response.json()
log.info(f'Marker API status check for request {request_id}: {result}')
return result
except requests.HTTPError as e:
log.error(f'Error checking Marker request status: {e}')
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail=f'Failed to check Marker request: {e}',
)
except ValueError as e:
log.error(f'Invalid JSON checking Marker request: {e}')
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail=f'Invalid JSON: {e}')
def load(self) -> List[Document]:
filename = os.path.basename(self.file_path)
mime_type = self._get_mime_type(filename)
@ -103,7 +85,10 @@ class DatalabMarkerLoader:
form_data['additional_config'] = self.additional_config
log.info(
f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}"
"Datalab Marker POST request parameters: {'filename': '%s', 'mime_type': '%s', **%s}",
filename,
mime_type,
form_data,
)
try:
@ -167,7 +152,7 @@ class DatalabMarkerLoader:
'total_cost',
)
}
log.info(f'Marker processing completed successfully: {json.dumps(summary, indent=2)}')
log.info('Marker processing completed successfully: %s', json.dumps(summary, indent=2))
break
if status_val == 'failed' or success_val is False:
@ -234,7 +219,7 @@ class DatalabMarkerLoader:
try:
with open(output_path, 'w', encoding='utf-8') as f:
f.write(full_text)
log.info(f'Saved Marker output to: {output_path}')
log.info('Saved Marker output to: %s', output_path)
except Exception as e:
log.warning(f'Failed to write marker output to disk: {e}')
@ -249,11 +234,11 @@ class DatalabMarkerLoader:
images = final_result.get('images', {})
if images:
metadata['image_count'] = len(images)
metadata['images'] = json.dumps(list(images.keys()))
metadata['images'] = JSONCodec.dumps(list(images.keys()))
for k, v in metadata.items():
if isinstance(v, (dict, list)):
metadata[k] = json.dumps(v)
metadata[k] = JSONCodec.dumps(v)
elif v is None:
metadata[k] = ''

View file

@ -6,7 +6,7 @@ from urllib.parse import quote
import requests
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
from open_webui.utils.headers import include_user_info_headers, parse_custom_headers
log = logging.getLogger(__name__)
@ -19,6 +19,7 @@ class ExternalDocumentLoader(BaseLoader):
api_key: str,
mime_type=None,
user=None,
user_groups=None,
headers=None,
metadata=None,
**kwargs,
@ -30,6 +31,7 @@ class ExternalDocumentLoader(BaseLoader):
self.mime_type = mime_type
self.user = user
self.user_groups = user_groups
self.headers = headers
self.metadata = metadata
@ -49,7 +51,7 @@ class ExternalDocumentLoader(BaseLoader):
except Exception:
pass
headers.update(get_custom_headers(self.headers, self.user, self.metadata))
headers.update(parse_custom_headers(self.headers, self.user, self.metadata, user_groups=self.user_groups))
if self.user is not None:
headers = include_user_info_headers(headers, self.user)

View file

@ -30,6 +30,9 @@ class ExternalWebLoader(BaseLoader):
response = requests.post(
self.external_url,
headers={
# LICENSE covers this Open WebUI user-agent identifier.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) External Web Loader',
'Authorization': f'Bearer {self.external_api_key}',
},

View file

@ -1,7 +1,9 @@
import asyncio
import json
import csv
import logging
import os
import sys
import zipfile
import ftfy
import requests
@ -11,10 +13,8 @@ from langchain_community.document_loaders import (
BSHTMLLoader,
CSVLoader,
Docx2txtLoader,
OutlookMessageLoader,
PyPDFLoader,
TextLoader,
YoutubeLoader,
)
from langchain_core.documents import Document
from open_webui.env import (
@ -27,7 +27,9 @@ from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
from open_webui.retrieval.loaders.external_document import ExternalDocumentLoader
from open_webui.retrieval.loaders.mineru import MinerULoader
from open_webui.retrieval.loaders.mistral import MistralLoader
from open_webui.retrieval.loaders.paddleocr_vl import PaddleOCRVLLoader
from open_webui.retrieval.loaders.paddleocr_vl import PADDLEOCR_VL_SUPPORTED_EXTENSIONS, PaddleOCRVLLoader
from open_webui.utils.headers import get_user_groups_for_custom_headers
from open_webui.utils.json_codec import JSONCodec
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@ -88,6 +90,15 @@ known_source_ext = [
'toml',
]
known_archive_ext = {'docx', 'epub', 'odt', 'pptx', 'xlsx'}
known_archive_content_types = {
'application/epub+zip',
'application/vnd.oasis.opendocument.text',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}
class ExcelLoader:
"""Fallback Excel loader using pandas when unstructured is not installed."""
@ -111,6 +122,52 @@ class ExcelLoader:
]
def get_csv_summary(filename: str, file_path: str, encoding: str) -> str | None:
try:
with open(file_path, newline='', encoding=encoding) as f:
sample = f.read(4096)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample)
except csv.Error:
dialect = csv.excel
total_rows = 0
max_columns = 0
headers = []
for row in csv.reader(f, dialect):
total_rows += 1
max_columns = max(max_columns, len(row))
if total_rows == 1:
headers = [header.lstrip('\ufeff') for header in row]
except Exception:
return None
if total_rows == 0:
return None
return (
f'Table: {total_rows} rows incl. header; '
f'{max(total_rows - 1, 0)} data rows; '
f'{max_columns} columns: {", ".join(headers)}.'
)
class CSVLoaderWithSummary:
def __init__(self, file_path: str, filename: str, encoding: str):
self.file_path = file_path
self.filename = filename
self.encoding = encoding
def load(self) -> list[Document]:
docs = CSVLoader(self.file_path, encoding=self.encoding).load()
if os.getenv('ENABLE_RAG_CSV_SUMMARY', 'False').lower() == 'true':
summary = get_csv_summary(self.filename, self.file_path, self.encoding)
if summary:
docs.insert(0, Document(page_content=summary, metadata={'source': self.file_path, 'row': -1}))
return docs
class PptxLoader:
"""Fallback PowerPoint loader using python-pptx when unstructured is not installed."""
@ -138,10 +195,11 @@ class PptxLoader:
class TikaLoader:
def __init__(self, url, file_path, mime_type=None, extract_images=None):
def __init__(self, url, file_path, mime_type=None, extract_images=None, server_version='3'):
self.url = url
self.file_path = file_path
self.mime_type = mime_type
self.server_version = str(server_version or '3')
self.extract_images = extract_images
@ -157,16 +215,15 @@ class TikaLoader:
if self.extract_images == True:
headers['X-Tika-PDFextractInlineImages'] = 'true'
endpoint = self.url
if not endpoint.endswith('/'):
endpoint += '/'
endpoint += 'tika/text'
endpoint_path = 'tika/json/text' if self.server_version == '4' else 'tika/text'
content_key = 'tk:content' if self.server_version == '4' else 'X-TIKA:content'
endpoint = f'{self.url.rstrip("/")}/{endpoint_path}'
r = requests.put(endpoint, data=data, headers=headers, verify=REQUESTS_VERIFY)
if r.ok:
raw_metadata = r.json()
text = raw_metadata.get('X-TIKA:content', '<No text content found>').strip()
text = raw_metadata.get(content_key, '<No text content found>').strip()
if 'Content-Type' in raw_metadata:
headers['Content-Type'] = raw_metadata['Content-Type']
@ -206,6 +263,9 @@ class DoclingLoader:
data={
'image_export_mode': 'placeholder',
'md_page_break_placeholder': page_break_marker,
# Keep Docling params as user-provided form values. Encoding nested
# values here would make Open WebUI responsible for Docling's API
# quirks and could break when Docling changes its form contract.
**self.params,
},
headers=headers,
@ -246,6 +306,7 @@ class Loader:
def __init__(self, engine: str = '', **kwargs):
self.engine = engine
self.user = kwargs.get('user', None)
self.user_groups = kwargs.get('user_groups', None)
self.metadata = kwargs.get('metadata', {})
self.kwargs = kwargs
@ -264,6 +325,13 @@ class Loader:
loop for the entire parse minutes for large PDFs. This offloads
the work to a worker thread so the loop stays responsive.
"""
# Group lookup is async-only, so it must happen before `load`
# is offloaded to a thread without a running event loop.
if self.engine == 'external' and self.user_groups is None:
self.user_groups = await get_user_groups_for_custom_headers(
self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'), self.user
)
return await asyncio.to_thread(self.load, filename, file_content_type, file_path)
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
@ -303,13 +371,20 @@ class Loader:
try:
raw.decode('utf-8')
return 'utf-8'
except UnicodeDecodeError:
pass
except UnicodeDecodeError as e:
first_non_utf8 = e.start
# Use chardet as a hint, not as ground truth
import chardet
detected = chardet.detect(raw)
# chardet is pure Python (~1.3s/MB), so sample around the first bad byte
window = 256 * 1024
sample_start = max(0, first_non_utf8 - window // 2)
sample = raw[sample_start : sample_start + window]
detected = chardet.detect(sample)
# A stray byte can sit far from the real payload, leaving the sample with nothing to read
if len(sample.translate(None, delete=bytes(range(128)))) < 64 and len(sample) < len(raw):
detected = chardet.detect(raw)
detected_enc = (detected.get('encoding') or '').lower().replace('-', '').replace('_', '')
# Map chardet's detected encoding to the correct superset codec.
@ -411,6 +486,27 @@ class Loader:
def _get_loader(self, filename: str, file_content_type: str, file_path: str):
file_ext = filename.split('.')[-1].lower()
if file_ext in known_archive_ext or file_content_type in known_archive_content_types:
max_file_size = self.kwargs.get('FILE_MAX_SIZE')
try:
max_file_size_bytes = int(max_file_size) * 1024 * 1024 if max_file_size else 100 * 1024 * 1024
except (TypeError, ValueError):
max_file_size_bytes = 100 * 1024 * 1024
if max_file_size_bytes > 0:
try:
with zipfile.ZipFile(file_path) as archive:
uncompressed_size = sum(entry.file_size for entry in archive.infolist())
except (zipfile.BadZipFile, OSError):
pass
else:
max_bytes = min(
max(10 * 1024 * 1024, os.path.getsize(file_path) * 100),
max_file_size_bytes,
)
if uncompressed_size > max_bytes:
raise ValueError('Document archive is too large after decompression')
if (
self.engine == 'external'
and self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_URL')
@ -422,6 +518,7 @@ class Loader:
api_key=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_API_KEY'),
mime_type=file_content_type,
user=self.user,
user_groups=self.user_groups,
headers=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'),
metadata={
**self.metadata,
@ -436,6 +533,7 @@ class Loader:
loader = TikaLoader(
url=self.kwargs.get('TIKA_SERVER_URL'),
file_path=file_path,
server_version=self.kwargs.get('TIKA_SERVER_VERSION'),
extract_images=self.kwargs.get('PDF_EXTRACT_IMAGES'),
)
elif (
@ -489,8 +587,8 @@ class Loader:
params = self.kwargs.get('DOCLING_PARAMS', {})
if not isinstance(params, dict):
try:
params = json.loads(params)
except json.JSONDecodeError:
params = JSONCodec.loads(params)
except JSONCodec.JSONDecodeError:
log.error('Invalid DOCLING_PARAMS format, expected JSON object')
params = {}
@ -554,8 +652,14 @@ class Loader:
api_key=self.kwargs.get('MISTRAL_OCR_API_KEY'),
file_path=file_path,
use_base64=self.kwargs.get('MISTRAL_OCR_USE_BASE64', False),
user=self.user,
)
elif self.engine == 'paddleocr_vl' and self.kwargs.get('PADDLEOCR_VL_TOKEN') != '':
elif (
self.engine == 'paddleocr_vl'
and self.kwargs.get('PADDLEOCR_VL_BASE_URL')
and self.kwargs.get('PADDLEOCR_VL_TOKEN')
and file_ext in PADDLEOCR_VL_SUPPORTED_EXTENSIONS
):
loader = PaddleOCRVLLoader(
api_url=self.kwargs.get('PADDLEOCR_VL_BASE_URL'),
token=self.kwargs.get('PADDLEOCR_VL_TOKEN'),
@ -569,7 +673,11 @@ class Loader:
mode=self.kwargs.get('PDF_LOADER_MODE', 'page'),
)
elif file_ext == 'csv':
loader = CSVLoader(file_path, encoding=self._detect_text_encoding(file_path))
loader = CSVLoaderWithSummary(
file_path,
filename,
self._detect_text_encoding(file_path),
)
elif file_ext == 'rst':
try:
from langchain_community.document_loaders import UnstructuredRSTLoader
@ -654,7 +762,18 @@ class Loader:
)
loader = PptxLoader(file_path)
elif file_ext == 'msg':
loader = OutlookMessageLoader(file_path)
try:
from langchain_community.document_loaders import (
UnstructuredEmailLoader,
)
# unstructured parses .msg via python-oxmsg; avoids extract_msg's beautifulsoup4<4.14 conflict
loader = UnstructuredEmailLoader(file_path, process_attachments=False)
except ImportError:
raise ValueError(
"Processing .msg files requires the 'unstructured' package. "
'Install it with: pip install unstructured'
)
elif file_ext == 'odt':
try:
from langchain_community.document_loaders import UnstructuredODTLoader

View file

@ -10,7 +10,6 @@ from langchain_core.documents import Document
log = logging.getLogger(__name__)
DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL = 'https://api.microsoft.ai/v3'
MICROSOFT_BROWSE_RETRY_STATUS_CODES = {202, 429, 500, 502, 503, 504}
MICROSOFT_BROWSE_MAX_RETRIES = 2
@ -27,7 +26,7 @@ class MicrosoftWebIQLoader(BaseLoader):
continue_on_failure: bool = True,
) -> None:
self.urls = urls if isinstance(urls, list) else [urls]
self.api_base_url = (api_base_url or DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL).rstrip('/')
self.api_base_url = api_base_url.rstrip('/')
self.api_key = api_key
self.language = language
self.verify_ssl = verify_ssl

View file

@ -74,7 +74,7 @@ class MinerULoader:
Load document using Local API (synchronous).
Posts file to /file_parse endpoint and gets immediate response.
"""
log.info(f'Using MinerU Local API at {self.api_url}')
log.info('Using MinerU Local API at %s', self.api_url)
filename = os.path.basename(self.file_path)
@ -97,8 +97,8 @@ class MinerULoader:
with open(self.file_path, 'rb') as f:
files = {'files': (filename, f, 'application/octet-stream')}
log.info(f'Sending file to MinerU Local API: {filename}')
log.debug(f'Local API parameters: {form_data}')
log.info('Sending file to MinerU Local API: %s', filename)
log.debug('Local API parameters: %s', form_data)
response = requests.post(
f'{self.api_url}/file_parse',
@ -163,7 +163,7 @@ class MinerULoader:
detail='MinerU returned empty markdown content',
)
log.info(f'Successfully parsed document with MinerU Local API: {filename}')
log.info('Successfully parsed document with MinerU Local API: %s', filename)
# Create metadata
metadata = {
@ -180,7 +180,7 @@ class MinerULoader:
Load document using Cloud API (asynchronous).
Uses batch upload endpoint to avoid need for public file URLs.
"""
log.info(f'Using MinerU Cloud API at {self.api_url}')
log.info('Using MinerU Cloud API at %s', self.api_url)
filename = os.path.basename(self.file_path)
@ -196,7 +196,7 @@ class MinerULoader:
# Step 4: Download and extract markdown from ZIP
markdown_content = self._download_and_extract_zip(result['full_zip_url'], filename)
log.info(f'Successfully parsed document with MinerU Cloud API: {filename}')
log.info('Successfully parsed document with MinerU Cloud API: %s', filename)
# Create metadata
metadata = {
@ -232,8 +232,8 @@ class MinerULoader:
if self.page_ranges:
request_body['files'][0]['page_ranges'] = self.page_ranges
log.info(f'Requesting upload URL for: {filename}')
log.debug(f'Cloud API request body: {request_body}')
log.info('Requesting upload URL for: %s', filename)
log.debug('Cloud API request body: %s', request_body)
try:
response = requests.post(
@ -284,7 +284,7 @@ class MinerULoader:
)
upload_url = file_urls[0]
log.info(f'Received upload URL for batch: {batch_id}')
log.info('Received upload URL for batch: %s', batch_id)
return batch_id, upload_url
@ -334,7 +334,7 @@ class MinerULoader:
max_iterations = 300 # 10 minutes max (2 seconds per iteration)
poll_interval = 2 # seconds
log.info(f'Polling batch status: {batch_id}')
log.info('Polling batch status: %s', batch_id)
for iteration in range(max_iterations):
try:
@ -393,7 +393,7 @@ class MinerULoader:
state = file_result.get('state')
if state == 'done':
log.info(f'Processing complete for {filename}')
log.info('Processing complete for %s', filename)
return file_result
elif state == 'failed':
error_msg = file_result.get('err_msg', 'Unknown error')
@ -404,7 +404,7 @@ class MinerULoader:
elif state in ['waiting-file', 'pending', 'running', 'converting']:
# Still processing
if iteration % 10 == 0: # Log every 20 seconds
log.info(f'Processing status: {state} (iteration {iteration + 1}/{max_iterations})')
log.info('Processing status: %s (iteration %s/%s)', state, iteration + 1, max_iterations)
time.sleep(poll_interval)
else:
log.warning(f'Unknown state: {state}')
@ -421,7 +421,7 @@ class MinerULoader:
Download ZIP file from CDN and extract markdown content.
Returns the markdown content as a string.
"""
log.info(f'Downloading results from: {zip_url}')
log.info('Downloading results from: %s', zip_url)
try:
response = requests.get(zip_url, timeout=60)
@ -452,7 +452,7 @@ class MinerULoader:
read_errors = []
for member in md_members:
log.info(f'Found markdown file in ZIP: {member.filename}')
log.info('Found markdown file in ZIP: %s', member.filename)
try:
with zip_ref.open(member, 'r') as f:
if self.max_markdown_bytes is None:
@ -515,5 +515,5 @@ class MinerULoader:
detail='Extracted markdown content is empty',
)
log.info(f'Successfully extracted markdown content ({len(markdown_content)} characters)')
log.info('Successfully extracted markdown content (%s characters)', len(markdown_content))
return markdown_content

View file

@ -1,16 +1,14 @@
import asyncio
import base64
import logging
import os
import sys
import time
from contextlib import asynccontextmanager
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
import aiohttp
import requests
from langchain_core.documents import Document
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, GLOBAL_LOG_LEVEL
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, GLOBAL_LOG_LEVEL
from open_webui.utils.headers import include_user_info_headers
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@ -18,15 +16,12 @@ log = logging.getLogger(__name__)
class MistralLoader:
"""
Enhanced Mistral OCR loader with both sync and async support.
Enhanced Mistral OCR loader.
Loads documents by processing them through the Mistral OCR API.
Performance Optimizations:
- Differentiated timeouts for different operations
- Intelligent retry logic with exponential backoff
- Memory-efficient file streaming for large files
- Connection pooling and keepalive optimization
- Semaphore-based concurrency control for batch processing
- Enhanced error handling with retryable error classification
"""
@ -39,6 +34,7 @@ class MistralLoader:
max_retries: int = 3,
enable_debug_logging: bool = False,
use_base64: bool = False,
user: Optional[Any] = None,
):
"""
Initializes the loader with enhanced features.
@ -50,6 +46,8 @@ class MistralLoader:
max_retries: Maximum number of retry attempts.
enable_debug_logging: Enable detailed debug logs.
use_base64: Send the document as a data URL instead of uploading it first.
user: The requesting user, forwarded to Mistral via user-info headers
when ENABLE_FORWARD_USER_INFO_HEADERS is enabled.
"""
if not api_key:
raise ValueError('API key cannot be empty.')
@ -59,10 +57,10 @@ class MistralLoader:
self.base_url = base_url.rstrip('/') if base_url else 'https://api.mistral.ai/v1'
self.api_key = api_key
self.file_path = file_path
self.timeout = timeout
self.max_retries = max_retries
self.debug = enable_debug_logging
self.use_base64 = use_base64
self.user = user
# PERFORMANCE OPTIMIZATION: Differentiated timeouts for different operations
# This prevents long-running OCR operations from affecting quick operations
@ -82,6 +80,8 @@ class MistralLoader:
'Authorization': f'Bearer {self.api_key}',
'User-Agent': 'OpenWebUI-MistralLoader/2.0', # Helps API provider track usage
}
if self.user is not None and ENABLE_FORWARD_USER_INFO_HEADERS:
self.headers = include_user_info_headers(self.headers, self.user)
def _debug_log(self, message: str, *args) -> None:
"""
@ -111,32 +111,6 @@ class MistralLoader:
log.error(f'JSON decode error: {json_err} - Response: {response.text}')
raise # Re-raise after logging
async def _handle_response_async(self, response: aiohttp.ClientResponse) -> Dict[str, Any]:
"""Async version of response handling with better error info."""
try:
response.raise_for_status()
# Check content type
content_type = response.headers.get('content-type', '')
if 'application/json' not in content_type:
if response.status == 204:
return {}
text = await response.text()
raise ValueError(f'Unexpected content type: {content_type}, body: {text[:200]}...')
return await response.json()
except aiohttp.ClientResponseError as e:
error_text = await response.text() if response else 'No response'
log.error(f'HTTP {e.status}: {e.message} - Response: {error_text[:500]}')
raise
except aiohttp.ClientError as e:
log.error(f'Client error: {e}')
raise
except Exception as e:
log.error(f'Unexpected error processing response: {e}')
raise
def _is_retryable_error(self, error: Exception) -> bool:
"""
ENHANCEMENT: Intelligent error classification for retry logic.
@ -166,10 +140,6 @@ class MistralLoader:
status_code = error.response.status_code
return status_code >= 500 or status_code == 429
return False
if isinstance(error, (aiohttp.ClientConnectionError, aiohttp.ServerTimeoutError)):
return True # Async network/timeout errors are retryable
if isinstance(error, aiohttp.ClientResponseError):
return error.status >= 500 or error.status == 429
return False # All other errors are non-retryable
def _retry_request_sync(self, request_func, *args, **kwargs):
@ -196,32 +166,11 @@ class MistralLoader:
)
time.sleep(wait_time)
async def _retry_request_async(self, request_func, *args, **kwargs):
"""
ENHANCEMENT: Async retry logic with intelligent error classification.
Async version of retry logic that doesn't block the event loop during
wait periods. Uses the same exponential backoff strategy as sync version.
"""
for attempt in range(self.max_retries):
try:
return await request_func(*args, **kwargs)
except Exception as e:
if attempt == self.max_retries - 1 or not self._is_retryable_error(e):
raise
# PERFORMANCE OPTIMIZATION: Non-blocking exponential backoff
wait_time = min((2**attempt) + 0.5, 30) # Cap at 30 seconds
log.warning(
f'Retryable error (attempt {attempt + 1}/{self.max_retries}): {e}. Retrying in {wait_time}s...'
)
await asyncio.sleep(wait_time) # Non-blocking wait
def _upload_file(self) -> str:
"""
PERFORMANCE OPTIMIZATION: Enhanced file upload with streaming consideration.
Uploads the file to Mistral for OCR processing (sync version).
Uploads the file to Mistral for OCR processing.
Uses context manager for file handling to ensure proper resource cleanup.
Although streaming is not enabled for this endpoint, the file is opened
in a context manager to minimize memory usage duration.
@ -254,56 +203,15 @@ class MistralLoader:
file_id = response_data.get('id')
if not file_id:
raise ValueError('File ID not found in upload response.')
log.info(f'File uploaded successfully. File ID: {file_id}')
log.info('File uploaded successfully. File ID: %s', file_id)
return file_id
except Exception as e:
log.error(f'Failed to upload file: {e}')
raise
async def _upload_file_async(self, session: aiohttp.ClientSession) -> str:
"""Async file upload with streaming for better memory efficiency."""
url = f'{self.base_url}/files'
async def upload_request():
# Open inside the request so the handle stays valid for the whole
# streamed POST and is closed right after.
with open(self.file_path, 'rb') as f:
writer = aiohttp.MultipartWriter('form-data')
# Add purpose field
purpose_part = writer.append('ocr')
purpose_part.set_content_disposition('form-data', name='purpose')
# Stream the file. aiohttp builds a payload from the file object;
# the previous aiohttp.streams.FilePayload was removed upstream
# (payloads live in aiohttp.payload and there is no FilePayload),
# so this path raised AttributeError on every async OCR upload.
file_part = writer.append(f, {'Content-Type': 'application/pdf'})
file_part.set_content_disposition('form-data', name='file', filename=self.file_name)
self._debug_log(f'Uploading file: {self.file_name} ({self.file_size:,} bytes)')
async with session.post(
url,
data=writer,
headers=self.headers,
timeout=aiohttp.ClientTimeout(total=self.upload_timeout),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await self._handle_response_async(response)
response_data = await self._retry_request_async(upload_request)
file_id = response_data.get('id')
if not file_id:
raise ValueError('File ID not found in upload response.')
log.info(f'File uploaded successfully. File ID: {file_id}')
return file_id
def _get_signed_url(self, file_id: str) -> str:
"""Retrieves a temporary signed URL for the uploaded file (sync version)."""
log.info(f'Getting signed URL for file ID: {file_id}')
"""Retrieves a temporary signed URL for the uploaded file."""
log.info('Getting signed URL for file ID: %s', file_id)
url = f'{self.base_url}/files/{file_id}/url'
params = {'expiry': 1}
signed_url_headers = {**self.headers, 'Accept': 'application/json'}
@ -323,35 +231,8 @@ class MistralLoader:
log.error(f'Failed to get signed URL: {e}')
raise
async def _get_signed_url_async(self, session: aiohttp.ClientSession, file_id: str) -> str:
"""Async signed URL retrieval."""
url = f'{self.base_url}/files/{file_id}/url'
params = {'expiry': 1}
headers = {**self.headers, 'Accept': 'application/json'}
async def url_request():
self._debug_log(f'Getting signed URL for file ID: {file_id}')
async with session.get(
url,
headers=headers,
params=params,
timeout=aiohttp.ClientTimeout(total=self.url_timeout),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await self._handle_response_async(response)
response_data = await self._retry_request_async(url_request)
signed_url = response_data.get('url')
if not signed_url:
raise ValueError('Signed URL not found in response.')
self._debug_log('Signed URL received successfully')
return signed_url
def _process_ocr(self, signed_url: str) -> Dict[str, Any]:
"""Sends the signed URL to the OCR endpoint for processing (sync version)."""
"""Sends the signed URL to the OCR endpoint for processing."""
log.info('Processing OCR via Mistral API')
url = f'{self.base_url}/ocr'
ocr_headers = {
@ -381,113 +262,24 @@ class MistralLoader:
log.error(f'Failed during OCR processing: {e}')
raise
async def _process_ocr_async(self, session: aiohttp.ClientSession, signed_url: str) -> Dict[str, Any]:
"""Async OCR processing with timing metrics."""
url = f'{self.base_url}/ocr'
headers = {
**self.headers,
'Content-Type': 'application/json',
'Accept': 'application/json',
}
payload = {
'model': 'mistral-ocr-latest',
'document': {
'type': 'document_url',
'document_url': signed_url,
},
'include_image_base64': False,
}
async def ocr_request():
log.info('Starting OCR processing via Mistral API')
start_time = time.time()
async with session.post(
url,
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=self.ocr_timeout),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
ocr_response = await self._handle_response_async(response)
processing_time = time.time() - start_time
log.info(f'OCR processing completed in {processing_time:.2f}s')
return ocr_response
return await self._retry_request_async(ocr_request)
def _get_file_data_url(self) -> str:
with open(self.file_path, 'rb') as f:
encoded_file = base64.b64encode(f.read()).decode('utf-8')
return f'data:application/pdf;base64,{encoded_file}'
def _delete_file(self, file_id: str) -> None:
"""Deletes the file from Mistral storage (sync version)."""
log.info(f'Deleting uploaded file ID: {file_id}')
"""Deletes the file from Mistral storage."""
log.info('Deleting uploaded file ID: %s', file_id)
url = f'{self.base_url}/files/{file_id}'
try:
response = requests.delete(url, headers=self.headers, timeout=self.cleanup_timeout)
delete_response = self._handle_response(response)
log.info(f'File deleted successfully: {delete_response}')
log.info('File deleted successfully: %s', delete_response)
except Exception as e:
# Log error but don't necessarily halt execution if deletion fails
log.error(f'Failed to delete file ID {file_id}: {e}')
async def _delete_file_async(self, session: aiohttp.ClientSession, file_id: str) -> None:
"""Async file deletion with error tolerance."""
try:
async def delete_request():
self._debug_log(f'Deleting file ID: {file_id}')
async with session.delete(
url=f'{self.base_url}/files/{file_id}',
headers=self.headers,
timeout=aiohttp.ClientTimeout(total=self.cleanup_timeout),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await self._handle_response_async(response)
await self._retry_request_async(delete_request)
self._debug_log(f'File {file_id} deleted successfully')
except Exception as e:
# Don't fail the entire process if cleanup fails
log.warning(f'Failed to delete file ID {file_id}: {e}')
@asynccontextmanager
async def _get_session(self):
"""Context manager for HTTP session with optimized settings."""
connector = aiohttp.TCPConnector(
limit=20, # Increased total connection limit for better throughput
limit_per_host=10, # Increased per-host limit for API endpoints
ttl_dns_cache=600, # Longer DNS cache TTL (10 minutes)
use_dns_cache=True,
keepalive_timeout=60, # Increased keepalive for connection reuse
enable_cleanup_closed=True,
force_close=False, # Allow connection reuse
resolver=aiohttp.AsyncResolver(), # Use async DNS resolver
)
timeout = aiohttp.ClientTimeout(
total=self.timeout,
connect=30, # Connection timeout
sock_read=60, # Socket read timeout
)
async with aiohttp.ClientSession(
connector=connector,
timeout=timeout,
headers={'User-Agent': 'OpenWebUI-MistralLoader/2.0'},
raise_for_status=False, # We handle status codes manually
trust_env=True,
) as session:
yield session
def _process_results(self, ocr_response: Dict[str, Any]) -> List[Document]:
"""Process OCR results into Document objects with enhanced metadata and memory efficiency."""
pages_data = ocr_response.get('pages')
@ -512,7 +304,7 @@ class MistralLoader:
if page_content is None or page_index is None:
skipped_pages += 1
self._debug_log(
f"Skipping page due to missing 'markdown' or 'index'. Data keys: {list(page_data.keys())}"
"Skipping page due to missing 'markdown' or 'index'. Data keys: %s", list(page_data.keys())
)
continue
@ -524,7 +316,7 @@ class MistralLoader:
if not cleaned_content:
skipped_pages += 1
self._debug_log(f'Skipping empty page {page_index}')
self._debug_log('Skipping empty page %s', page_index)
continue
# Create document with optimized metadata
@ -544,7 +336,7 @@ class MistralLoader:
)
if skipped_pages > 0:
log.info(f'Processed {len(documents)} pages, skipped {skipped_pages} empty/invalid pages')
log.info('Processed %s pages, skipped %s empty/invalid pages', len(documents), skipped_pages)
if not documents:
# Case where pages existed but none had valid markdown/index
@ -565,7 +357,6 @@ class MistralLoader:
def load(self) -> List[Document]:
"""
Executes the full OCR workflow: upload, get URL, process OCR, delete file.
Synchronous version for backward compatibility.
Returns:
A list of Document objects, one for each page processed.
@ -577,7 +368,7 @@ class MistralLoader:
if self.use_base64:
documents = self._process_results(self._process_ocr(self._get_file_data_url()))
total_time = time.time() - start_time
log.info(f'Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents')
log.info('Sync OCR workflow completed in %.2fs, produced %s documents', total_time, len(documents))
return documents
# 1. Upload file
@ -593,7 +384,7 @@ class MistralLoader:
documents = self._process_results(ocr_response)
total_time = time.time() - start_time
log.info(f'Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents')
log.info('Sync OCR workflow completed in %.2fs, produced %s documents', total_time, len(documents))
return documents
@ -618,125 +409,3 @@ class MistralLoader:
except Exception as del_e:
# Log deletion error, but don't overwrite original error if one occurred
log.error(f'Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}')
async def load_async(self) -> List[Document]:
"""
Asynchronous OCR workflow execution with optimized performance.
Returns:
A list of Document objects, one for each page processed.
"""
file_id = None
start_time = time.time()
try:
async with self._get_session() as session:
if self.use_base64:
ocr_response = await self._process_ocr_async(session, self._get_file_data_url())
documents = self._process_results(ocr_response)
total_time = time.time() - start_time
log.info(f'Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents')
return documents
# 1. Upload file with streaming
file_id = await self._upload_file_async(session)
# 2. Get signed URL
signed_url = await self._get_signed_url_async(session, file_id)
# 3. Process OCR
ocr_response = await self._process_ocr_async(session, signed_url)
# 4. Process results
documents = self._process_results(ocr_response)
total_time = time.time() - start_time
log.info(f'Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents')
return documents
except Exception as e:
total_time = time.time() - start_time
log.error(f'Async OCR workflow failed after {total_time:.2f}s: {e}')
return [
Document(
page_content=f'Error during OCR processing: {e}',
metadata={
'error': 'processing_failed',
'file_name': self.file_name,
},
)
]
finally:
# 5. Cleanup - always attempt file deletion
if file_id:
try:
async with self._get_session() as session:
await self._delete_file_async(session, file_id)
except Exception as cleanup_error:
log.error(f'Cleanup failed for file ID {file_id}: {cleanup_error}')
@staticmethod
async def load_multiple_async(
loaders: List['MistralLoader'],
max_concurrent: int = 5, # Limit concurrent requests
) -> List[List[Document]]:
"""
Process multiple files concurrently with controlled concurrency.
Args:
loaders: List of MistralLoader instances
max_concurrent: Maximum number of concurrent requests
Returns:
List of document lists, one for each loader
"""
if not loaders:
return []
log.info(f'Starting concurrent processing of {len(loaders)} files with max {max_concurrent} concurrent')
start_time = time.time()
# Use semaphore to control concurrency
semaphore = asyncio.Semaphore(max_concurrent)
async def process_with_semaphore(loader: 'MistralLoader') -> List[Document]:
async with semaphore:
return await loader.load_async()
# Process all files with controlled concurrency
tasks = [process_with_semaphore(loader) for loader in loaders]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Handle any exceptions in results
processed_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
log.error(f'File {i} failed: {result}')
processed_results.append(
[
Document(
page_content=f'Error processing file: {result}',
metadata={
'error': 'batch_processing_failed',
'file_index': i,
},
)
]
)
else:
processed_results.append(result)
# MONITORING: Log comprehensive batch processing statistics
total_time = time.time() - start_time
total_docs = sum(len(docs) for docs in processed_results)
success_count = sum(1 for result in results if not isinstance(result, Exception))
failure_count = len(results) - success_count
log.info(
f'Batch processing completed in {total_time:.2f}s: '
f'{success_count} files succeeded, {failure_count} files failed, '
f'produced {total_docs} total documents'
)
return processed_results

View file

@ -11,6 +11,9 @@ from open_webui.env import GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
PADDLEOCR_VL_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp']
PADDLEOCR_VL_SUPPORTED_EXTENSIONS = ['pdf'] + PADDLEOCR_VL_IMAGE_EXTENSIONS
class PaddleOCRVLLoader:
"""Loader that uses PaddleOCR-vl API to extract text from PDF/images."""
@ -32,7 +35,7 @@ class PaddleOCRVLLoader:
self.file_name = os.path.basename(file_path)
def load(self) -> List[Document]:
log.info(f'Processing with PaddleOCR-vl: {self.file_path}')
log.info('Processing with PaddleOCR-vl: %s', self.file_path)
try:
with open(self.file_path, 'rb') as file:
@ -46,8 +49,7 @@ class PaddleOCRVLLoader:
# Detect fileType based on file extension
ext = self.file_path.lower().split('.')[-1]
image_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp']
file_type = 1 if ext in image_extensions else 0
file_type = 1 if ext in PADDLEOCR_VL_IMAGE_EXTENSIONS else 0
payload = {
'file': file_data,
@ -94,7 +96,7 @@ class PaddleOCRVLLoader:
)
if skipped_pages > 0:
log.info(f'PaddleOCR-vl: Processed {len(documents)} pages, skipped {skipped_pages} empty pages.')
log.info('PaddleOCR-vl: Processed %s pages, skipped %s empty pages.', len(documents), skipped_pages)
if not documents:
log.warning('No valid text content found by PaddleOCR-vl.')

View file

@ -4,6 +4,7 @@ from typing import Iterator, List, Literal, Union
import requests
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.env import TAVILY_API_BASE_URL
log = logging.getLogger(__name__)
@ -48,7 +49,7 @@ class TavilyLoader(BaseLoader):
self.urls = urls if isinstance(urls, list) else [urls]
self.extract_depth = extract_depth
self.continue_on_failure = continue_on_failure
self.api_url = 'https://api.tavily.com/extract'
self.api_url = f'{TAVILY_API_BASE_URL}/extract'
def lazy_load(self) -> Iterator[Document]:
"""Extract and yield documents from the URLs using Tavily Extract API."""

View file

@ -18,6 +18,32 @@ ALLOWED_NETLOCS = {
}
class YoutubeTranscriptError(Exception):
"""A YouTube transcript could not be retrieved."""
def _transcript_error_message(error: Exception, video_id: str) -> str:
name = type(error).__name__
if name in {'RequestBlocked', 'IpBlocked'}:
return (
f'YouTube blocked the transcript request for {video_id} from this server. '
'This usually means the server address is rate limited or belongs to a cloud '
'provider. A proxy for these requests can be configured under Admin Settings, '
'Web Search, Youtube Proxy URL.'
)
if name == 'TranscriptsDisabled':
return f'Transcripts are disabled for the YouTube video {video_id}.'
if name == 'AgeRestricted':
return f'The YouTube video {video_id} is age restricted, so its transcript cannot be retrieved.'
if name in {'VideoUnavailable', 'VideoUnplayable', 'InvalidVideoId'}:
return f'The YouTube video {video_id} is unavailable.'
if name == 'PoTokenRequired':
return f'YouTube requires additional verification to return the transcript for {video_id}.'
return f'Could not retrieve a transcript for the YouTube video {video_id}.'
def _parse_video_id(url: str) -> Optional[str]:
"""Parse a YouTube URL and return the video ID if valid, otherwise None."""
parsed_url = urlparse(url)
@ -90,7 +116,7 @@ class YoutubeLoader:
if self.proxy_url:
youtube_proxies = GenericProxyConfig(http_url=self.proxy_url, https_url=self.proxy_url)
log.debug(f'Using proxy URL: {self.proxy_url[:14]}...')
log.debug('Using proxy URL: %s...', self.proxy_url[:14])
else:
youtube_proxies = None
@ -98,31 +124,31 @@ class YoutubeLoader:
try:
transcript_list = transcript_api.list(self.video_id)
except Exception as e:
log.warning(f'Loading YouTube transcript failed: {e}')
return []
log.warning('Loading YouTube transcript failed: %s', e)
raise YoutubeTranscriptError(_transcript_error_message(e, self.video_id)) from e
# Try each language in order of priority
for lang in self.language:
try:
transcript = transcript_list.find_transcript([lang])
if transcript.is_generated:
log.debug(f"Found generated transcript for language '{lang}'")
log.debug("Found generated transcript for language '%s'", lang)
try:
transcript = transcript_list.find_manually_created_transcript([lang])
log.debug(f"Found manual transcript for language '{lang}'")
log.debug("Found manual transcript for language '%s'", lang)
except NoTranscriptFound:
log.debug(f"No manual transcript found for language '{lang}', using generated")
log.debug("No manual transcript found for language '%s', using generated", lang)
pass
log.debug(f"Found transcript for language '{lang}'")
log.debug("Found transcript for language '%s'", lang)
try:
transcript_pieces: List[Dict[str, Any]] = transcript.fetch()
except ParseError:
log.debug(f"Empty or invalid transcript for language '{lang}'")
log.debug("Empty or invalid transcript for language '%s'", lang)
continue
if not transcript_pieces:
log.debug(f"Empty transcript for language '{lang}'")
log.debug("Empty transcript for language '%s'", lang)
continue
transcript_text = ' '.join(
@ -135,18 +161,20 @@ class YoutubeLoader:
)
return [Document(page_content=transcript_text, metadata=self._metadata)]
except NoTranscriptFound:
log.debug(f"No transcript found for language '{lang}'")
log.debug("No transcript found for language '%s'", lang)
continue
except Exception as e:
log.info(f"Error finding transcript for language '{lang}'")
raise e
log.info("Error finding transcript for language '%s'", lang)
raise YoutubeTranscriptError(_transcript_error_message(e, self.video_id)) from e
# If we get here, all languages failed
languages_tried = ', '.join(self.language)
log.warning(
f'No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed.'
)
raise NoTranscriptFound(self.video_id, self.language, list(transcript_list))
raise YoutubeTranscriptError(
f'No transcript found for the YouTube video {self.video_id} in these languages: {languages_tried}.'
)
async def aload(self) -> Generator[Document, None, None]:
"""Asynchronously load YouTube transcripts into `Document` objects."""

View file

@ -12,7 +12,7 @@ log = logging.getLogger(__name__)
class ColBERT(BaseReranker):
def __init__(self, name, **kwargs) -> None:
log.info('ColBERT: Loading model', name)
log.info('ColBERT: Loading model %s', name)
self.device = 'cuda' if torch.cuda.is_available() else 'cpu'
DOCKER = kwargs.get('env') == 'docker'

View file

@ -35,8 +35,8 @@ class ExternalReranker(BaseReranker):
}
try:
log.info(f'ExternalReranker:predict:model {self.model}')
log.info(f'ExternalReranker:predict:query {query}')
log.info('ExternalReranker:predict:model %s', self.model)
log.info('ExternalReranker:predict:query %s', query)
headers = {
'Content-Type': 'application/json',

View file

@ -6,7 +6,6 @@ import logging
import os
import re
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Awaitable, Optional, Union
from urllib.parse import quote
@ -25,6 +24,7 @@ from open_webui.config import (
RAG_EMBEDDING_QUERY_PREFIX,
VECTOR_DB,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import (
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
AIOHTTP_CLIENT_SESSION_SSL,
@ -37,6 +37,7 @@ from open_webui.env import (
from open_webui.models.access_grants import AccessGrants
from open_webui.models.chats import Chats
from open_webui.models.files import Files
from open_webui.models.folders import Folders
from open_webui.models.knowledge import Knowledges
from open_webui.models.notes import Notes
from open_webui.models.config import Config
@ -47,9 +48,10 @@ from open_webui.retrieval.external import retrieve_external_knowledge
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.main import GetResult, SearchResult
from open_webui.retrieval.web.utils import get_web_loader
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.misc import get_message_list
from open_webui.utils.access_control.files import get_owner_accessible_folder_files, has_access_to_file
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.headers import get_json_bearer_headers, include_user_info_headers
from open_webui.utils.misc import get_content_from_message, get_message_list
log = logging.getLogger(__name__)
@ -66,11 +68,26 @@ def is_youtube_url(url: str) -> bool:
LOADER_CONFIG_KEYS = {
'file_max_size': 'rag.file.max_size',
'youtube_language': 'rag.youtube_loader_language',
'youtube_proxy_url': 'rag.youtube_loader_proxy_url',
'web_loader_ssl_verification': 'web.loader.ssl_verification',
'web_loader_concurrent_requests': 'web.loader.concurrent_requests',
'web_search_trust_env': 'web.search.trust_env',
'web_loader_engine': 'web.loader.engine',
'web_loader_timeout': 'web.loader.timeout',
'playwright_ws_url': 'web.loader.playwright_ws_url',
'playwright_timeout': 'web.loader.playwright_timeout',
'firecrawl_api_key': 'web.loader.firecrawl_api_key',
'firecrawl_api_url': 'web.loader.firecrawl_api_url',
'firecrawl_timeout': 'web.loader.firecrawl_timeout',
'tavily_api_key': 'web.search.tavily_api_key',
'tavily_extract_depth': 'web.search.tavily_extract_depth',
'microsoft_web_iq_api_base_url': 'web.search.microsoft_web_iq_api_base_url',
'microsoft_web_iq_api_key': 'web.search.microsoft_web_iq_api_key',
'microsoft_web_iq_language': 'web.search.microsoft_web_iq_language',
'external_web_loader_url': 'web.loader.external_web_loader_url',
'external_web_loader_api_key': 'web.loader.external_web_loader_api_key',
'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine',
'DATALAB_MARKER_API_KEY': 'rag.datalab_marker_api_key',
'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url',
@ -87,6 +104,7 @@ LOADER_CONFIG_KEYS = {
'EXTERNAL_DOCUMENT_LOADER_API_KEY': 'rag.external_document_loader_api_key',
'EXTERNAL_DOCUMENT_LOADER_HEADERS': 'rag.external_document_loader_headers',
'TIKA_SERVER_URL': 'rag.tika_server_url',
'TIKA_SERVER_VERSION': 'rag.tika_server_version',
'DOCLING_SERVER_URL': 'rag.docling_server_url',
'DOCLING_API_KEY': 'rag.docling_api_key',
'DOCLING_PARAMS': 'rag.docling_params',
@ -126,6 +144,7 @@ def get_loader(request, url: str, config: dict):
verify_ssl=config.get('web_loader_ssl_verification'),
requests_per_second=config.get('web_loader_concurrent_requests'),
trust_env=config.get('web_search_trust_env'),
loader_config=config,
)
@ -134,6 +153,7 @@ def build_loader_from_config(request, config: dict):
from open_webui.retrieval.loaders.main import Loader
loader_config = {key: config.get(key) for key in LOADER_CONFIG_KEYS if key.isupper()}
loader_config['FILE_MAX_SIZE'] = config.get('file_max_size')
return Loader(
engine=loader_config['CONTENT_EXTRACTION_ENGINE'],
**{key: value for key, value in loader_config.items() if key != 'CONTENT_EXTRACTION_ENGINE'},
@ -166,11 +186,20 @@ def _extract_text_from_binary_response(
suffix = '.' + filename.split('.')[-1].lower() if '.' in filename else ''
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(response.content)
tmp_path = tmp.name
max_size = loader_config.get('file_max_size')
max_bytes = int(max_size) * 1024 * 1024 if max_size else 0
tmp_fd, tmp_path = tempfile.mkstemp(suffix=suffix)
try:
downloaded = 0
# Stream to disk; response.content buffers the whole body in memory first.
with os.fdopen(tmp_fd, 'wb') as tmp:
for chunk in response.iter_content(64 * 1024):
downloaded += len(chunk)
if max_bytes and downloaded > max_bytes:
raise ValueError(ERROR_MESSAGES.FILE_TOO_LARGE(size=f'{max_size} MB'))
tmp.write(chunk)
loader = build_loader_from_config(request, loader_config)
docs = loader.load(filename, content_type, tmp_path)
for doc in docs:
@ -181,14 +210,24 @@ def _extract_text_from_binary_response(
os.remove(tmp_path)
TEXT_APPLICATION_CONTENT_TYPES = {
'application/javascript',
'application/json',
'application/xml',
'application/x-javascript',
}
def _is_text_content_type(content_type: str) -> bool:
"""Return True if the content type should be handled by the web loader."""
ct = content_type.split(';')[0].strip().lower()
if not ct:
return True
if ct.startswith('text/'):
return True
if any(t in ct for t in ['xml', 'json', 'javascript']):
if ct in TEXT_APPLICATION_CONTENT_TYPES:
return True
return not ct # empty / missing → assume HTML
return ct.endswith(('+xml', '+json'))
async def get_content_from_url(request, url: str) -> str:
@ -201,7 +240,7 @@ async def get_content_from_url(request, url: str) -> str:
def _get_content_from_url_sync(request, url: str, loader_config):
from open_webui.retrieval.web.utils import validate_url, _SSRFSafeAdapter
from open_webui.retrieval.web.utils import validate_url, get_ssrf_safe_requests_session
# Validate URL before making any request (blocks private IPs, non-HTTP, filter list)
validate_url(url)
@ -225,9 +264,7 @@ def _get_content_from_url_sync(request, url: str, loader_config):
# cloud-metadata 169.254.169.254) via a public host that redirects internally.
try:
# Probe through the connect-time SSRF guard; bare requests.get re-resolves (DNS-rebinding gap).
session = requests.Session()
session.mount('http://', _SSRFSafeAdapter())
session.mount('https://', _SSRFSafeAdapter())
session = get_ssrf_safe_requests_session()
response = session.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS)
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')
@ -294,7 +331,7 @@ class VectorSearchRetriever(BaseRetriever):
def query_doc(collection_name: str, query_embedding: list[float], k: int, user: UserModel = None):
try:
log.debug(f'query_doc:doc {collection_name}')
log.debug('query_doc:doc %s', collection_name)
result = VECTOR_DB_CLIENT.search(
collection_name=collection_name,
vectors=[query_embedding],
@ -302,7 +339,7 @@ def query_doc(collection_name: str, query_embedding: list[float], k: int, user:
)
if result:
log.info(f'query_doc:result {result.ids} {result.metadatas}')
log.info('query_doc:result %s %s', result.ids, result.metadatas)
return result
except Exception as e:
@ -312,11 +349,11 @@ def query_doc(collection_name: str, query_embedding: list[float], k: int, user:
def get_doc(collection_name: str, user: UserModel = None):
try:
log.debug(f'get_doc:doc {collection_name}')
log.debug('get_doc:doc %s', collection_name)
result = VECTOR_DB_CLIENT.get(collection_name=collection_name)
if result:
log.info(f'query_doc:result {result.ids} {result.metadatas}')
log.info('query_doc:result %s %s', result.ids, result.metadatas)
return result
except Exception as e:
@ -441,7 +478,7 @@ async def query_doc_with_native_hybrid_search(
'metadatas': [metadatas],
}
except Exception as e:
log.debug(f'Native hybrid search failed for {collection_name}, falling back to legacy hybrid search: {e}')
log.debug('Native hybrid search failed for %s, falling back to legacy hybrid search: %s', collection_name, e)
return None
@ -494,7 +531,7 @@ async def query_doc_with_hybrid_search(
log.warning(f'query_doc_with_hybrid_search:no_docs {collection_name}')
return {'documents': [], 'metadatas': [], 'distances': []}
log.debug(f'query_doc_with_hybrid_search:doc {collection_name}')
log.debug('query_doc_with_hybrid_search:doc %s', collection_name)
original_texts = collection_result.documents[0]
bm25_metadatas = [
@ -569,7 +606,7 @@ async def query_doc_with_hybrid_search(
'metadatas': [metadatas],
}
log.info('query_doc_with_hybrid_search:result ' + f'{result["metadatas"]} {result["distances"]}')
log.info('query_doc_with_hybrid_search:result %s %s', result['metadatas'], result['distances'])
return result
except Exception as e:
log.exception(f'Error querying doc {collection_name} with hybrid search: {e}')
@ -615,9 +652,9 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
for distance, document, metadata in zip(distances, documents, metadatas):
if isinstance(document, str):
doc_hash = hashlib.sha256(document.encode()).hexdigest() # Compute a hash for uniqueness
doc_hash = (metadata or {}).get(CHUNK_HASH_KEY) or _content_hash(document)
if doc_hash not in combined.keys():
if doc_hash not in combined:
combined[doc_hash] = (distance, document, metadata)
continue # if doc is new, no further comparison is needed
@ -691,7 +728,7 @@ async def query_collection(
enable_enriched_texts=config.get('rag.enable_hybrid_search_enriched_texts'),
)
except Exception as e:
log.debug(f'Hybrid search failed, falling back to vector search: {e}')
log.debug('Hybrid search failed, falling back to vector search: %s', e)
results = []
error = False
@ -720,15 +757,15 @@ async def query_collection(
# Generate all query embeddings (in one call)
query_embeddings = await embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX)
log.debug(f'query_collection: processing {len(queries)} queries across {len(collection_names)} collections')
log.debug('query_collection: processing %s queries across %s collections', len(queries), len(collection_names))
with ThreadPoolExecutor() as executor:
future_results = []
for query_embedding in query_embeddings:
for collection_name in collection_names:
result = executor.submit(process_query_collection, collection_name, query_embedding)
future_results.append(result)
task_results = [future.result() for future in future_results]
task_results = await asyncio.gather(
*[
asyncio.to_thread(process_query_collection, collection_name, query_embedding)
for query_embedding in query_embeddings
for collection_name in collection_names
]
)
for result, err in task_results:
if err is not None:
@ -796,7 +833,7 @@ async def query_collection_with_hybrid_search(
collection_results = dict(await asyncio.gather(*(_fetch_collection(name) for name in collection_names)))
log.info(f'Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections...')
log.info('Starting hybrid search for %s queries in %s collections...', len(queries), len(collection_names))
async def process_query(collection_name, query):
try:
@ -850,15 +887,12 @@ def generate_openai_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug(f'generate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
log.debug('generate_openai_batch_embeddings:model %s batch size: %s', model, len(texts))
json_data = {'input': texts, 'model': model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
headers = get_json_bearer_headers(key)
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
@ -883,15 +917,12 @@ async def agenerate_openai_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug(f'agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
log.debug('agenerate_openai_batch_embeddings:model %s batch size: %s', model, len(texts))
form_data = {'input': texts, 'model': model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
headers = get_json_bearer_headers(key)
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
@ -921,7 +952,7 @@ def generate_azure_openai_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug(f'generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
log.debug('generate_azure_openai_batch_embeddings:deployment %s batch size: %s', model, len(texts))
json_data = {'input': texts}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
@ -963,7 +994,7 @@ async def agenerate_azure_openai_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug(f'agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
log.debug('agenerate_azure_openai_batch_embeddings:deployment %s batch size: %s', model, len(texts))
form_data = {'input': texts}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
@ -1002,15 +1033,12 @@ def generate_ollama_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug(f'generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
log.debug('generate_ollama_batch_embeddings:model %s batch size: %s', model, len(texts))
json_data = {'input': texts, 'model': model, 'truncate': True}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
headers = get_json_bearer_headers(key)
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
@ -1038,15 +1066,12 @@ async def agenerate_ollama_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug(f'agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
log.debug('agenerate_ollama_batch_embeddings:model %s batch size: %s', model, len(texts))
form_data = {'input': texts, 'model': model, 'truncate': True}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
headers = get_json_bearer_headers(key)
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
@ -1122,7 +1147,7 @@ def get_embedding_function(
batches = [query[i : i + embedding_batch_size] for i in range(0, len(query), embedding_batch_size)]
if enable_async:
log.debug(f'generate_multiple_async: Processing {len(batches)} batches in parallel')
log.debug('generate_multiple_async: Processing %s batches in parallel', len(batches))
# Use semaphore to limit concurrent embedding API requests
# 0 = unlimited (no semaphore)
if concurrent_requests:
@ -1137,7 +1162,7 @@ def get_embedding_function(
tasks = [embedding_function(batch, prefix=prefix, user=user) for batch in batches]
batch_results = await asyncio.gather(*tasks)
else:
log.debug(f'generate_multiple_async: Processing {len(batches)} batches sequentially')
log.debug('generate_multiple_async: Processing %s batches sequentially', len(batches))
batch_results = []
for batch in batches:
batch_results.append(await embedding_function(batch, prefix=prefix, user=user))
@ -1150,7 +1175,9 @@ def get_embedding_function(
embeddings.extend(batch_embeddings)
log.debug(
f'generate_multiple_async: Generated {len(embeddings)} embeddings from {len(batches)} parallel batches'
'generate_multiple_async: Generated %s embeddings from %s parallel batches',
len(embeddings),
len(batches),
)
return embeddings
else:
@ -1250,7 +1277,7 @@ async def filter_accessible_collections(
- any name with characters outside [A-Za-z0-9_-] rejected
- file-* validated via has_access_to_file
- user-memory-* must match user's own memory collection
- web-search-* ephemeral per-query collections, always allowed
- web-search-* ephemeral per-query collections, owner-bound to web-search-{user.id}-*
- knowledge-bases always denied (system meta-collection)
- everything else if the name matches a knowledge base, validated
via Knowledges.check_access_by_user_id; if no
@ -1285,10 +1312,10 @@ async def filter_accessible_collections(
if name == f'user-memory-{user.id}':
validated.add(name)
elif name.startswith('web-search-'):
# Ephemeral collections created by process_web_search — safe
# to allow because they contain only transient web-search
# results scoped to the requesting user's session.
validated.add(name)
# Ephemeral per-query collections, owner-bound: process_web_search mints
# them as web-search-{user.id}-<hash>, so only the creator may read/write.
if name.startswith(f'web-search-{user.id}-'):
validated.add(name)
else:
# May be a knowledge-base ID or a legacy/ephemeral collection.
# If it IS a KB, enforce access control. If no such KB
@ -1318,11 +1345,28 @@ async def get_sources_from_items(
full_context=False,
user: UserModel | None = None,
):
log.debug(f'items: {items} {queries} {embedding_function} {reranking_function} {full_context}')
log.debug('items: %s %s %s %s %s', items, queries, embedding_function, reranking_function, full_context)
bypass_embedding_and_retrieval = await Config.get('rag.bypass_embedding_and_retrieval')
extracted_collections = []
query_results = []
folder_items = set()
expanded_folders = set()
items = list(items)
for item in items:
if item.get('type') != 'folder' or not user:
continue
folder_id = item.get('id')
if not folder_id or folder_id in expanded_folders:
continue
expanded_folders.add(folder_id)
folder = await Folders.get_folder_by_id(folder_id)
if folder and (user.role == 'admin' or await has_folder_access(user.id, folder, 'read', db=None)):
files = await get_owner_accessible_folder_files(folder)
folder_items.update((entry.get('type'), entry.get('id')) for entry in files if isinstance(entry, dict))
items.extend(files)
for item in items:
query_result = None
@ -1381,8 +1425,21 @@ async def get_sources_from_items(
elif item.get('type') == 'chat':
# Chat Attached
chat = await Chats.get_chat_by_id(item.get('id'))
has_read_access = bool(chat and (user.role == 'admin' or chat.user_id == user.id))
if chat and (user.role == 'admin' or chat.user_id == user.id):
if chat and not has_read_access:
has_read_access = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=chat.id,
permission='read',
)
if chat and not has_read_access and chat.folder_id:
folder = await Folders.get_folder_by_id(chat.folder_id)
has_read_access = folder and await has_folder_access(user.id, folder, 'read', db=None)
if has_read_access:
messages_map = chat.chat.get('history', {}).get('messages', {})
message_id = chat.chat.get('history', {}).get('currentId')
@ -1390,7 +1447,10 @@ async def get_sources_from_items(
# Reconstruct the message list in order
message_list = get_message_list(messages_map, message_id)
message_history = '\n'.join(
[f'#### {m.get("role", "user").capitalize()}\n{m.get("content")}\n' for m in message_list]
[
f'#### {m.get("role", "user").capitalize()}\n{get_content_from_message(m) or ""}\n'
for m in message_list
]
)
# User has access to the chat
@ -1429,6 +1489,7 @@ async def get_sources_from_items(
user.role == 'admin'
or file_object.user_id == user.id
or await has_access_to_file(item.get('id'), 'read', user)
or ('file', item.get('id')) in folder_items
):
query_result = {
'documents': [[file_object.data.get('content', '')]],
@ -1459,6 +1520,7 @@ async def get_sources_from_items(
user.role == 'admin'
or file_object.user_id == user.id
or await has_access_to_file(file_id, 'read', user)
or ('file', file_id) in folder_items
):
if item.get('legacy'):
collection_names.append(f'{file_id}')
@ -1478,6 +1540,7 @@ async def get_sources_from_items(
resource_id=knowledge_base.id,
permission='read',
)
or ('collection', item.get('id')) in folder_items
):
if (knowledge_base.meta or {}).get('source') == 'external':
query_result = await retrieve_external_knowledge(
@ -1500,6 +1563,7 @@ async def get_sources_from_items(
resource_id=knowledge_base.id,
permission='read',
)
or ('collection', item.get('id')) in folder_items
):
files = await Knowledges.get_files_by_id(knowledge_base.id)
@ -1566,14 +1630,14 @@ async def get_sources_from_items(
if query_result is None and collection_names:
collection_names = set(collection_names).difference(extracted_collections)
if not collection_names:
log.debug(f'skipping {item} as it has already been extracted')
log.debug('skipping %s as it has already been extracted', item)
continue
# Filter out collections the user cannot read
if user:
if user and (item.get('type'), item.get('id')) not in folder_items:
collection_names = await filter_accessible_collections(collection_names, user)
if not collection_names:
log.debug(f'access denied for all collections in item {item}')
log.debug('access denied for all collections in item %s', item)
continue
try:
@ -1632,8 +1696,8 @@ def get_model_path(model: str, update_model: bool = False):
'local_files_only': local_files_only,
}
log.debug(f'model: {model}')
log.debug(f'snapshot_kwargs: {snapshot_kwargs}')
log.debug('model: %s', model)
log.debug('snapshot_kwargs: %s', snapshot_kwargs)
# Inspiration from upstream sentence_transformers
if os.path.exists(model) or ('\\' in model or model.count('/') > 1) and local_files_only:
@ -1648,7 +1712,7 @@ def get_model_path(model: str, update_model: bool = False):
# Attempt to query the huggingface_hub library to determine the local path and/or to update
try:
model_repo_path = snapshot_download(**snapshot_kwargs)
log.debug(f'model_repo_path: {model_repo_path}')
log.debug('model_repo_path: %s', model_repo_path)
return model_repo_path
except Exception as e:
log.exception(f'Cannot determine model snapshot path: {e}')

View file

@ -3,6 +3,7 @@ from typing import Optional
import chromadb
from chromadb import Settings
from chromadb.errors import NotFoundError
from chromadb.utils.batch_utils import create_batches
from open_webui.config import (
CHROMA_CLIENT_AUTH_CREDENTIALS,
@ -56,13 +57,11 @@ class ChromaClient(VectorDBBase):
)
def has_collection(self, collection_name: str) -> bool:
# Check if the collection exists based on the collection name.
# chromadb's list_collections() returns Collection objects (1.x), so a
# bare `name in collections` membership test is always False — compare
# against the names. (hasattr guard tolerates versions that yield names.)
collections = self.client.list_collections()
collection_names = [c.name if hasattr(c, 'name') else c for c in collections]
return collection_name in collection_names
try:
self.client.get_collection(name=collection_name)
return True
except NotFoundError:
return False
def delete_collection(self, collection_name: str):
# Delete the collection based on the collection name.
@ -183,7 +182,7 @@ class ChromaClient(VectorDBBase):
collection.delete(where=filter)
except Exception as e:
# If collection doesn't exist, that's fine - nothing to delete
log.debug(f'Attempted to delete from non-existent collection {collection_name}. Ignoring.')
log.debug('Attempted to delete from non-existent collection %s. Ignoring.', collection_name)
pass
def reset(self):

View file

@ -3,7 +3,7 @@ NOTE: This vector database integration is community-supported and maintained on
"""
import ssl
from typing import Optional
from typing import Any, Optional
from elasticsearch import BadRequestError, Elasticsearch
from elasticsearch.helpers import bulk, scan
@ -23,7 +23,13 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
def _metadata_filter(key: str, op: str, value: Any) -> dict:
if op == '$in':
return {'terms': {f'metadata.{key}': value}}
return {'term': {f'metadata.{key}': value}}
class ElasticsearchClient(VectorDBBase):
@ -161,12 +167,16 @@ class ElasticsearchClient(VectorDBBase):
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
filters = [{'term': {'collection': collection_name}}]
if filter:
filters.extend(_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter))
query = {
'size': limit,
'_source': ['text', 'metadata'],
'query': {
'script_score': {
'query': {'bool': {'filter': [{'term': {'collection': collection_name}}]}},
'query': {'bool': {'filter': filters}},
'script': {
'source': "cosineSimilarity(params.vector, 'vector') + 1.0",
'params': {'vector': vectors[0]}, # Assuming single query vector

View file

@ -5,7 +5,6 @@ NOTE: This vector database integration is community-supported and maintained on
from __future__ import annotations
import array
import json
import logging
import math
import re
@ -30,6 +29,7 @@ from open_webui.retrieval.vector.main import (
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.utils.json_codec import JSONCodec
from sqlalchemy import create_engine
from sqlalchemy.pool import NullPool, QueuePool
@ -72,7 +72,7 @@ def _safe_json(v: Any) -> Dict[str, Any]:
return {}
if isinstance(v, str):
try:
j = json.loads(v)
j = JSONCodec.loads(v)
return j if isinstance(j, dict) else {}
except Exception:
return {}
@ -324,7 +324,7 @@ class MariaDBVectorClient(VectorDBBase):
emb,
collection_name,
item.get('text'),
json.dumps(meta),
JSONCodec.dumps(meta),
)
)
cur.executemany(sql, params)
@ -367,7 +367,7 @@ class MariaDBVectorClient(VectorDBBase):
emb,
collection_name,
item.get('text'),
json.dumps(meta),
JSONCodec.dumps(meta),
)
)
cur.executemany(sql, params)

View file

@ -2,9 +2,9 @@
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
import json
import logging
from typing import Optional
import re
from typing import Any, Optional
from open_webui.config import (
MILVUS_DB,
@ -24,8 +24,9 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
from pymilvus import Collection, DataType, FieldSchema, connections
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
from open_webui.utils.json_codec import JSONCodec
from pymilvus import DataType
from pymilvus import MilvusClient as Client
from pymilvus.exceptions import MilvusException
@ -35,6 +36,36 @@ log = logging.getLogger(__name__)
# field). Clamp long chunks before insert so one oversized chunk can't fail the
# whole batch and leave the file with zero embeddings.
MILVUS_TEXT_MAX_LENGTH = 65535
_SAFE_METADATA_KEY_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]{0,63}$')
def _escape_milvus_string(value: str) -> str:
if not isinstance(value, str):
raise TypeError(f'Expected str, got {type(value).__name__}')
return value.replace('\\', '\\\\').replace("'", "\\'")
def _milvus_literal(value: Any) -> str:
if isinstance(value, str):
return f"'{_escape_milvus_string(value)}'"
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, (int, float)):
return str(value)
raise TypeError(f'Unsupported Milvus filter value type: {type(value).__name__}')
def _metadata_exprs(filter: Optional[dict]) -> list[str]:
exprs = []
for key, op, value in iter_filter_conditions(filter):
if not isinstance(key, str) or not _SAFE_METADATA_KEY_RE.fullmatch(key):
raise ValueError(f'Invalid Milvus metadata filter key: {key!r}')
if op == '$in':
items = [f"metadata['{key}'] == {_milvus_literal(item)}" for item in value]
exprs.append(f'({" or ".join(items)})' if items else 'false')
else:
exprs.append(f"metadata['{key}'] == {_milvus_literal(value)}")
return exprs
class MilvusClient(VectorDBBase):
@ -125,7 +156,7 @@ class MilvusClient(VectorDBBase):
index_type = MILVUS_INDEX_TYPE.upper()
metric_type = MILVUS_METRIC_TYPE.upper()
log.info(f'Using Milvus index type: {index_type}, metric type: {metric_type}')
log.info('Using Milvus index type: %s, metric type: %s', index_type, metric_type)
index_creation_params = {}
if index_type == 'HNSW':
@ -133,18 +164,18 @@ class MilvusClient(VectorDBBase):
'M': MILVUS_HNSW_M,
'efConstruction': MILVUS_HNSW_EFCONSTRUCTION,
}
log.info(f'HNSW params: {index_creation_params}')
log.info('HNSW params: %s', index_creation_params)
elif index_type == 'IVF_FLAT':
index_creation_params = {'nlist': MILVUS_IVF_FLAT_NLIST}
log.info(f'IVF_FLAT params: {index_creation_params}')
log.info('IVF_FLAT params: %s', index_creation_params)
elif index_type == 'DISKANN':
index_creation_params = {
'max_degree': MILVUS_DISKANN_MAX_DEGREE,
'search_list_size': MILVUS_DISKANN_SEARCH_LIST_SIZE,
}
log.info(f'DISKANN params: {index_creation_params}')
log.info('DISKANN params: %s', index_creation_params)
elif index_type in ['FLAT', 'AUTOINDEX']:
log.info(f'Using {index_type} index with no specific build-time params.')
log.info('Using %s index with no specific build-time params.', index_type)
else:
log.warning(
f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. "
@ -167,7 +198,11 @@ class MilvusClient(VectorDBBase):
index_params=index_params,
)
log.info(
f"Successfully created collection '{self.collection_prefix}_{collection_name}' with index type '{index_type}' and metric '{metric_type}'."
"Successfully created collection '%s_%s' with index type '%s' and metric '%s'.",
self.collection_prefix,
collection_name,
index_type,
metric_type,
)
def has_collection(self, collection_name: str) -> bool:
@ -189,6 +224,9 @@ class MilvusClient(VectorDBBase):
) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
collection_name = collection_name.replace('-', '_')
kwargs = {}
if filter:
kwargs['filter'] = ' and '.join(_metadata_exprs(filter))
# For some index types like IVF_FLAT, search params like nprobe can be set.
# Example: search_params = {"nprobe": 10} if using IVF_FLAT
# For simplicity, not adding configurable search_params here, but could be extended.
@ -197,13 +235,12 @@ class MilvusClient(VectorDBBase):
data=vectors,
limit=limit,
output_fields=['data', 'metadata'],
**kwargs,
# search_params=search_params # Potentially add later if needed
)
return self._result_to_search_result(result)
def query(self, collection_name: str, filter: dict, limit: int = -1):
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
collection_name = collection_name.replace('-', '_')
if not self.has_collection(collection_name):
log.warning(f'Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}')
@ -218,16 +255,20 @@ class MilvusClient(VectorDBBase):
filter_string = ' && '.join(filter_expressions)
collection = Collection(f'{self.collection_prefix}_{collection_name}')
collection.load()
self.client.load_collection(collection_name=f'{self.collection_prefix}_{collection_name}')
try:
log.info(
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
"Querying collection %s_%s with filter: '%s', limit: %s",
self.collection_prefix,
collection_name,
filter_string,
limit,
)
iterator = collection.query_iterator(
expr=filter_string,
iterator = self.client.query_iterator(
collection_name=f'{self.collection_prefix}_{collection_name}',
filter=filter_string,
output_fields=[
'id',
'data',
@ -244,7 +285,7 @@ class MilvusClient(VectorDBBase):
break
all_results.extend(batch)
log.debug(f'Total results from query: {len(all_results)}')
log.debug('Total results from query: %s', len(all_results))
return self._result_to_get_result([all_results] if all_results else [[]])
except Exception as e:
@ -267,7 +308,7 @@ class MilvusClient(VectorDBBase):
# Insert the items into the collection, if the collection does not exist, it will be created.
collection_name = collection_name.replace('-', '_')
if not self.client.has_collection(collection_name=f'{self.collection_prefix}_{collection_name}'):
log.info(f'Collection {self.collection_prefix}_{collection_name} does not exist. Creating now.')
log.info('Collection %s_%s does not exist. Creating now.', self.collection_prefix, collection_name)
if not items:
log.error(
f'Cannot create collection {self.collection_prefix}_{collection_name} without items to determine dimension.'
@ -275,7 +316,7 @@ class MilvusClient(VectorDBBase):
raise ValueError('Cannot create Milvus collection without items to determine vector dimension.')
self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector']))
log.info(f'Inserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.')
log.info('Inserting %s items into collection %s_%s.', len(items), self.collection_prefix, collection_name)
data = []
for item in items:
text = item['text'] or ''
@ -303,7 +344,9 @@ class MilvusClient(VectorDBBase):
# Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created.
collection_name = collection_name.replace('-', '_')
if not self.client.has_collection(collection_name=f'{self.collection_prefix}_{collection_name}'):
log.info(f'Collection {self.collection_prefix}_{collection_name} does not exist for upsert. Creating now.')
log.info(
'Collection %s_%s does not exist for upsert. Creating now.', self.collection_prefix, collection_name
)
if not items:
log.error(
f'Cannot create collection {self.collection_prefix}_{collection_name} for upsert without items to determine dimension.'
@ -313,7 +356,7 @@ class MilvusClient(VectorDBBase):
)
self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector']))
log.info(f'Upserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.')
log.info('Upserting %s items into collection %s_%s.', len(items), self.collection_prefix, collection_name)
data = []
for item in items:
text = item['text'] or ''
@ -350,15 +393,20 @@ class MilvusClient(VectorDBBase):
return None
if ids:
log.info(f'Deleting items by IDs from {self.collection_prefix}_{collection_name}. IDs: {ids}')
log.info('Deleting items by IDs from %s_%s. IDs: %s', self.collection_prefix, collection_name, ids)
return self.client.delete(
collection_name=f'{self.collection_prefix}_{collection_name}',
ids=ids,
)
elif filter:
filter_string = ' && '.join([f'metadata["{key}"] == {json.dumps(value)}' for key, value in filter.items()])
filter_string = ' && '.join(
[f'metadata["{key}"] == {JSONCodec.dumps(value)}' for key, value in filter.items()]
)
log.info(
f'Deleting items by filter from {self.collection_prefix}_{collection_name}. Filter: {filter_string}'
'Deleting items by filter from %s_%s. Filter: %s',
self.collection_prefix,
collection_name,
filter_string,
)
return self.client.delete(
collection_name=f'{self.collection_prefix}_{collection_name}',
@ -380,7 +428,7 @@ class MilvusClient(VectorDBBase):
try:
self.client.drop_collection(collection_name=collection_name_full)
deleted_collections.append(collection_name_full)
log.info(f'Deleted collection: {collection_name_full}')
log.info('Deleted collection: %s', collection_name_full)
except Exception as e:
log.error(f'Error deleting collection {collection_name_full}: {e}')
log.info(f'Milvus reset complete. Deleted collections: {deleted_collections}')
log.info('Milvus reset complete. Deleted collections: %s', deleted_collections)

View file

@ -17,20 +17,15 @@ from open_webui.config import (
MILVUS_TOKEN,
MILVUS_URI,
)
from open_webui.retrieval.vector.dbs.milvus import _metadata_exprs
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
VectorDBBase,
VectorItem,
)
from pymilvus import (
Collection,
CollectionSchema,
DataType,
FieldSchema,
connections,
utility,
)
from pymilvus import DataType
from pymilvus import MilvusClient as Client
from pymilvus.exceptions import MilvusException
log = logging.getLogger(__name__)
@ -70,12 +65,7 @@ class MilvusClient(VectorDBBase):
def __init__(self):
# Milvus collection names can only contain numbers, letters, and underscores.
self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace('-', '_')
connections.connect(
alias='default',
uri=MILVUS_URI,
token=MILVUS_TOKEN,
db_name=MILVUS_DB,
)
self.client = Client(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
# Main collection types for multi-tenancy
self.MEMORY_COLLECTION = f'{self.collection_prefix}_memories'
@ -116,53 +106,66 @@ class MilvusClient(VectorDBBase):
return self.KNOWLEDGE_COLLECTION, resource_id
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
fields = [
FieldSchema(
name='id',
dtype=DataType.VARCHAR,
is_primary=True,
auto_id=False,
max_length=36,
),
FieldSchema(name='vector', dtype=DataType.FLOAT_VECTOR, dim=dimension),
FieldSchema(name='text', dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name='metadata', dtype=DataType.JSON),
FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
]
schema = CollectionSchema(fields, 'Shared collection for multi-tenancy')
collection = Collection(mt_collection_name, schema)
schema = self.client.create_schema(auto_id=False, description='Shared collection for multi-tenancy')
schema.add_field(field_name='id', datatype=DataType.VARCHAR, is_primary=True, max_length=36)
schema.add_field(field_name='vector', datatype=DataType.FLOAT_VECTOR, dim=dimension)
schema.add_field(field_name='text', datatype=DataType.VARCHAR, max_length=MILVUS_TEXT_MAX_LENGTH)
schema.add_field(field_name='metadata', datatype=DataType.JSON)
schema.add_field(field_name=RESOURCE_ID_FIELD, datatype=DataType.VARCHAR, max_length=255)
index_params = {
'metric_type': MILVUS_METRIC_TYPE,
'index_type': MILVUS_INDEX_TYPE,
'params': {},
}
index_build_params = {}
if MILVUS_INDEX_TYPE == 'HNSW':
index_params['params'] = {
index_build_params = {
'M': MILVUS_HNSW_M,
'efConstruction': MILVUS_HNSW_EFCONSTRUCTION,
}
elif MILVUS_INDEX_TYPE == 'IVF_FLAT':
index_params['params'] = {'nlist': MILVUS_IVF_FLAT_NLIST}
index_build_params = {'nlist': MILVUS_IVF_FLAT_NLIST}
collection.create_index('vector', index_params)
collection.create_index(RESOURCE_ID_FIELD)
log.info(f'Created shared collection: {mt_collection_name}')
return collection
vector_index = self.client.prepare_index_params(
field_name='vector',
index_type=MILVUS_INDEX_TYPE,
metric_type=MILVUS_METRIC_TYPE,
params=index_build_params,
)
self.client.create_collection(collection_name=mt_collection_name, schema=schema)
self.client.create_index(collection_name=mt_collection_name, index_params=vector_index)
try:
# A Milvus server auto-selects the scalar index type from a parameterless call.
self.client.create_index(
collection_name=mt_collection_name,
index_params=self.client.prepare_index_params(field_name=RESOURCE_ID_FIELD),
)
except MilvusException:
try:
self.client.create_index(
collection_name=mt_collection_name,
index_params=self.client.prepare_index_params(field_name=RESOURCE_ID_FIELD, index_type='INVERTED'),
)
except MilvusException as e:
# The index only accelerates resource_id filters; never fail
# collection creation over it.
log.warning(f'Could not create {RESOURCE_ID_FIELD} index on {mt_collection_name}: {e}')
log.info('Created shared collection: %s', mt_collection_name)
def _ensure_collection(self, mt_collection_name: str, dimension: int):
if not utility.has_collection(mt_collection_name):
if not self.client.has_collection(mt_collection_name):
self._create_shared_collection(mt_collection_name, dimension)
def has_collection(self, collection_name: str) -> bool:
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not utility.has_collection(mt_collection):
if not self.client.has_collection(mt_collection):
return False
collection = Collection(mt_collection)
collection.load()
res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
self.client.load_collection(mt_collection)
res = self.client.query(
collection_name=mt_collection,
filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
output_fields=['id'],
limit=1,
)
return len(res) > 0
def upsert(self, collection_name: str, items: List[VectorItem]):
@ -172,7 +175,6 @@ class MilvusClient(VectorDBBase):
_validate_resource_id(resource_id)
dimension = len(items[0]['vector'])
self._ensure_collection(mt_collection, dimension)
collection = Collection(mt_collection)
entities = []
for item in items:
@ -195,7 +197,7 @@ class MilvusClient(VectorDBBase):
)
try:
collection.insert(entities)
self.client.insert(collection_name=mt_collection, data=entities)
except MilvusException as e:
log.error(
f'Milvus insert failed (collection={mt_collection}, '
@ -215,19 +217,19 @@ class MilvusClient(VectorDBBase):
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not utility.has_collection(mt_collection):
if not self.client.has_collection(mt_collection):
return None
collection = Collection(mt_collection)
collection.load()
self.client.load_collection(mt_collection)
search_params = {'metric_type': MILVUS_METRIC_TYPE, 'params': {}}
results = collection.search(
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'", *_metadata_exprs(filter)]
results = self.client.search(
collection_name=mt_collection,
data=vectors,
anns_field='vector',
param=search_params,
search_params={'metric_type': MILVUS_METRIC_TYPE, 'params': {}},
limit=limit,
expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
filter=' and '.join(expr),
output_fields=['id', 'text', 'metadata'],
)
@ -235,10 +237,11 @@ class MilvusClient(VectorDBBase):
for hits in results:
batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
for hit in hits:
batch_ids.append(hit.entity.get('id'))
batch_docs.append(hit.entity.get('text'))
batch_metadatas.append(hit.entity.get('metadata'))
batch_dists.append(hit.distance)
entity = hit.get('entity', {})
batch_ids.append(entity.get('id'))
batch_docs.append(entity.get('text'))
batch_metadatas.append(entity.get('metadata'))
batch_dists.append(hit.get('distance'))
ids.append(batch_ids)
documents.append(batch_docs)
metadatas.append(batch_metadatas)
@ -254,11 +257,9 @@ class MilvusClient(VectorDBBase):
):
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not utility.has_collection(mt_collection):
if not self.client.has_collection(mt_collection):
return
collection = Collection(mt_collection)
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if ids:
# Milvus expects a string list for 'in' operator
@ -270,30 +271,28 @@ class MilvusClient(VectorDBBase):
_validate_metadata_key(key)
expr.append(f"metadata['{key}'] == '{_escape_milvus_string(str(value))}'")
collection.delete(' and '.join(expr))
self.client.delete(collection_name=mt_collection, filter=' and '.join(expr))
def reset(self):
for collection_name in self.shared_collections:
if utility.has_collection(collection_name):
utility.drop_collection(collection_name)
if self.client.has_collection(collection_name):
self.client.drop_collection(collection_name)
def delete_collection(self, collection_name: str):
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not utility.has_collection(mt_collection):
if not self.client.has_collection(mt_collection):
return
collection = Collection(mt_collection)
collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
self.client.delete(collection_name=mt_collection, filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'")
def query(self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None) -> Optional[GetResult]:
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not utility.has_collection(mt_collection):
if not self.client.has_collection(mt_collection):
return None
collection = Collection(mt_collection)
collection.load()
self.client.load_collection(mt_collection)
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if filter:
@ -308,8 +307,9 @@ class MilvusClient(VectorDBBase):
else:
raise TypeError(f'Unsupported Milvus filter value type for key {key!r}: {type(value).__name__}')
iterator = collection.query_iterator(
expr=' and '.join(expr),
iterator = self.client.query_iterator(
collection_name=mt_collection,
filter=' and '.join(expr),
output_fields=['id', 'text', 'metadata'],
limit=limit if limit else -1,
)

View file

@ -2,7 +2,6 @@
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
import json
import logging
import re
from typing import Any, Dict, List, Optional
@ -63,20 +62,18 @@ from open_webui.config import (
OPENGAUSS_POOL_SIZE,
OPENGAUSS_POOL_TIMEOUT,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
VECTOR_LENGTH = OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH
Base = declarative_base()
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS['RAG'])
class DocumentChunk(Base):
@ -89,6 +86,12 @@ class DocumentChunk(Base):
vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True)
def _metadata_clause(key: str, op: str, value: Any):
if op == '$in':
return DocumentChunk.vmetadata[key].astext.in_([str(v) for v in value])
return DocumentChunk.vmetadata[key].astext == str(value)
class OpenGaussClient(VectorDBBase):
def __init__(self) -> None:
if not OPENGAUSS_DB_URL:
@ -182,7 +185,7 @@ class OpenGaussClient(VectorDBBase):
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
self.session.commit()
log.info(f"Inserting {len(new_items)} items into collection '{collection_name}'.")
log.info("Inserting %s items into collection '%s'.", len(new_items), collection_name)
except Exception as e:
self.session.rollback()
log.exception(f'Failed to insert data: {e}')
@ -208,7 +211,7 @@ class OpenGaussClient(VectorDBBase):
)
self.session.add(new_chunk)
self.session.commit()
log.info(f"Inserting/updating {len(items)} items in collection '{collection_name}'.")
log.info("Inserting/updating %s items in collection '%s'.", len(items), collection_name)
except Exception as e:
self.session.rollback()
log.exception(f'Failed to insert or update data.: {e}')
@ -245,10 +248,15 @@ class OpenGaussClient(VectorDBBase):
DocumentChunk.vmetadata,
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label('distance'),
]
where_clauses = [DocumentChunk.collection_name == collection_name]
if filter:
where_clauses.extend(
_metadata_clause(key, op, value) for key, op, value in iter_filter_conditions(filter)
)
subq = (
select(*result_fields)
.where(DocumentChunk.collection_name == collection_name)
.where(*where_clauses)
.order_by(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector))
)
if limit is not None:
@ -353,7 +361,7 @@ class OpenGaussClient(VectorDBBase):
query = query.filter(DocumentChunk.vmetadata[key].astext == str(value))
deleted = query.delete(synchronize_session=False)
self.session.commit()
log.info(f"Deleted {deleted} items from collection '{collection_name}'")
log.info("Deleted %s items from collection '%s'", deleted, collection_name)
except Exception as e:
self.session.rollback()
log.exception(f'Failed to delete data: {e}')
@ -363,7 +371,7 @@ class OpenGaussClient(VectorDBBase):
try:
deleted = self.session.query(DocumentChunk).delete()
self.session.commit()
log.info(f'Reset completed. Deleted {deleted} items')
log.info('Reset completed. Deleted %s items', deleted)
except Exception as e:
self.session.rollback()
log.exception(f'Reset failed: {e}')
@ -387,4 +395,4 @@ class OpenGaussClient(VectorDBBase):
def delete_collection(self, collection_name: str) -> None:
self.delete(collection_name)
log.info(f"Collection '{collection_name}' has been deleted")
log.info("Collection '%s' has been deleted", collection_name)

View file

@ -2,7 +2,7 @@
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
from typing import Optional
from typing import Any, Optional
from open_webui.config import (
OPENSEARCH_CERT_VERIFY,
@ -17,11 +17,17 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
def _metadata_filter(key: str, op: str, value: Any) -> dict:
if op == '$in':
return {'terms': {f'metadata.{key}.keyword': value}}
return {'term': {f'metadata.{key}.keyword': value}}
class OpenSearchClient(VectorDBBase):
def __init__(self):
self.index_prefix = 'open_webui'
@ -121,6 +127,8 @@ class OpenSearchClient(VectorDBBase):
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
filter_clauses = [_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter)]
try:
if not self.has_collection(collection_name):
return None
@ -130,7 +138,7 @@ class OpenSearchClient(VectorDBBase):
'_source': ['text', 'metadata'],
'query': {
'script_score': {
'query': {'match_all': {}},
'query': {'bool': {'filter': filter_clauses}} if filter_clauses else {'match_all': {}},
'script': {
'source': '(cosineSimilarity(params.query_value, doc[params.field]) + 1.0) / 2.0',
'params': {

View file

@ -32,6 +32,7 @@ import array
import json
import logging
import os
import re
import threading
import time
from decimal import Decimal
@ -56,8 +57,29 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import iter_filter_conditions
from open_webui.utils.json_codec import JSONCodec
log = logging.getLogger(__name__)
_SAFE_METADATA_KEY_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]{0,63}$')
def _metadata_where(filter: Optional[dict]) -> tuple[str, dict[str, Any]]:
clause = ''
params: dict[str, Any] = {}
for i, (key, op, value) in enumerate(iter_filter_conditions(filter)):
if not isinstance(key, str) or not _SAFE_METADATA_KEY_RE.fullmatch(key):
raise ValueError(f'Invalid Oracle metadata filter key: {key!r}')
json_value = f"JSON_VALUE(dc.vmetadata, '$.{key}' RETURNING VARCHAR2(4096))"
if op == '$in':
names = [f'value_{i}_{j}' for j, _ in enumerate(value)]
clause += f' AND {json_value} IN ({", ".join(f":{name}" for name in names)})' if names else ' AND 1 = 0'
params.update({name: str(item) for name, item in zip(names, value)})
else:
name = f'value_{i}'
clause += f' AND {json_value} = :{name}'
params[name] = str(value)
return clause, params
class Oracle23aiClient(VectorDBBase):
@ -93,10 +115,10 @@ class Oracle23aiClient(VectorDBBase):
self._create_dbcs_pool()
dsn = ORACLE_DB_DSN
log.info(f'Creating Connection Pool [{ORACLE_DB_USER}:**@{dsn}]')
log.info('Creating Connection Pool [%s:**@%s]', ORACLE_DB_USER, dsn)
with self.get_connection() as connection:
log.info(f'Connection version: {connection.version}')
log.info('Connection version: %s', connection.version)
self._initialize_database(connection)
log.info('Oracle Vector Search initialization complete.')
@ -158,7 +180,7 @@ class Oracle23aiClient(VectorDBBase):
if attempt < max_retries - 1:
wait_time = 2**attempt
log.info(f'Retrying in {wait_time} seconds...')
log.info('Retrying in %s seconds...', wait_time)
time.sleep(wait_time)
else:
raise
@ -183,7 +205,7 @@ class Oracle23aiClient(VectorDBBase):
thread = threading.Thread(target=_monitor, daemon=True)
thread.start()
log.info(f'Started DB health monitor every {interval_seconds} seconds.')
log.info('Started DB health monitor every %s seconds.', interval_seconds)
def _reconnect_pool(self):
"""
@ -390,7 +412,7 @@ class Oracle23aiClient(VectorDBBase):
Returns:
Dict: Metadata dictionary
"""
return json.loads(json_str) if json_str else {}
return JSONCodec.loads(json_str) if json_str else {}
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
"""
@ -411,7 +433,7 @@ class Oracle23aiClient(VectorDBBase):
... ]
>>> client.insert("my_collection", items)
"""
log.info(f"Inserting {len(items)} items into collection '{collection_name}'.")
log.info("Inserting %s items into collection '%s'.", len(items), collection_name)
with self.get_connection() as connection:
try:
@ -436,7 +458,7 @@ class Oracle23aiClient(VectorDBBase):
)
connection.commit()
log.info(f"Successfully inserted {len(items)} items into collection '{collection_name}'.")
log.info("Successfully inserted %s items into collection '%s'.", len(items), collection_name)
except Exception as e:
connection.rollback()
@ -465,7 +487,7 @@ class Oracle23aiClient(VectorDBBase):
... ]
>>> client.upsert("my_collection", items)
"""
log.info(f"Upserting {len(items)} items into collection '{collection_name}'.")
log.info("Upserting %s items into collection '%s'.", len(items), collection_name)
with self.get_connection() as connection:
try:
@ -504,7 +526,7 @@ class Oracle23aiClient(VectorDBBase):
)
connection.commit()
log.info(f"Successfully upserted {len(items)} items into collection '{collection_name}'.")
log.info("Successfully upserted %s items into collection '%s'.", len(items), collection_name)
except Exception as e:
connection.rollback()
@ -540,7 +562,7 @@ class Oracle23aiClient(VectorDBBase):
... for i, (id, dist) in enumerate(zip(results.ids[0], results.distances[0])):
... log.info(f"Match {i+1}: id={id}, distance={dist}")
"""
log.info(f"Searching items from collection '{collection_name}' with limit {limit}.")
log.info("Searching items from collection '%s' with limit %s.", collection_name, limit)
try:
if not vectors:
@ -548,6 +570,7 @@ class Oracle23aiClient(VectorDBBase):
return None
num_queries = len(vectors)
filter_clause, filter_params = _metadata_where(filter)
ids = [[] for _ in range(num_queries)]
distances = [[] for _ in range(num_queries)]
@ -560,12 +583,12 @@ class Oracle23aiClient(VectorDBBase):
vector_blob = self._vector_to_blob(vector)
cursor.execute(
"""
SELECT dc.id, dc.text,
f"""
SELECT dc.id, dc.text,
JSON_SERIALIZE(dc.vmetadata RETURNING VARCHAR2(4096)) as vmetadata,
VECTOR_DISTANCE(dc.vector, :query_vector, COSINE) as distance
FROM document_chunk dc
WHERE dc.collection_name = :collection_name
WHERE dc.collection_name = :collection_name{filter_clause}
ORDER BY VECTOR_DISTANCE(dc.vector, :query_vector, COSINE)
FETCH APPROX FIRST :limit ROWS ONLY
""",
@ -573,6 +596,7 @@ class Oracle23aiClient(VectorDBBase):
'query_vector': vector_blob,
'collection_name': collection_name,
'limit': limit,
**filter_params,
},
)
@ -586,7 +610,7 @@ class Oracle23aiClient(VectorDBBase):
metadatas[qid].append(self._json_to_metadata(metadata_str))
distances[qid].append(float(row[3]))
log.info(f'Search completed. Found {sum(len(ids[i]) for i in range(num_queries))} total results.')
log.info('Search completed. Found %s total results.', sum(len(ids[i]) for i in range(num_queries)))
return SearchResult(ids=ids, distances=distances, documents=documents, metadatas=metadatas)
@ -615,7 +639,7 @@ class Oracle23aiClient(VectorDBBase):
>>> if results:
... print(f"Found {len(results.ids[0])} matching documents")
"""
log.info(f"Querying items from collection '{collection_name}' with filters.")
log.info("Querying items from collection '%s' with filters.", collection_name)
try:
limit = limit or 100
@ -655,7 +679,7 @@ class Oracle23aiClient(VectorDBBase):
]
]
log.info(f'Query completed. Found {len(results)} results.')
log.info('Query completed. Found %s results.', len(results))
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
@ -746,7 +770,7 @@ class Oracle23aiClient(VectorDBBase):
>>> # Or delete by metadata filter
>>> client.delete("my_collection", filter={"source": "deprecated_source"})
"""
log.info(f"Deleting items from collection '{collection_name}'.")
log.info("Deleting items from collection '%s'.", collection_name)
try:
query = 'DELETE FROM document_chunk WHERE collection_name = :collection_name'
@ -771,7 +795,7 @@ class Oracle23aiClient(VectorDBBase):
deleted = cursor.rowcount
connection.commit()
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
log.info("Deleted %s items from collection '%s'.", deleted, collection_name)
except Exception as e:
log.exception(f'Error during delete: {e}')
@ -799,7 +823,7 @@ class Oracle23aiClient(VectorDBBase):
deleted = cursor.rowcount
connection.commit()
log.info(f"Reset complete. Deleted {deleted} items from 'document_chunk' table.")
log.info("Reset complete. Deleted %s items from 'document_chunk' table.", deleted)
except Exception as e:
log.exception(f'Error during reset: {e}')
@ -874,7 +898,7 @@ class Oracle23aiClient(VectorDBBase):
>>> client = Oracle23aiClient()
>>> client.delete_collection("obsolete_collection")
"""
log.info(f"Deleting collection '{collection_name}'.")
log.info("Deleting collection '%s'.", collection_name)
try:
with self.get_connection() as connection:
@ -890,7 +914,7 @@ class Oracle23aiClient(VectorDBBase):
deleted = cursor.rowcount
connection.commit()
log.info(f"Collection '{collection_name}' deleted. Removed {deleted} items.")
log.info("Collection '%s' deleted. Removed %s items.", collection_name, deleted)
except Exception as e:
log.exception(f"Error deleting collection '{collection_name}': {e}")

View file

@ -1,4 +1,3 @@
import json
import logging
from typing import Any, Dict, List, Optional, Tuple
@ -18,6 +17,7 @@ from open_webui.config import (
PGVECTOR_POOL_TIMEOUT,
PGVECTOR_USE_HALFVEC,
)
from open_webui.internal.db import ScopedSession, enable_iam_token_auth
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
@ -25,6 +25,7 @@ from open_webui.retrieval.vector.main import (
VectorItem,
)
from open_webui.retrieval.vector.utils import merge_hybrid_search_results, process_metadata
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.misc import sanitize_text_for_db
from pgvector.sqlalchemy import HALFVEC, Vector
from sqlalchemy import (
@ -87,8 +88,6 @@ class PgvectorClient(VectorDBBase):
def __init__(self) -> None:
# if no pgvector uri, use the existing database connection
if not PGVECTOR_DB_URL:
from open_webui.internal.db import ScopedSession
self.session = ScopedSession
else:
if isinstance(PGVECTOR_POOL_SIZE, int):
@ -107,6 +106,7 @@ class PgvectorClient(VectorDBBase):
else:
engine = create_engine(PGVECTOR_DB_URL, pool_pre_ping=True)
enable_iam_token_auth(engine)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
self.session = scoped_session(SessionLocal)
@ -303,7 +303,7 @@ class PgvectorClient(VectorDBBase):
# Use raw SQL for BYTEA/pgcrypto
# Ensure metadata is converted to its JSON text representation
# Sanitize to strip null bytes / surrogates that PostgreSQL cannot store
json_metadata = sanitize_text_for_db(json.dumps(item['metadata']))
json_metadata = sanitize_text_for_db(JSONCodec.dumps(item['metadata']))
item_text = sanitize_text_for_db(item['text'])
self.session.execute(
text("""
@ -326,7 +326,7 @@ class PgvectorClient(VectorDBBase):
},
)
self.session.commit()
log.info(f"Encrypted & inserted {len(items)} into '{collection_name}'")
log.info("Encrypted & inserted %s into '%s'", len(items), collection_name)
else:
new_items = []
@ -342,7 +342,7 @@ class PgvectorClient(VectorDBBase):
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
self.session.commit()
log.info(f"Inserted {len(new_items)} items into collection '{collection_name}'.")
log.info("Inserted %s items into collection '%s'.", len(new_items), collection_name)
except Exception as e:
self.session.rollback()
log.exception(f'Error during insert: {e}')
@ -354,7 +354,7 @@ class PgvectorClient(VectorDBBase):
for item in items:
vector = self.adjust_vector_length(item['vector'])
# Sanitize to strip null bytes / surrogates that PostgreSQL cannot store
json_metadata = sanitize_text_for_db(json.dumps(item['metadata']))
json_metadata = sanitize_text_for_db(JSONCodec.dumps(item['metadata']))
item_text = sanitize_text_for_db(item['text'])
self.session.execute(
text("""
@ -381,7 +381,7 @@ class PgvectorClient(VectorDBBase):
},
)
self.session.commit()
log.info(f"Encrypted & upserted {len(items)} into '{collection_name}'")
log.info("Encrypted & upserted %s into '%s'", len(items), collection_name)
else:
for item in items:
vector = self.adjust_vector_length(item['vector'])
@ -401,7 +401,7 @@ class PgvectorClient(VectorDBBase):
)
self.session.add(new_chunk)
self.session.commit()
log.info(f"Upserted {len(items)} items into collection '{collection_name}'.")
log.info("Upserted %s items into collection '%s'.", len(items), collection_name)
except Exception as e:
self.session.rollback()
log.exception(f'Error during upsert: {e}')
@ -712,7 +712,7 @@ class PgvectorClient(VectorDBBase):
query = query.filter(DocumentChunk.vmetadata[key].astext == str(value))
deleted = query.delete(synchronize_session=False)
self.session.commit()
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
log.info("Deleted %s items from collection '%s'.", deleted, collection_name)
except Exception as e:
self.session.rollback()
log.exception(f'Error during delete: {e}')
@ -722,7 +722,7 @@ class PgvectorClient(VectorDBBase):
try:
deleted = self.session.query(DocumentChunk).delete()
self.session.commit()
log.info(f"Reset complete. Deleted {deleted} items from 'document_chunk' table.")
log.info("Reset complete. Deleted %s items from 'document_chunk' table.", deleted)
except Exception as e:
self.session.rollback()
log.exception(f'Error during reset: {e}')
@ -746,4 +746,4 @@ class PgvectorClient(VectorDBBase):
def delete_collection(self, collection_name: str) -> None:
self.delete(collection_name)
log.info(f"Collection '{collection_name}' deleted.")
log.info("Collection '%s' deleted.", collection_name)

View file

@ -35,7 +35,7 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.utils import normalize_filter, process_metadata
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
@ -106,16 +106,16 @@ class PineconeClient(VectorDBBase):
try:
# Check if index exists
if self.index_name not in self.client.list_indexes().names():
log.info(f"Creating Pinecone index '{self.index_name}'...")
log.info("Creating Pinecone index '%s'...", self.index_name)
self.client.create_index(
name=self.index_name,
dimension=self.dimension,
metric=self.metric,
spec=ServerlessSpec(cloud=self.cloud, region=self.environment),
)
log.info(f"Successfully created Pinecone index '{self.index_name}'")
log.info("Successfully created Pinecone index '%s'", self.index_name)
else:
log.info(f"Using existing Pinecone index '{self.index_name}'")
log.info("Using existing Pinecone index '%s'", self.index_name)
# Connect to the index
self.index = self.client.Index(
@ -245,7 +245,7 @@ class PineconeClient(VectorDBBase):
collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name)
try:
self.index.delete(filter={'collection_name': collection_name_with_prefix})
log.info(f"Collection '{collection_name_with_prefix}' deleted (all vectors removed).")
log.info("Collection '%s' deleted (all vectors removed).", collection_name_with_prefix)
except Exception as e:
log.warning(f"Failed to delete collection '{collection_name_with_prefix}': {e}")
raise
@ -274,9 +274,9 @@ class PineconeClient(VectorDBBase):
log.error(f'Error inserting batch: {e}')
raise
elapsed = time.time() - start_time
log.debug(f'Insert of {len(points)} vectors took {elapsed:.2f} seconds')
log.debug('Insert of %s vectors took %.2f seconds', len(points), elapsed)
log.info(
f"Successfully inserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'"
"Successfully inserted %s vectors in parallel batches into '%s'", len(points), collection_name_with_prefix
)
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
@ -303,9 +303,9 @@ class PineconeClient(VectorDBBase):
log.error(f'Error upserting batch: {e}')
raise
elapsed = time.time() - start_time
log.debug(f'Upsert of {len(points)} vectors took {elapsed:.2f} seconds')
log.debug('Upsert of %s vectors took %.2f seconds', len(points), elapsed)
log.info(
f"Successfully upserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'"
"Successfully upserted %s vectors in parallel batches into '%s'", len(points), collection_name_with_prefix
)
async def insert_async(self, collection_name: str, items: List[VectorItem]) -> None:
@ -326,7 +326,9 @@ class PineconeClient(VectorDBBase):
if isinstance(result, Exception):
log.error(f'Error in async insert batch: {result}')
raise result
log.info(f"Successfully async inserted {len(points)} vectors in batches into '{collection_name_with_prefix}'")
log.info(
"Successfully async inserted %s vectors in batches into '%s'", len(points), collection_name_with_prefix
)
async def upsert_async(self, collection_name: str, items: List[VectorItem]) -> None:
"""Async version of upsert using asyncio and run_in_executor for improved performance."""
@ -346,7 +348,9 @@ class PineconeClient(VectorDBBase):
if isinstance(result, Exception):
log.error(f'Error in async upsert batch: {result}')
raise result
log.info(f"Successfully async upserted {len(points)} vectors in batches into '{collection_name_with_prefix}'")
log.info(
"Successfully async upserted %s vectors in batches into '%s'", len(points), collection_name_with_prefix
)
def search(
self,
@ -368,13 +372,15 @@ class PineconeClient(VectorDBBase):
try:
# Search using the first vector (assuming this is the intended behavior)
query_vector = vectors[0]
pinecone_filter = normalize_filter(filter)
pinecone_filter['collection_name'] = collection_name_with_prefix
# Perform the search
query_response = self.index.query(
vector=query_vector,
top_k=limit,
include_metadata=True,
filter={'collection_name': collection_name_with_prefix},
filter=pinecone_filter,
)
matches = getattr(query_response, 'matches', []) or []
@ -474,8 +480,10 @@ class PineconeClient(VectorDBBase):
# Note: When deleting by ID, we can't filter by collection_name
# This is a limitation of Pinecone - be careful with ID uniqueness
self.index.delete(ids=batch_ids)
log.debug(f"Deleted batch of {len(batch_ids)} vectors by ID from '{collection_name_with_prefix}'")
log.info(f"Successfully deleted {len(ids)} vectors by ID from '{collection_name_with_prefix}'")
log.debug(
"Deleted batch of %s vectors by ID from '%s'", len(batch_ids), collection_name_with_prefix
)
log.info("Successfully deleted %s vectors by ID from '%s'", len(ids), collection_name_with_prefix)
elif filter:
# Combine user filter with collection_name
@ -484,7 +492,7 @@ class PineconeClient(VectorDBBase):
pinecone_filter.update(filter)
# Delete by metadata filter
self.index.delete(filter=pinecone_filter)
log.info(f"Successfully deleted vectors by filter from '{collection_name_with_prefix}'")
log.info("Successfully deleted vectors by filter from '%s'", collection_name_with_prefix)
else:
log.warning('No ids or filter provided for delete operation')

View file

@ -3,7 +3,7 @@ NOTE: This vector database integration is community-supported and maintained on
"""
import logging
from typing import Optional
from typing import Any, Optional
from urllib.parse import urlparse
from open_webui.config import (
@ -22,6 +22,7 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import iter_filter_conditions
from qdrant_client import QdrantClient as Qclient
from qdrant_client.http.models import PointStruct
from qdrant_client.models import models
@ -31,6 +32,11 @@ NO_LIMIT = 999999999
log = logging.getLogger(__name__)
def _metadata_filter(key: str, op: str, value: Any) -> models.FieldCondition:
match = models.MatchAny(any=value) if op == '$in' else models.MatchValue(value=value)
return models.FieldCondition(key=f'metadata.{key}', match=match)
class QdrantClient(VectorDBBase):
def __init__(self):
self.collection_prefix = QDRANT_COLLECTION_PREFIX
@ -119,7 +125,7 @@ class QdrantClient(VectorDBBase):
on_disk=self.QDRANT_ON_DISK,
),
)
log.info(f'collection {collection_name_with_prefix} successfully created!')
log.info('collection %s successfully created!', collection_name_with_prefix)
def _create_collection_if_not_exists(self, collection_name, dimension):
if not self.has_collection(collection_name=collection_name):
@ -152,10 +158,13 @@ class QdrantClient(VectorDBBase):
if limit is None:
limit = NO_LIMIT # otherwise qdrant would set limit to 10!
conditions = [_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter)]
query_filter = models.Filter(must=conditions) if conditions else None
query_response = self.client.query_points(
collection_name=f'{self.collection_prefix}_{collection_name}',
query=vectors[0],
limit=limit,
query_filter=query_filter,
)
get_result = self._result_to_get_result(query_response.points)
return SearchResult(

View file

@ -23,6 +23,7 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import iter_filter_conditions
from qdrant_client import QdrantClient as Qclient
from qdrant_client.http.exceptions import UnexpectedResponse
from qdrant_client.http.models import PointStruct
@ -39,8 +40,9 @@ def _tenant_filter(tenant_id: str) -> models.FieldCondition:
return models.FieldCondition(key=TENANT_ID_FIELD, match=models.MatchValue(value=tenant_id))
def _metadata_filter(key: str, value: Any) -> models.FieldCondition:
return models.FieldCondition(key=f'metadata.{key}', match=models.MatchValue(value=value))
def _metadata_filter(key: str, op: str, value: Any) -> models.FieldCondition:
match = models.MatchAny(any=value) if op == '$in' else models.MatchValue(value=value)
return models.FieldCondition(key=f'metadata.{key}', match=match)
class QdrantClient(VectorDBBase):
@ -148,7 +150,7 @@ class QdrantClient(VectorDBBase):
m=0,
),
)
log.info(f'Multi-tenant collection {mt_collection_name} created with dimension {dimension}!')
log.info('Multi-tenant collection %s created with dimension %s!', mt_collection_name, dimension)
self.client.create_payload_index(
collection_name=mt_collection_name,
@ -224,7 +226,7 @@ class QdrantClient(VectorDBBase):
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
if not self.client.collection_exists(collection_name=mt_collection):
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
log.debug("Collection %s doesn't exist, nothing to delete", mt_collection)
return None
must_conditions = [_tenant_filter(tenant_id)]
@ -234,7 +236,7 @@ class QdrantClient(VectorDBBase):
# whose payload omits an id (e.g. memories), leaving orphaned vectors.
must_conditions.append(models.HasIdCondition(has_id=ids))
elif filter:
must_conditions += [_metadata_filter(k, v) for k, v in filter.items()]
must_conditions += [_metadata_filter(k, '$eq', v) for k, v in filter.items()]
return self.client.delete(
collection_name=mt_collection,
@ -255,15 +257,17 @@ class QdrantClient(VectorDBBase):
return None
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
if not self.client.collection_exists(collection_name=mt_collection):
log.debug(f"Collection {mt_collection} doesn't exist, search returns None")
log.debug("Collection %s doesn't exist, search returns None", mt_collection)
return None
tenant_filter = _tenant_filter(tenant_id)
conditions = [_tenant_filter(tenant_id)]
if filter:
conditions.extend(_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter))
query_response = self.client.query_points(
collection_name=mt_collection,
query=vectors[0],
limit=limit,
query_filter=models.Filter(must=[tenant_filter]),
query_filter=models.Filter(must=conditions),
)
get_result = self._result_to_get_result(query_response.points)
return SearchResult(
@ -281,12 +285,12 @@ class QdrantClient(VectorDBBase):
return None
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
if not self.client.collection_exists(collection_name=mt_collection):
log.debug(f"Collection {mt_collection} doesn't exist, query returns None")
log.debug("Collection %s doesn't exist, query returns None", mt_collection)
return None
if limit is None:
limit = NO_LIMIT
tenant_filter = _tenant_filter(tenant_id)
field_conditions = [_metadata_filter(k, v) for k, v in filter.items()]
field_conditions = [_metadata_filter(k, '$eq', v) for k, v in filter.items()]
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
points = self.client.scroll(
collection_name=mt_collection,
@ -303,7 +307,7 @@ class QdrantClient(VectorDBBase):
return None
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
if not self.client.collection_exists(collection_name=mt_collection):
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
log.debug("Collection %s doesn't exist, get returns None", mt_collection)
return None
tenant_filter = _tenant_filter(tenant_id)
points = self.client.scroll(
@ -350,7 +354,7 @@ class QdrantClient(VectorDBBase):
return None
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
if not self.client.collection_exists(collection_name=mt_collection):
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
log.debug("Collection %s doesn't exist, nothing to delete", mt_collection)
return None
self.client.delete(
collection_name=mt_collection,

View file

@ -13,7 +13,7 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.utils import metadata_matches_filter, normalize_filter, process_metadata
log = logging.getLogger(__name__)
@ -36,7 +36,7 @@ class S3VectorClient(VectorDBBase):
if self.bucket_name and self.region:
try:
self.client = boto3.client('s3vectors', region_name=self.region)
log.info(f"S3Vector client initialized for bucket '{self.bucket_name}' in region '{self.region}'")
log.info("S3Vector client initialized for bucket '%s' in region '%s'", self.bucket_name, self.region)
except Exception as e:
log.error(f'Failed to initialize S3Vector client: {e}')
self.client = None
@ -54,7 +54,7 @@ class S3VectorClient(VectorDBBase):
Create a new index in the S3 vector bucket for the given collection if it does not exist.
"""
if self.has_collection(index_name):
log.debug(f"Index '{index_name}' already exists, skipping creation")
log.debug("Index '%s' already exists, skipping creation", index_name)
return
try:
@ -70,7 +70,9 @@ class S3VectorClient(VectorDBBase):
]
},
)
log.info(f'Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})')
log.info(
'Created S3 index: %s (dim=%s, type=%s, metric=%s)', index_name, dimension, data_type, distance_metric
)
except Exception as e:
log.error(f"Error creating S3 index '{index_name}': {e}")
raise
@ -137,9 +139,9 @@ class S3VectorClient(VectorDBBase):
return
try:
log.info(f"Deleting collection '{collection_name}'")
log.info("Deleting collection '%s'", collection_name)
self.client.delete_index(vectorBucketName=self.bucket_name, indexName=collection_name)
log.info(f"Successfully deleted collection '{collection_name}'")
log.info("Successfully deleted collection '%s'", collection_name)
except Exception as e:
log.error(f"Error deleting collection '{collection_name}': {e}")
raise
@ -156,7 +158,7 @@ class S3VectorClient(VectorDBBase):
try:
if not self.has_collection(collection_name):
log.info(f"Index '{collection_name}' does not exist. Creating index.")
log.info("Index '%s' does not exist. Creating index.", collection_name)
self._create_index(
index_name=collection_name,
dimension=dimension,
@ -202,9 +204,11 @@ class S3VectorClient(VectorDBBase):
indexName=collection_name,
vectors=batch,
)
log.info(f"Inserted batch {i // batch_size + 1}: {len(batch)} vectors into index '{collection_name}'.")
log.info(
"Inserted batch %s: %s vectors into index '%s'.", i // batch_size + 1, len(batch), collection_name
)
log.info(f"Completed insertion of {len(vectors)} vectors into index '{collection_name}'.")
log.info("Completed insertion of %s vectors into index '%s'.", len(vectors), collection_name)
except Exception as e:
log.error(f'Error inserting vectors: {e}')
raise
@ -218,11 +222,11 @@ class S3VectorClient(VectorDBBase):
return
dimension = len(items[0]['vector'])
log.info(f'Upsert dimension: {dimension}')
log.info('Upsert dimension: %s', dimension)
try:
if not self.has_collection(collection_name):
log.info(f"Index '{collection_name}' does not exist. Creating index for upsert.")
log.info("Index '%s' does not exist. Creating index for upsert.", collection_name)
self._create_index(
index_name=collection_name,
dimension=dimension,
@ -264,10 +268,14 @@ class S3VectorClient(VectorDBBase):
batch = vectors[i : i + batch_size]
if i == 0: # Log sample info for first batch only
log.info(
f'Upserting batch 1: {len(batch)} vectors. First vector sample: key={batch[0]["key"]}, data_type={type(batch[0]["data"]["float32"])}, data_len={len(batch[0]["data"]["float32"])}'
'Upserting batch 1: %s vectors. First vector sample: key=%s, data_type=%s, data_len=%s',
len(batch),
batch[0]['key'],
type(batch[0]['data']['float32']),
len(batch[0]['data']['float32']),
)
else:
log.info(f'Upserting batch {i // batch_size + 1}: {len(batch)} vectors.')
log.info('Upserting batch %s: %s vectors.', i // batch_size + 1, len(batch))
self.client.put_vectors(
vectorBucketName=self.bucket_name,
@ -275,7 +283,7 @@ class S3VectorClient(VectorDBBase):
vectors=batch,
)
log.info(f"Completed upsert of {len(vectors)} vectors into index '{collection_name}'.")
log.info("Completed upsert of %s vectors into index '%s'.", len(vectors), collection_name)
except Exception as e:
log.error(f'Error upserting vectors: {e}')
raise
@ -300,7 +308,8 @@ class S3VectorClient(VectorDBBase):
return None
try:
log.info(f"Searching collection '{collection_name}' with {len(vectors)} query vectors, limit={limit}")
log.info("Searching collection '%s' with %s query vectors, limit=%s", collection_name, len(vectors), limit)
vector_filter = normalize_filter(filter)
# Initialize result lists
all_ids = []
@ -310,20 +319,23 @@ class S3VectorClient(VectorDBBase):
# Process each query vector
for i, query_vector in enumerate(vectors):
log.debug(f'Processing query vector {i + 1}/{len(vectors)}')
log.debug('Processing query vector %s/%s', i + 1, len(vectors))
# Prepare the query vector in S3 Vector format
query_vector_dict = {'float32': [float(x) for x in query_vector]}
# Call S3 Vector query API
response = self.client.query_vectors(
vectorBucketName=self.bucket_name,
indexName=collection_name,
topK=limit,
queryVector=query_vector_dict,
returnMetadata=True,
returnDistance=True,
)
request_params = {
'vectorBucketName': self.bucket_name,
'indexName': collection_name,
'topK': limit,
'queryVector': query_vector_dict,
'returnMetadata': True,
'returnDistance': True,
}
if vector_filter:
request_params['filter'] = vector_filter
response = self.client.query_vectors(**request_params)
# Process results for this query
query_ids = []
@ -338,6 +350,9 @@ class S3VectorClient(VectorDBBase):
vector_metadata = vector.get('metadata', {})
vector_distance = vector.get('distance', 0.0)
if vector_filter and not metadata_matches_filter(vector_metadata, vector_filter):
continue
# Extract document text from metadata
document_text = ''
if isinstance(vector_metadata, dict):
@ -362,7 +377,7 @@ class S3VectorClient(VectorDBBase):
all_metadatas.append(query_metadatas)
all_distances.append(query_distances)
log.info(f'Search completed. Found results for {len(all_ids)} queries')
log.info('Search completed. Found results for %s queries', len(all_ids))
# Return SearchResult format
return SearchResult(
@ -402,7 +417,7 @@ class S3VectorClient(VectorDBBase):
return self.get(collection_name)
try:
log.info(f"Querying collection '{collection_name}' with filter: {filter}")
log.info("Querying collection '%s' with filter: %s", collection_name, filter)
# For S3 Vector, we need to use list_vectors and then filter results
# Since S3 Vector may not support complex server-side filtering,
@ -437,7 +452,7 @@ class S3VectorClient(VectorDBBase):
if limit and len(filtered_ids) >= limit:
break
log.info(f'Filter applied: {len(filtered_ids)} vectors match out of {len(all_ids)} total')
log.info('Filter applied: %s vectors match out of %s total', len(filtered_ids), len(all_ids))
# Return GetResult format
if filtered_ids:
@ -472,7 +487,7 @@ class S3VectorClient(VectorDBBase):
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
try:
log.info(f"Retrieving all vectors from collection '{collection_name}'")
log.info("Retrieving all vectors from collection '%s'", collection_name)
# Initialize result lists
all_ids = []
@ -521,7 +536,7 @@ class S3VectorClient(VectorDBBase):
)
# Log the actual content for debugging
log.debug(f'Document text preview (first 200 chars): {str(document_text)[:200]}')
log.debug('Document text preview (first 200 chars): %s', str(document_text)[:200])
else:
document_text = vector_id
@ -534,7 +549,7 @@ class S3VectorClient(VectorDBBase):
if not next_token:
break
log.info(f"Retrieved {len(all_ids)} vectors from collection '{collection_name}'")
log.info("Retrieved %s vectors from collection '%s'", len(all_ids), collection_name)
# Return in GetResult format
# The Open WebUI GetResult expects lists of lists, so we wrap each list
@ -576,17 +591,17 @@ class S3VectorClient(VectorDBBase):
try:
if ids:
# Delete by specific vector IDs/keys
log.info(f"Deleting {len(ids)} vectors by IDs from collection '{collection_name}'")
log.info("Deleting %s vectors by IDs from collection '%s'", len(ids), collection_name)
self.client.delete_vectors(
vectorBucketName=self.bucket_name,
indexName=collection_name,
keys=ids,
)
log.info(f"Deleted {len(ids)} vectors from index '{collection_name}'")
log.info("Deleted %s vectors from index '%s'", len(ids), collection_name)
elif filter:
# Handle filter-based deletion
log.info(f"Deleting vectors by filter from collection '{collection_name}': {filter}")
log.info("Deleting vectors by filter from collection '%s': %s", collection_name, filter)
# If this is a knowledge collection and we have a file_id filter,
# also clean up the corresponding file-specific collection
@ -595,7 +610,8 @@ class S3VectorClient(VectorDBBase):
file_collection_name = f'file-{file_id}'
if self.has_collection(file_collection_name):
log.info(
f"Found related file-specific collection '{file_collection_name}', deleting it to prevent duplicates"
"Found related file-specific collection '%s', deleting it to prevent duplicates",
file_collection_name,
)
self.delete_collection(file_collection_name)
@ -604,7 +620,7 @@ class S3VectorClient(VectorDBBase):
query_result = self.query(collection_name, filter)
if query_result and query_result.ids and query_result.ids[0]:
matching_ids = query_result.ids[0]
log.info(f'Found {len(matching_ids)} vectors matching filter, deleting them')
log.info('Found %s vectors matching filter, deleting them', len(matching_ids))
# Delete the matching vectors by ID
self.client.delete_vectors(
@ -612,7 +628,7 @@ class S3VectorClient(VectorDBBase):
indexName=collection_name,
keys=matching_ids,
)
log.info(f"Deleted {len(matching_ids)} vectors from index '{collection_name}' using filter")
log.info("Deleted %s vectors from index '%s' using filter", len(matching_ids), collection_name)
else:
log.warning('No vectors found matching the filter criteria')
else:
@ -645,11 +661,11 @@ class S3VectorClient(VectorDBBase):
try:
self.client.delete_index(vectorBucketName=self.bucket_name, indexName=index_name)
deleted_count += 1
log.info(f'Deleted index: {index_name}')
log.info('Deleted index: %s', index_name)
except Exception as e:
log.error(f"Error deleting index '{index_name}': {e}")
log.info(f'Reset completed: deleted {deleted_count} indexes')
log.info('Reset completed: deleted %s indexes', deleted_count)
except Exception as e:
log.error(f'Error during reset: {e}')

View file

@ -2,7 +2,6 @@
# Requires Valkey core >= 9.0.1 with the valkey-search module >= 1.2.0 loaded.
import atexit
import json
import logging
import re
import struct
@ -24,6 +23,7 @@ from open_webui.retrieval.vector.main import (
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.utils.json_codec import JSONCodec
log = logging.getLogger(__name__)
@ -279,7 +279,7 @@ class ValkeyClient(VectorDBBase):
f'{self._format_version(MIN_VALKEY_VERSION)}. valkey-search 1.2.0 requires Valkey core '
'9.0.1 or later. Upgrade your server or use valkey-bundle:9.1.0-rc2+.'
)
log.info(f'Valkey core version: {self._format_version(version) if version else "unknown"}')
log.info('Valkey core version: %s', self._format_version(version) if version else 'unknown')
def _check_search_module(self) -> None:
try:
@ -331,7 +331,7 @@ class ValkeyClient(VectorDBBase):
'TEXT field type and filter-only FT.SEARCH support required by this backend. '
'Upgrade to valkey-bundle:9.1.0-rc2+ or load valkey-search 1.2.0+ as a module.'
)
log.info(f'valkey-search version: {self._format_version(search_version) if search_version else "unknown"}')
log.info('valkey-search version: %s', self._format_version(search_version) if search_version else 'unknown')
def _index_name(self, collection_name: str) -> str:
return f'idx:{self.collection_prefix}:{collection_name}'
@ -385,12 +385,15 @@ class ValkeyClient(VectorDBBase):
try:
g['glide_ft'].create(self.client, index_name, schema, options)
log.info(
f'Created Valkey index {index_name} with dimension={dimension}, '
f'type={self.index_type}, metric={self.distance_metric}'
'Created Valkey index %s with dimension=%s, type=%s, metric=%s',
index_name,
dimension,
self.index_type,
self.distance_metric,
)
except g['RequestError'] as e:
if 'already exists' in str(e).lower():
log.debug(f'Index {index_name} already exists, skipping creation.')
log.debug('Index %s already exists, skipping creation.', index_name)
else:
raise
@ -456,9 +459,9 @@ class ValkeyClient(VectorDBBase):
index_name = self._index_name(collection_name)
try:
self._g['glide_ft'].dropindex(self.client, index_name)
log.info(f'Dropped index {index_name}')
log.info('Dropped index %s', index_name)
except self._g['RequestError'] as e:
log.debug(f'Could not drop index {index_name}: {e}')
log.debug('Could not drop index %s: %s', index_name, e)
self._delete_keys_by_prefix(self._key_prefix(collection_name))
@ -482,7 +485,7 @@ class ValkeyClient(VectorDBBase):
'id': item['id'],
'vector': _vector_to_bytes(item['vector']),
'text': item['text'],
'metadata_json': json.dumps(metadata),
'metadata_json': JSONCodec.dumps(metadata),
# `or ''` prevents indexing literal 'None' as a TAG value, which would
# poison $ne / equality queries.
'hash': str(metadata.get('hash') or ''),
@ -492,7 +495,7 @@ class ValkeyClient(VectorDBBase):
}
self.batch_client.hset(self._item_key(collection_name, item['id']), mapping)
log.debug(f'Inserted {len(items)} items into collection {collection_name}')
log.debug('Inserted %s items into collection %s', len(items), collection_name)
def upsert(self, collection_name: str, items: list[VectorItem]):
self.insert(collection_name, items)
@ -588,8 +591,8 @@ class ValkeyClient(VectorDBBase):
ids.append(_decode(fields.get(b'id', b'')))
documents.append(_decode(fields.get(b'text', b'')))
try:
metadatas.append(json.loads(_decode(fields.get(b'metadata_json', b'{}'))))
except (json.JSONDecodeError, TypeError):
metadatas.append(JSONCodec.loads(_decode(fields.get(b'metadata_json', b'{}'))))
except (ValueError, TypeError):
metadatas.append({})
if limit is not None and limit > 0 and len(ids) >= limit:
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
@ -656,7 +659,7 @@ class ValkeyClient(VectorDBBase):
collections.append(name[len(idx_prefix) :])
try:
glide_ft.dropindex(self.client, idx)
log.info(f'Dropped index: {name}')
log.info('Dropped index: %s', name)
except Exception as e:
log.error(f'Error dropping index {name}: {e}')
except Exception as e:
@ -664,7 +667,7 @@ class ValkeyClient(VectorDBBase):
for collection in collections:
self._delete_keys_by_prefix(self._key_prefix(collection))
log.info(f'Valkey vector store reset complete (prefix: {self.collection_prefix})')
log.info('Valkey vector store reset complete (prefix: %s)', self.collection_prefix)
def _delete_keys_by_prefix(self, prefix: str) -> None:
cursor = '0'
@ -734,8 +737,8 @@ class ValkeyClient(VectorDBBase):
ids.append(_decode(fields.get(b'id', b'')))
documents.append(_decode(fields.get(b'text', b'')))
try:
metadatas.append(json.loads(_decode(fields.get(b'metadata_json', b'{}'))))
except (json.JSONDecodeError, TypeError):
metadatas.append(JSONCodec.loads(_decode(fields.get(b'metadata_json', b'{}'))))
except (ValueError, TypeError):
metadatas.append({})
if include_score:

View file

@ -23,7 +23,7 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
def _convert_uuids_to_strings(obj: Any) -> Any:
@ -54,6 +54,20 @@ def _convert_uuids_to_strings(obj: Any) -> Any:
return obj
def _metadata_filter(filter: Optional[dict]) -> Any:
clauses = []
for key, op, value in iter_filter_conditions(filter):
if op == '$in':
clauses.append(
weaviate.classes.query.Filter.any_of(
[weaviate.classes.query.Filter.by_property(name=key).equal(item) for item in value]
)
)
else:
clauses.append(weaviate.classes.query.Filter.by_property(name=key).equal(value))
return weaviate.classes.query.Filter.all_of(clauses) if len(clauses) > 1 else (clauses[0] if clauses else None)
class WeaviateClient(VectorDBBase):
def __init__(self):
self.url = WEAVIATE_HTTP_HOST
@ -168,6 +182,7 @@ class WeaviateClient(VectorDBBase):
return None
collection = self.client.collections.get(sane_collection_name)
weaviate_filter = _metadata_filter(filter)
result_ids, result_documents, result_metadatas, result_distances = (
[],
@ -181,6 +196,7 @@ class WeaviateClient(VectorDBBase):
response = collection.query.near_vector(
near_vector=vector_embedding,
limit=limit,
filters=weaviate_filter,
return_metadata=weaviate.classes.query.MetadataQuery(distance=True),
)

View file

@ -1,16 +1,38 @@
import datetime as dt
from typing import Any
from open_webui.env import RAG_METADATA_MAX_VALUE_CHARS
from open_webui.retrieval.vector.main import SearchResult
from open_webui.utils.misc import sanitize_text_for_db
KEYS_TO_EXCLUDE = ['content', 'pages', 'tables', 'paragraphs', 'sections', 'figures']
KEYS_TO_EXCLUDE = [
'content',
'pages',
'tables',
'paragraphs',
'sections',
'figures',
'documents',
'keyValuePairs',
'styles',
'languages',
]
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
# Removes large/redundant fields from metadata dict.
metadata = {key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE}
return metadata
result = {}
for key, value in metadata.items():
if key in KEYS_TO_EXCLUDE:
continue
if RAG_METADATA_MAX_VALUE_CHARS is not None and isinstance(value, (list, dict)):
try:
if len(str(value)) > RAG_METADATA_MAX_VALUE_CHARS:
continue
except (MemoryError, RecursionError, ValueError):
continue
result[key] = value
return result
def process_metadata(
@ -25,6 +47,12 @@ def process_metadata(
continue
if value is None:
continue
if RAG_METADATA_MAX_VALUE_CHARS is not None and isinstance(value, (list, dict)):
try:
if len(str(value)) > RAG_METADATA_MAX_VALUE_CHARS:
continue
except (MemoryError, RecursionError, ValueError):
continue
# Convert non-serializable fields to strings
if isinstance(value, (dt.datetime, list, dict)):
result[key] = sanitize_text_for_db(str(value))
@ -33,6 +61,33 @@ def process_metadata(
return result
def iter_filter_conditions(filter: dict[str, Any] | None):
for key, value in (filter or {}).items():
if isinstance(value, dict):
if set(value) != {'$in'}:
raise ValueError(f"Unsupported metadata filter for '{key}': {value}")
yield key, '$in', list(value['$in'])
else:
yield key, '$eq', value
def normalize_filter(filter: dict[str, Any] | None) -> dict[str, Any]:
return {key: {'$in': value} if op == '$in' else value for key, op, value in iter_filter_conditions(filter)}
def metadata_matches_filter(metadata: dict[str, Any], filter: dict[str, Any] | None) -> bool:
if not isinstance(metadata, dict):
return False
for key, op, value in iter_filter_conditions(filter):
actual = metadata.get(key)
if op == '$in':
if actual not in value:
return False
elif actual != value:
return False
return True
def merge_hybrid_search_results(
vector_result: SearchResult | None,
fts_results: list[dict[str, Any]],

View file

@ -1,9 +1,9 @@
import json
import logging
from typing import Optional
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.json_codec import JSONCodec
log = logging.getLogger(__name__)
@ -41,7 +41,7 @@ def search_bocha(api_key: str, query: str, count: int, filter_list: Optional[lis
url = 'https://api.bochaai.com/v1/web-search?utm_source=ollama'
headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}
payload = json.dumps({'query': query, 'summary': True, 'freshness': 'noLimit', 'count': count})
payload = JSONCodec.dumps({'query': query, 'summary': True, 'freshness': 'noLimit', 'count': count})
response = requests.post(url, headers=headers, data=payload, timeout=5)
response.raise_for_status()

View file

@ -4,7 +4,6 @@ import logging
import urllib.request
from ddgs import DDGS
from ddgs.exceptions import RatelimitException
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
log = logging.getLogger(__name__)
@ -31,21 +30,11 @@ def search_duckduckgo(
# Resolve via stdlib getproxies() — same pattern as the other loaders.
env_proxies = urllib.request.getproxies()
proxy = env_proxies.get('https') or env_proxies.get('http')
search_results = []
with DDGS(proxy=proxy) as ddgs:
if concurrent_requests:
ddgs.threads = concurrent_requests
# Use the ddgs.text() method to perform the search
try:
kwargs = {'safesearch': 'moderate', 'max_results': count}
if backend and backend != 'auto':
kwargs['backend'] = backend
results = ddgs.text(query, **kwargs)
search_results = results if results is not None else []
except RatelimitException as e:
log.error(f'RatelimitException: {e}')
search_results = []
search_results = ddgs.text(query, safesearch='moderate', max_results=count, backend=backend or 'auto')
if filter_list:
search_results = get_filtered_results(search_results, filter_list)

View file

@ -31,7 +31,7 @@ def search_exa(
count (int): Number of results to return
filter_list (Optional[list[str]]): List of domains to filter results by
"""
log.info(f'Searching with Exa for query: {query}')
log.info('Searching with Exa for query: %s', query)
headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}
@ -58,7 +58,7 @@ def search_exa(
)
)
log.info(f'Found {len(results)} results')
log.info('Found %s results', len(results))
return [
SearchResult(
link=result.url,

View file

@ -21,6 +21,9 @@ def search_external(
) -> List[SearchResult]:
try:
headers = {
# LICENSE covers this Open WebUI user-agent identifier.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot',
'Authorization': f'Bearer {external_api_key}',
}
@ -50,7 +53,7 @@ def search_external(
)
for result in results[:count]
]
log.info(f'External search results: {results}')
log.info('External search results: %s', results)
return results
except Exception as e:
log.error(f'Error in External search: {e}')

View file

@ -28,10 +28,10 @@ def build_firecrawl_url(base_url: str | None, path: str) -> str:
def build_firecrawl_headers(api_key: str | None) -> dict[str, str]:
return {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key or ""}',
}
headers = {'Content-Type': 'application/json'}
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
return headers
def get_firecrawl_timeout_seconds(timeout: Any) -> float | None:
@ -225,7 +225,7 @@ def search_firecrawl(
)
)
log.info(f'FireCrawl search results: {search_results}')
log.info('FireCrawl search results: %s', search_results)
return search_results
except Exception as e:
log.error(f'Error in FireCrawl search: {e}')

View file

@ -4,7 +4,7 @@ from urllib.parse import urlparse
import validators
from open_webui.retrieval.web.utils import resolve_hostname
from open_webui.utils.misc import is_host_allowed
from open_webui.utils.misc import as_network, get_allow_block_lists, is_host_allowed
from pydantic import BaseModel
@ -12,6 +12,10 @@ def get_filtered_results(results, filter_list):
if not filter_list:
return results
allow_list, block_list = get_allow_block_lists(filter_list)
# Only worth a lookup when an entry names an address, since a hostname entry matches by name.
resolve_ips = any(as_network(entry) is not None for entry in allow_list + block_list)
filtered_results = []
for result in results:
@ -19,18 +23,19 @@ def get_filtered_results(results, filter_list):
if not validators.url(url):
continue
domain = urlparse(url).netloc
domain = urlparse(url).hostname
if not domain:
continue
hostnames = [domain]
try:
ipv4_addresses, ipv6_addresses = resolve_hostname(domain)
hostnames.extend(ipv4_addresses)
hostnames.extend(ipv6_addresses)
except Exception:
pass
if resolve_ips:
try:
ipv4_addresses, ipv6_addresses = resolve_hostname(domain)
hostnames.extend(ipv4_addresses)
hostnames.extend(ipv6_addresses)
except Exception:
pass
if is_host_allowed(hostnames, filter_list):
filtered_results.append(result)

View file

@ -9,8 +9,6 @@ from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL = 'https://api.microsoft.ai/v3'
def search_microsoft_web_iq(
api_base_url: str,
@ -22,7 +20,7 @@ def search_microsoft_web_iq(
user=None,
) -> list[SearchResult]:
try:
api_base_url = (api_base_url or DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL).rstrip('/')
api_base_url = api_base_url.rstrip('/')
headers = {
'host': urlparse(api_base_url).netloc or 'api.microsoft.ai',
'x-apikey': api_key,

View file

@ -23,7 +23,7 @@ def search_ollama_cloud(
count (int): Number of results to return
filter_list (Optional[list[str]]): List of domains to filter results by
"""
log.info(f'Searching with Ollama for query: {query}')
log.info('Searching with Ollama for query: %s', query)
headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}
payload = {'query': query, 'max_results': count}
@ -34,7 +34,7 @@ def search_ollama_cloud(
data = response.json()
results = data.get('results', [])
log.info(f'Found {len(results)} results')
log.info('Found %s results', len(results))
if filter_list:
results = get_filtered_results(results, filter_list)

View file

@ -0,0 +1,45 @@
from __future__ import annotations
import logging
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.session_pool import get_session
log = logging.getLogger(__name__)
async def search_openserp(
base_url: str,
query: str,
count: int,
filter_list: list[str | None] | None = None,
) -> list[SearchResult]:
"""Query an OpenSERP instance and return normalised results.
OpenSERP aggregates results from 6 engines (google, bing, yandex,
baidu, duckduckgo, ecosia) at once via ``/mega/search``.
No API key is required -- only a reachable OpenSERP base URL.
"""
url = f'{base_url.rstrip("/")}/mega/search'
params = {'text': query, 'limit': count}
log.debug('searching OpenSERP at %s', url)
session = await get_session()
async with session.get(url, params=params) as response:
response.raise_for_status()
payload = await response.json()
results = payload.get('results', [])
if filter_list:
results = get_filtered_results(results, filter_list)
return [
SearchResult(
link=item.get('url', ''),
title=item.get('title'),
snippet=item.get('snippet'),
)
for item in results[:count]
]

View file

@ -31,7 +31,7 @@ def search_searchapi(
response = requests.request('GET', url)
json_response = response.json()
log.info(f'results from searchapi search: {json_response}')
log.info('results from searchapi search: %s', json_response)
results = sorted(json_response.get('organic_results', []), key=lambda x: x.get('position', 0))
if filter_list:

View file

@ -1,7 +1,10 @@
from __future__ import annotations
import logging
import ssl
from functools import lru_cache
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, SEARXNG_CLIENT_CERT_FILE, SEARXNG_CLIENT_KEY_FILE
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.session_pool import get_session
@ -9,6 +12,9 @@ log = logging.getLogger(__name__)
# SearXNG request headers — identifies the bot to instance operators.
_SEARXNG_HEADERS = {
# LICENSE covers this Open WebUI user-agent identifier.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot',
'Accept': 'text/html',
'Accept-Encoding': 'gzip, deflate',
@ -17,6 +23,19 @@ _SEARXNG_HEADERS = {
}
@lru_cache
def _get_ssl_context() -> bool | ssl.SSLContext:
if not SEARXNG_CLIENT_CERT_FILE:
return AIOHTTP_CLIENT_SESSION_SSL
ssl_context = ssl.create_default_context()
ssl_context.load_cert_chain(
certfile=SEARXNG_CLIENT_CERT_FILE,
keyfile=SEARXNG_CLIENT_KEY_FILE or None,
)
return ssl_context
async def search_searxng(
query_url: str,
query: str,
@ -48,7 +67,12 @@ async def search_searxng(
log.debug('searching %s', query_url)
session = await get_session()
async with session.get(query_url, headers=_SEARXNG_HEADERS, params=params) as response:
async with session.get(
query_url,
headers=_SEARXNG_HEADERS,
params=params,
ssl=_get_ssl_context(),
) as response:
response.raise_for_status()
payload = await response.json()

View file

@ -31,7 +31,7 @@ def search_serpapi(
response = requests.request('GET', url)
json_response = response.json()
log.info(f'results from serpapi search: {json_response}')
log.info('results from serpapi search: %s', json_response)
results = sorted(json_response.get('organic_results', []), key=lambda x: x.get('position', 0))
if filter_list:

Some files were not shown because too many files have changed in this diff Show more