Sending an image or file with no typed text to a model with skills
bound replaced the (empty) message text with the list of skill names,
so the model answered with its skill catalog instead of handling the
attachment. The empty-message guard added for #24929 fired on any
empty last user text without checking for attachments.
The guard now skips the fallback when the current message carries
files, read from the user_message object the frontend sends with the
request. Attachment-only messages then go out exactly as they do on
models without skills. A bare skill selection with no attachment still
gets the fallback, so the provider 400 from #24929 stays fixed.
Request-level files were not usable as the signal: that list carries
the model's knowledge and folder files, so a bare skill selection on a
knowledge model would have gone out empty again.
Fixes#30040
Fixes#27750
When a model runs on the Responses API, every Chat Completions style tool (workspace tools, MCP and OpenAPI servers) is converted to the Responses tool shape. Only an explicit strict value was carried over, so tools without one were sent with strict omitted. Chat Completions defaults strict to false, the Responses API defaults it to true, so the conversion silently switched loose schemas into strict mode. The model then filled every optional property (empty strings, zeros, false, empty arrays) and combined mutually exclusive filters, and those arguments went to the tool unchanged. Search and filter tools received meaningful values where omission was intended and rejected the call or returned wrong results.
The converter now defaults strict to false when the source tool does not set it. Explicit true or false values are still preserved and already native Responses tools are passed through untouched, matching what the same tool definition gets on Chat Completions.
The comments on the opt-in in-place append made it sound like the fast path
can lose streamed text under normal operation. The only way the field can be
emptied is an allocation failure, which means the host is already out of
memory, and the default path (which copies the whole accumulated string on
every chunk) raises in that situation as well. Reword both comments to state
that condition so the flag is not read as unsafe.
Asked to add a section or change a few lines, models answer with a whole-note
replace_note_content call and the rest of the note is gone, with nothing to
undo because the editor only records versions on chat inserts. The tool
already supports replace_range operations with an expected guard, but its
description never said when to use them or how the offsets work, so models
defaulted to sending the whole note back.
The docstring, which is the description every model receives for this tool
in note chats and normal chats alike, now states the preference for range
edits and the offset, overlap and expected rules the handler enforces.
Verified the text lands in the generated tool spec unchanged and that range
edits, the expected mismatch rejection and whole-note replace behave as
described against a sqlite data dir.
Fetching a single URL through the Playwright loader (fetch_url tool, a URL attached to a
chat, the process/web endpoint) never returned when the page opened a WebSocket. The worker
thread stayed at 100% CPU for the life of the process, and every further hit cost another
core, so the whole instance got slow. Web search was unaffected, it uses the async loader.
The sync loader's websocket route handler called the synchronous close(). Playwright runs
websocket route handlers directly on its dispatcher fiber, so that call waited on the very
loop it was blocking and busy-spun forever. The handler is now a no-op: a routed socket only
reaches the network when the handler asks for it, so the page still cannot dial out, and
nothing in the handler waits on the dispatcher any more.
Aborting the upgrade request from the HTTP route handler instead does not work, page.route
never sees WebSocket handshakes and the connection goes through.
Fixes#30024
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/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: 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_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
When Redis is unreachable at shutdown, the socket cleanup tasks do not stop. release_lock is a bare eval called from the finally of both periodic_session_pool_cleanup and periodic_usage_pool_cleanup, so it raises there and replaces the CancelledError already in flight. The usage task's except Exception then catches the Redis error and carries on reaping after shutdown cancelled it, and the session task ends with a ConnectionError in place of its cancellation.
release_lock now logs the failure and returns. aquire_lock sets the key with ex=self.timeout_secs and renew_lock re-expires it with the same value, so a release that never lands costs at most one lock timeout before another node can take over.
The except names both RedisClusterException and RedisError because the cluster-only types subclass Exception directly, and redis_cluster is a supported configuration, so RedisError alone would miss the outage on a cluster. Genuine bugs still propagate.
Verified against a real Redis: acquire, refusal while held, renew, compare-and-delete release and non-owner release are unchanged, a populated pool reaps identically with identical lock TTL lifecycles, and both tasks now cancel cleanly where before one kept running and the other died with the wrong exception.
An authenticated socket client can grow Open WebUI's memory without bound by sending ydoc updates for an empty document id. create_task files every task under item_tasks[id] whatever the id, while cleanup_task removes it only for a truthy id, so each update leaves a uuid behind for the process lifetime. normalize_document_id passes an empty id through, and such an id also skips the note access check.
create_task now files the task only when an id is provided, which is what its own comment already described and what the rest of the file does: redis_save_task and redis_cleanup_task both guard on a truthy item id, and stop_task normalizes a falsy one away. Nothing is filed, so nothing leaks, and cleanup_task's existing guard correctly no-ops.
This also settles a disagreement between the two backends. For an empty id, list_task_ids_by_item_id, has_active_tasks and stop_item_tasks answered one way with Redis and another without it; they now match. The visible consequence is that an instance without Redis no longer cancels a pending save for an empty document id, which is how Redis instances already behaved.
Measured over 300 calls with an empty id: 300 stale entries before, none after. Behaviour for a normal id is unchanged, including ordering, cancellation and key removal when the last task finishes.
A slow Redis freezes the whole worker during sign-in, not just the user signing in. RateLimiter held a synchronous redis-py client and signin called is_limited inline from a coroutine, so every attempt did blocking round trips on the event-loop thread, with REDIS_SOCKET_TIMEOUT defaulting to None so nothing bounded the wait. Its Redis methods are now async and take the handle as their first argument, and both handlers pass request.app.state.redis, the async client the lifespan already creates. Building one in the limiter instead would pin its pooled connection to the first event loop that used it.
Without Redis, which is the default single-instance setup, the fallback store leaked. It was keyed by the rate-limit key and pruned a key's expired buckets only when that same key was checked again, so a login email never seen again was never reclaimed, and that email comes straight from an unauthenticated request body. It is now keyed by bucket, so one prune drops every key an expired bucket held, and it lives on the instance: pruning uses the per-instance num_buckets, so a shared store would let a limiter with a short window delete buckets a longer-windowed one still needs.
With a Redis costing a second per call, the widest event-loop tick gap drops from 2.010s to 0.010s and a concurrent request is answered at 0.05s instead of 2.05s, at no cost to the caller's own latency. Across 20,000 distinct keys the store goes from 40,000 entries and 6.4 MB, growing linearly, to a flat 1,004 entries and 100 KB. Rate-limiting decisions are unchanged across 700,000 randomised calls over 14 window, bucket and limit combinations, against a real Redis and the in-memory fallback alike, and sign-in still returns its first 429 on attempt 16.
Two behaviour changes worth naming. Pruning is now global rather than per key, so a wall clock that jumps forward past a full window and back forgets a hit it previously kept. The two limiters also stop sharing a store, which previously let a sign-in attempt with an IP-shaped email touch the token-exchange limiter's counters.
A single Redis blip permanently stops orphaned websocket sessions from being reaped. periodic_session_pool_cleanup acquires its lock outside the try, and that try has only a finally, so the first timeout or connection reset ends the coroutine for the life of the process. The session pool then only grows, and the sole trace is one "Task exception was never retrieved" at shutdown.
The loop body gets the same try/except Exception its sibling periodic_usage_pool_cleanup already has, which also brings the lock acquire inside the guarded region. The task now logs, releases the lock and retries after the existing delay, so another node can take the lock over meanwhile.
The diff reads long because the body is re-indented one level; nothing changes beyond indentation and the four added lines. Only Redis deployments are affected, since the lock functions are lambda: True otherwise.
Verified by injecting a ConnectionError at each of the four failure points (acquire, renew, the batch scan, the reaping delete), against a real Redis as well: the task survives all four and keeps retrying, where it previously died on the first. Reaping results, lock acquire and release counts, and cancellation at shutdown are unchanged.
Any signed-in user can grow Open WebUI's memory without bound by posting model entries with invalid profile image URLs. ModelMeta's validator keeps a set of every distinct rejected value so it warns about each one once, storing the full string with no size cap. FastAPI validates the request body before create_new_model reaches its workspace.models permission check, so the value is retained even when the caller is refused with a 401.
The set and the warning it served both go away; the validator clears the value exactly as before. The warning named no model and truncated the value at 80 characters, so it identified nothing. models/users.py swallows the identical ValueError and substitutes a fallback with no logging, so silence matches the neighbouring code.
Measured over the real create route with distinct 4KB invalid values: 8.9 MB retained at 2,000 values and 33.7 MB at 8,000 before, flat at 1.1 MB after.
Deleting a tool or a function leaves its full source text in memory for the life of the process. Each delete handler pops the module cache and leaves the matching content cache untouched, so the code of every plugin ever deleted stays resident.
Both handlers now pop the content cache next to the module cache. Measured over 200 deletes of an 8.8KB plugin: 1.78 MB of source retained per kind before, nothing after.
This is memory only, never a stale module. A cache hit needs the id present in both caches and delete already popped the module cache, so the leftover source could not have produced one.
The two routers are one change because it is the same pair of lines at the same point in sibling handlers, with no per-site reasoning and no reason to revert one without the other.
The embed flag defaults to off and is now forwarded through every markdown render path, including the standalone tool-call branch, colon fences and alerts, so embeds render only where a call site opts in. Assistant responses opt in for the viewer's own chat; channel messages pass it explicitly off.
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
The model listing routes accept the backend index as a path segment and as a query parameter on the index-less sibling route, so the check now runs in the handler and applies to both forms.
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 / 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
Pressing stop saved the assistant message as finished while its output items were still marked as running, so the affected block kept showing "Thinking...", "Analyzing..." or "Executing...". That state was written to the database, so it came back after every reload. A tool call cancelled mid-execution was affected as well: it is marked completed as soon as its arguments finish streaming, well before the tool returns, and the client reads that as still executing until the result item arrives.
Cancelling now closes those items before the message is saved, marking them incomplete, which is the status the client already treats as finished without flagging an error. Tool calls waiting for approval are left untouched so their approval prompt survives a reload. The corrected output is sent on the chat:tasks:cancel event that was already being emitted, so an open tab settles immediately instead of only after a reload.
Emitting a chat:completion event instead would have fired response auto-copy, text to speech playback and the chat finished listeners on a response the user had just cancelled.
Verified on a running instance against a mock upstream: cancelling mid-response leaves no item marked as running in the saved message, and the identical run on unpatched code leaves one.
Fixes#29281
Syncing a knowledge base from a local directory only ever showed a
generic "Error accessing directory" toast when a per-file read
failed, and production builds strip console.error, so nothing else
recorded the cause either. On Windows this hides a real Chromium bug
where files with very long absolute paths throw a NotFoundError from
the File System Access API, leaving no way to tell which file failed
or why.
Both per-file read paths (the directory picker and drag-and-drop) now
wrap read failures with the file's relative path and the underlying
browser error before they reach the toast, so the message actually
names the file and the reason. Reuses the existing translated toast
text instead of adding a new i18n key, so the fix doesn't orphan
existing translations of that string.
When a tool returns a base64 image, Open WebUI only moves it out of the model's context if the entire result is that image, or if the tool is MCP. An image sitting inside a returned object or a list was serialised into the tool message instead, so a single screenshot could cost hundreds of thousands of tokens, push out the rest of the conversation and leave the model answering from garbage.
Any string in a tool result that is exactly one image data URI is now moved into the result's files wherever it sits in the structure, and replaced with a short marker. The model gets the image as an attachment rather than as text, and it renders for the user instead of being dropped.
Detection is deliberately limited to values that are entirely a data URI. Scanning inside longer strings was tried and abandoned: a payload wrapped over several lines, or one followed by text, gets cut short, and shipping a truncated image is worse than the bloat because the provider rejects the whole request.
This also fixes the OpenAPI branch removing entries from the list it was iterating over, which skipped every second data URI and left it in the model's context.
Fixes#29208