Custom metadata attached to a file upload now reaches the vector DB, but the model still never sees it. Both prompt-assembly paths build their output from a fixed field set: the classic RAG <source> tag carries only id, name and resource type, and the retrieval tools return only content, source and file id per chunk. A scraper that records where each document came from therefore cannot get that origin in front of the model, so answers cannot state it.
RAG_SOURCE_METADATA_KEYS names the chunk metadata keys allowed through to the model. Configured keys are emitted as extra attributes on the <source> tag and as extra fields on tool result chunks, covering both retrieval paths. It is empty by default, so nothing changes for existing deployments.
An allowlist instead of passing everything through, because chunk metadata also carries file hashes, collection names, embedding config and relevance scores, which would then be added to every retrieved chunk of every request. Values are attacker-controllable through an uploaded file, so they are escaped before they go into the tag, and a configured key can never displace a field the tag or the chunk already defines.
Reported in open-webui/open-webui#29486.
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
Uploading an .svg to a chat attached it as a vision image input, so the model received a data URI it could not decode. PIL-backed servers answered "cannot identify image file" and OpenAI answered "The image data you provided does not represent a valid image". No setting made it work.
SVG now takes the ordinary file upload path, so its XML source is extracted and indexed and the model can answer questions about it. Rasterizing was the alternative and it would have discarded the part of an SVG a model reads best, the source itself. Raster formats are untouched and still go up as image inputs.
A shared helper replaces the ad hoc image/ prefix checks at the points that decide image input versus document, on both ends. It normalises the content type first, because a stored "image/SVG+xml" or a trailing charset parameter slipped past a plain comparison.
One behaviour change worth knowing: an SVG now needs the model to have the file upload capability, where before it rode in as an image.
Fixes#30100
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
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.
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 / 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
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
Some endpoints stream a tool call whose function name is JSON null instead of a string. Nothing normalized it, so the null stayed on the tool call, was written into the stored message, and was sent back to the endpoint in the assistant message on the next turn, where a null is not a valid function name.
The delta accumulator now replaces a null name with an empty string, at the same point it already normalizes the arguments field. The call still fails as an unknown tool, which is the right outcome for a call that has no name, so the result is one failed tool call instead of a follow-up request the endpoint has to reject.
This is done where the delta enters the accumulator rather than at the consumers, because the name is emitted to the client and persisted while the response is still streaming, before anything downstream could clean it up.
Checked against 1261 streaming delta sequences: behaviour is unchanged except where the delta that creates the tool call carries a null name.
Seen in #29686 with a custom sglang build.
A web search is not something that can be cited. The search engine
returns a title, a link and a one line snippet for each hit, and the
model never opens any of those pages. Emitting them as citation sources
produced one <source> tag per result, all named search_web with empty
bodies, and the citation template then instructed the model to cite them
by id. Models either hesitated visibly or attached an id to content from
a different result, which the citations panel then resolved to a title
that looked plausible, so the misattribution read as correct.
Web search results now stay in the tool output the model reads, and stop
being offered as things to cite. Where the model needs to cite a page it
calls fetch_url, whose citation names the URL and already works.
Web search results no longer appear in the citations panel. That is the
point of the change: the panel was offering pages that nothing had read.
Scoped to the native tool-calling path. The legacy handler cites every
tool result as one opaque source and does not single out web search, so
it is left alone rather than special-cased.
Every chat completion request re-loads the target conversation's entire
message history from the database inside drain_approved_tool_calls() before
discovering there is nothing to drain: a fresh message always points at a
newly minted assistant message with no stored output, so the full-history
read (one SELECT of every chat_message row plus building the message map,
uncached, on top of the identical read process_chat_payload already did) is
pure overhead on every message.
Queued tool approvals can only ever be acted on by a resume or continue
request, and exactly those requests carry assistant_message_id in their
payload. The drain now returns early when the field is absent, removing one
O(conversation length) query per chat message while resume, continue, reject
and pause flows behave exactly as before, independent of the approval mode.
* fix: a rejected ask_user call ending the turn with no reply
The documented behaviour of the built-in ask_user tool is that a call breaking its rules comes back to the model as an error. Instead the reply stopped there: the error was recorded as the tool result, the model was never asked again, and the user was left with a dead chat and no answer.
The rejection is now handed back like any other failed tool result, so the model sees it and can correct itself within the normal tool-call iteration limit. Any ordinary tool the model emitted in the same turn still runs.
A call rejected for arriving alongside other ask_user calls also left those siblings without a result, which the UI shows as a tool call stuck on "Executing..." forever. Every invalid call now gets its own result. Two ask_user calls on their own also reported the wrong reason, saying the call must be made by itself rather than that only one is allowed per turn.
Fixes#29077
* Keep the original ask_user validation order
Restores the pre-existing check order and the unchanged output id fallback, so this change only alters the return shape needed for staging, and trims a comment that narrated the lines below it.
* Correct the ask_user sibling-call error message
* Shorten the ask_user sibling-call error message
* Drop the untrue sibling-call claim from the ask_user error
The ask_user error text told the user and the model "The others ran.", but that sentence is written into the turn output before any sibling tool call has executed, so it can be plainly false. Under a saved chat with tool approval set to ask, the turn pauses right afterwards and the siblings sit at pending/queued, so the user reads "The others ran" directly above the approval prompt for tools that have not run, and reads it again beside the rejection result if they decline. When the model sends two ask_user calls and nothing else, nothing runs at all and the sentence is emitted twice.
The staging helper cannot see what happens to the sibling calls, so it no longer narrates it. The remaining two sentences hold in every flow: ask_user really is dropped from the executed calls whenever this error is set, and calling it on its own is always the right retry.
* fix: stop streaming responses breaking on a duplicate output key
With reasoning-capable models the chat froze mid-stream: the first chunk of the answer appeared, nothing followed, and the whole message only showed up once generation finished. The browser console showed a Svelte each_key_duplicate error.
When a stream event addresses an output slot past the end of the array, the missing slots were filled with the event's own item, id included, so a gap of two left two entries claiming the same id. The next chunk for that item was matched by id, landed in the first of the two, and the rendered list ended up with two items sharing a key, which Svelte refuses to update.
Only the addressed slot now takes the event's item, and the slots before it are anonymous placeholders. Replayed the reported event sequence against the real code: keys are unique again and the chunks stay in order instead of being split across the copies.
* fix: stream reasoning deltas when the provider also sends reasoning_details
Providers such as OpenRouter emit reasoning_details alongside the reasoning
text on the same delta. Merging those details cleared the pending event
unconditionally, discarding the response.reasoning_text.delta that had just
been built, so the client received no reasoning until the response completed
and the thinking block only appeared after generation finished.
The event is now only dropped when the details were all there was to report.
Details persistence is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uuEg4AXPs9zE3vVUfN1Fj
---------
Co-authored-by: Claude <noreply@anthropic.com>
After a tool call, the model's thinking was streamed into the chat as if it were the main response, and only jumped into the collapsed Thoughts section once the turn finished. Every further tool call repeated it.
Each tool round appended an empty placeholder message item to the output and sent it to the browser, then dropped it again from the copy used to offset the next round's item indices. The browser therefore held one item more than the backend counted, so the first thinking chunk of the next round was written into that leftover message item and rendered as normal text until the finished output replaced it.
The placeholder is removed. It was never needed: a message item is already created when actual content arrives, and dropping it also stops an empty assistant message being sent back to the model on the follow-up request.
Co-authored-by: Claude <noreply@anthropic.com>
Any `<$...>` run in a chat message was treated as an inline skill mention and removed before the request reached the model, so text like `<$(=MonthStart($(vMaxMonthEndINC)))"}, [Registration day] >` silently vanished mid-message and the model only saw the part before it.
The mention regexes accepted any character except `|` and `>` as the skill id, so they matched far more than real mentions. Skill ids are already validated as `[a-z0-9_-]+` when a skill is created, so both regexes now require that charset. Ordinary text passes through untouched while `<$id>`, `<$id|Label>` and `</id|Label>` still resolve and strip as before.
Verified against the reported message (now preserved verbatim) and the three mention forms.
Co-authored-by: Claude <noreply@anthropic.com>
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.
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.
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.
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.
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